feat: 토너먼트 조별 전투 로그를 복원한다
예선과 본선 조별 경기의 최신 전투 로그를 저장하고 화면에 여덟 조 모두 표시한다. 결선은 최근 완료 경기와 종료 후 결승 로그를 유지하며 기존 대진 난수 순서를 보존한다.
This commit is contained in:
@@ -84,6 +84,7 @@ const zMatch = z.object({
|
||||
id: z.number().int().positive(),
|
||||
stage: z.number().int().min(0),
|
||||
roundIndex: z.number().int().min(0),
|
||||
groupId: z.number().int().min(0).max(17).optional(),
|
||||
attackerId: z.number().int().positive(),
|
||||
defenderId: z.number().int().positive(),
|
||||
winnerId: z.number().int().positive().optional(),
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface TournamentMatchEntry {
|
||||
id: number;
|
||||
stage: number;
|
||||
roundIndex: number;
|
||||
/** Ref fight{group}.txt와 같이 조별전의 최신 로그를 식별합니다. */
|
||||
groupId?: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
winnerId?: number;
|
||||
|
||||
@@ -39,6 +39,35 @@ import {
|
||||
type TournamentPrismaClient,
|
||||
} from './workerHelpers.js';
|
||||
|
||||
const persistLatestGroupFightLogs = async (
|
||||
store: TournamentStore,
|
||||
stage: 2 | 4,
|
||||
outcomes: TournamentMatchOutcome[]
|
||||
): Promise<void> => {
|
||||
if (outcomes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = await store.getMatches();
|
||||
const replacedGroupIds = new Set(outcomes.map((outcome) => outcome.groupId));
|
||||
const retained = matches.filter(
|
||||
(match) => match.stage !== stage || match.groupId === undefined || !replacedGroupIds.has(match.groupId)
|
||||
);
|
||||
const latest = outcomes.map((outcome): TournamentMatchEntry => ({
|
||||
id: stage * 100 + outcome.groupId + 1,
|
||||
stage,
|
||||
roundIndex: outcome.groupId,
|
||||
groupId: outcome.groupId,
|
||||
attackerId: outcome.attackerId,
|
||||
defenderId: outcome.defenderId,
|
||||
winnerId: outcome.winnerId,
|
||||
log: outcome.log,
|
||||
logEntries: outcome.logEntries,
|
||||
lastEnergy: outcome.lastEnergy,
|
||||
}));
|
||||
await store.setMatches(retained.concat(latest));
|
||||
};
|
||||
|
||||
export const applyBattle = async (
|
||||
store: TournamentStore,
|
||||
state: TournamentState,
|
||||
@@ -222,6 +251,7 @@ export const applyPreBattleStage = async (
|
||||
outcomes.push(result.outcome);
|
||||
}
|
||||
await store.setParticipants(updated);
|
||||
await persistLatestGroupFightLogs(store, 2, outcomes);
|
||||
|
||||
if (outcomes.length > 0) {
|
||||
await Promise.all(
|
||||
@@ -366,6 +396,7 @@ export const applyPreBattleStage = async (
|
||||
outcomes.push(result.outcome);
|
||||
}
|
||||
await store.setParticipants(updated);
|
||||
await persistLatestGroupFightLogs(store, 4, outcomes);
|
||||
|
||||
if (outcomes.length > 0) {
|
||||
await Promise.all(
|
||||
@@ -417,13 +448,13 @@ export const applyPreBattleStage = async (
|
||||
|
||||
if (state.stage === 5) {
|
||||
const matches = await store.getMatches();
|
||||
if (matches.length === 0) {
|
||||
if (!matches.some((match) => match.stage >= 7)) {
|
||||
const fixedMatches = buildFinal16MatchesFromGroups(participants);
|
||||
const participantIds = fixedMatches
|
||||
? fixedMatches.flatMap((entry) => [entry.attackerId, entry.defenderId])
|
||||
: pickFinalists(state, participants);
|
||||
const initialMatches = fixedMatches ?? buildInitialMatches(state, baseSeed, participantIds);
|
||||
await store.setMatches(initialMatches);
|
||||
await store.setMatches(matches.concat(initialMatches));
|
||||
}
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
|
||||
@@ -371,9 +371,14 @@ export const fillParticipants = async (options: {
|
||||
};
|
||||
|
||||
export type TournamentMatchOutcome = {
|
||||
groupId: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
result: 'attacker' | 'defender' | 'draw';
|
||||
winnerId?: number;
|
||||
log: string[];
|
||||
logEntries: NonNullable<TournamentMatchEntry['logEntries']>;
|
||||
lastEnergy?: NonNullable<TournamentMatchEntry['lastEnergy']>;
|
||||
};
|
||||
|
||||
export const applyGroupMatch = (
|
||||
@@ -419,10 +424,18 @@ export const applyGroupMatch = (
|
||||
|
||||
const glDelta = Math.round((result.totalDamage.defender - result.totalDamage.attacker) / 50);
|
||||
|
||||
const lastLogEntry = result.logEntries.at(-1);
|
||||
const outcome: TournamentMatchOutcome = {
|
||||
groupId: matchIndex,
|
||||
attackerId: attacker.id,
|
||||
defenderId: defender.id,
|
||||
result: result.draw ? 'draw' : result.winnerId === attacker.id ? 'attacker' : 'defender',
|
||||
winnerId: result.winnerId ?? undefined,
|
||||
log: result.log,
|
||||
logEntries: result.logEntries,
|
||||
lastEnergy: lastLogEntry
|
||||
? { attacker: lastLogEntry.attackerEnergy, defender: lastLogEntry.defenderEnergy }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -572,7 +585,10 @@ export const buildNextMatches = (stage: number, matches: TournamentMatchEntry[])
|
||||
throw new Error('다음 라운드를 만들 수 없습니다.');
|
||||
}
|
||||
|
||||
const nextIdBase = matches.reduce((max, entry) => Math.max(max, entry.id), 0) + 1;
|
||||
// 조별 최신 로그도 matches projection에 함께 보존하지만 결선 match ID는
|
||||
// 전투 RNG seed의 일부이므로 기존 결선 경기만으로 연속 번호를 계산합니다.
|
||||
const nextIdBase =
|
||||
matches.filter((entry) => entry.stage >= 7).reduce((max, entry) => Math.max(max, entry.id), 0) + 1;
|
||||
const nextStageValue = nextStage(stage);
|
||||
const result: TournamentMatchEntry[] = [];
|
||||
|
||||
|
||||
@@ -179,6 +179,53 @@ const setTournamentFixture = async (redis: MemoryRedis, state: Record<string, un
|
||||
};
|
||||
|
||||
describe('tournament router permissions and mutations', () => {
|
||||
it('returns persisted group fight logs to an authenticated tournament viewer', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1');
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 2,
|
||||
phase: 1,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:matches',
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 201,
|
||||
stage: 2,
|
||||
roundIndex: 0,
|
||||
groupId: 0,
|
||||
attackerId: 11,
|
||||
defenderId: 12,
|
||||
winnerId: 11,
|
||||
log: ['<S>●</> <Y>후보11</> <S>승리</>!'],
|
||||
},
|
||||
])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general, buildGeneral(11, 'user-11')], userId: 'user-1' })
|
||||
);
|
||||
|
||||
const snapshot = await caller.tournament.getSnapshot();
|
||||
|
||||
expect(snapshot.matches).toEqual([
|
||||
expect.objectContaining({
|
||||
stage: 2,
|
||||
groupId: 0,
|
||||
attackerId: 11,
|
||||
defenderId: 12,
|
||||
winnerId: 11,
|
||||
log: ['<S>●</> <Y>후보11</> <S>승리</>!'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('charges the authenticated general once when joining', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
|
||||
@@ -359,10 +359,23 @@ describe('tournament worker (in-memory)', () => {
|
||||
const prisma = createPrismaMock({ baseSeed: 'seed' });
|
||||
const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' });
|
||||
const matches = await store.getMatches();
|
||||
const preliminaryLogs = matches.filter((match) => match.stage === 2);
|
||||
const finalGroupLogs = matches.filter((match) => match.stage === 4);
|
||||
const knockoutMatches = matches.filter((match) => match.stage >= 7);
|
||||
const finalMatches = matches.filter((match) => match.stage === 10);
|
||||
|
||||
expect(finalState.stage).toBe(0);
|
||||
expect(finalState.winnerId).toBe(15);
|
||||
expect(preliminaryLogs).toHaveLength(8);
|
||||
expect(finalGroupLogs).toHaveLength(8);
|
||||
expect(knockoutMatches).toHaveLength(15);
|
||||
expect(preliminaryLogs.map((match) => match.groupId).sort((a, b) => Number(a) - Number(b))).toEqual([
|
||||
0, 1, 2, 3, 4, 5, 6, 7,
|
||||
]);
|
||||
expect(finalGroupLogs.map((match) => match.groupId).sort((a, b) => Number(a) - Number(b))).toEqual([
|
||||
10, 11, 12, 13, 14, 15, 16, 17,
|
||||
]);
|
||||
expect([...preliminaryLogs, ...finalGroupLogs].every((match) => (match.log?.length ?? 0) >= 3)).toBe(true);
|
||||
expect(finalMatches).toHaveLength(1);
|
||||
expect(finalMatches[0]).toMatchObject({
|
||||
attackerId: 15,
|
||||
|
||||
Reference in New Issue
Block a user