From d4d373c4bdc6f9714642724d23d20773d597ebd4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 09:36:44 +0000 Subject: [PATCH 1/4] fix: match surprise attack boundaries --- app/game-engine/src/turn/inMemoryWorld.ts | 5 +- .../logic/src/actions/turn/nation/che_급습.ts | 5 +- .../src/turn-differential/coreCommandTrace.ts | 1 + ...urnCommandNationMatrix.integration.test.ts | 335 +++++++++++++++++- 4 files changed, 336 insertions(+), 10 deletions(-) diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 72dda91..16f2ce2 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -482,12 +482,9 @@ export class InMemoryTurnWorld { } getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null { - if (srcNationId === destNationId) { - return null; - } const key = buildDiplomacyKey(srcNationId, destNationId); let entry = this.diplomacy.get(key); - if (!entry && this.nations.has(srcNationId) && this.nations.has(destNationId)) { + if (!entry && srcNationId !== destNationId && this.nations.has(srcNationId) && this.nations.has(destNationId)) { entry = buildDefaultDiplomacy(srcNationId, destNationId); this.diplomacy.set(key, entry); this.dirtyDiplomacyKeys.add(key); diff --git a/packages/logic/src/actions/turn/nation/che_급습.ts b/packages/logic/src/actions/turn/nation/che_급습.ts index efa5adf..b7e4542 100644 --- a/packages/logic/src/actions/turn/nation/che_급습.ts +++ b/packages/logic/src/actions/turn/nation/che_급습.ts @@ -26,10 +26,7 @@ import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; const ARGS_SCHEMA = z.object({ - destNationId: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), + destNationId: z.number().int().positive(), }); export type RaidArgs = z.infer; diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index dc7a799..cc869d8 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -69,6 +69,7 @@ export interface TurnCommandFixtureRequest { messageAfterId?: number; generalCooldowns?: GeneralCooldownSelector[]; nationCooldowns?: NationCooldownSelector[]; + diplomacyPairs?: Array<{ fromNationId: number; toNationId: number }>; }; } diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index c4def10..27d8de9 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -308,6 +308,19 @@ const buildRequest = ( dead: 0, ...fixturePatches.diplomacy?.['2:1'], }, + ...Object.entries(fixturePatches.diplomacy ?? {}) + .filter(([key]) => !['1:2', '2:1'].includes(key)) + .map(([key, patch]) => { + const [fromNationId, toNationId] = key.split(':').map(Number); + return { + fromNationId, + toNationId, + state: 3, + term: 0, + dead: 0, + ...patch, + }; + }), ], }, observe: { @@ -331,12 +344,23 @@ const buildRequest = ( ...(fixturePatches.randomFoundingCandidateCityIds ?? []).filter((id) => id !== 3 && id !== 70), ], nationIds: [1, 2], - ...(action === 'che_백성동원' || action === 'che_이호경식' + ...(fixturePatches.diplomacy + ? { + diplomacyPairs: Object.keys(fixturePatches.diplomacy) + .filter((key) => !['1:2', '2:1'].includes(key)) + .map((key) => { + const [fromNationId, toNationId] = key.split(':').map(Number); + return { fromNationId, toNationId }; + }), + } + : {}), + ...(action === 'che_백성동원' || action === 'che_이호경식' || action === 'che_급습' ? { nationCooldowns: [ { nationId: 1, - actionName: action === 'che_백성동원' ? '백성동원' : '이호경식', + actionName: + action === 'che_백성동원' ? '백성동원' : action === 'che_이호경식' ? '이호경식' : '급습', }, ], } @@ -3265,3 +3289,310 @@ integration('nation degrade-relations target, diplomacy, front, and cooldown bou 120_000 ); }); + +const surpriseAttackBoundaryCases: Array<{ + name: string; + destNationId: unknown; + fixturePatches?: FixturePatches; + completed?: boolean; + coreResolution?: boolean; + expectedForwardState?: number; + expectedReverseState?: number; + expectedForwardTerm?: number; + expectedReverseTerm?: number; + expectedSourceFront?: number; + expectedDestFront?: number; + expectedStrategicCommandLimit?: number; + expectedPostReqTurn?: number; + expectedDestLogGeneralId?: number; +}> = [ + { + name: 'rejects a missing destination nation', + destNationId: 99, + }, + { + name: 'rejects a numeric string destination nation ID', + destNationId: '2', + }, + { + name: 'rejects a fractional destination nation ID', + destNationId: 2.9, + }, + { + name: 'rejects a source city occupied by another nation', + destNationId: 2, + fixturePatches: { + cities: { 3: { nationId: 2 } }, + diplomacy: { '1:2': { state: 1, term: 12 } }, + }, + }, + { + name: 'rejects an actor below chief rank', + destNationId: 2, + fixturePatches: { + generals: { 1: { officerLevel: 4, officerCityId: 0 } }, + diplomacy: { '1:2': { state: 1, term: 12 } }, + }, + coreResolution: false, + }, + { + name: 'rejects a remaining strategic-command delay', + destNationId: 2, + fixturePatches: { + nations: { 1: { strategicCommandLimit: 1 } }, + diplomacy: { '1:2': { state: 1, term: 12 } }, + }, + }, + { + name: 'rejects a forward diplomacy state other than declaration', + destNationId: 2, + fixturePatches: { + diplomacy: { '1:2': { state: 0, term: 12 } }, + }, + }, + { + name: 'rejects declaration term eleven at the lower boundary', + destNationId: 2, + fixturePatches: { + diplomacy: { '1:2': { state: 1, term: 11 } }, + }, + }, + { + name: 'allows an unsupplied source city and preserves both fronts', + destNationId: 2, + fixturePatches: { + cities: { + 3: { supplyState: 0, frontState: 2 }, + 70: { frontState: 3 }, + }, + diplomacy: { + '1:2': { state: 1, term: 12 }, + '2:1': { state: 1, term: 12 }, + }, + }, + completed: true, + expectedForwardState: 1, + expectedReverseState: 1, + expectedForwardTerm: 9, + expectedReverseTerm: 9, + expectedSourceFront: 2, + expectedDestFront: 3, + expectedPostReqTurn: 126, + }, + { + name: 'subtracts three from a disallowed reverse state into a negative term', + destNationId: 2, + fixturePatches: { + diplomacy: { + '1:2': { state: 1, term: 15 }, + '2:1': { state: 2, term: 2 }, + }, + }, + completed: true, + expectedForwardState: 1, + expectedReverseState: 2, + expectedForwardTerm: 12, + expectedReverseTerm: -1, + expectedSourceFront: 0, + expectedDestFront: 1, + expectedPostReqTurn: 126, + }, + { + name: 'allows the source nation when a self-diplomacy row is present', + destNationId: 1, + fixturePatches: { + diplomacy: { + '1:1': { state: 1, term: 12 }, + }, + }, + completed: true, + expectedForwardState: 1, + expectedReverseState: 1, + expectedForwardTerm: 9, + expectedReverseTerm: 9, + expectedSourceFront: 0, + expectedDestFront: 0, + expectedPostReqTurn: 126, + expectedDestLogGeneralId: 1, + }, + { + name: 'applies the strategist command and global-delay modifiers', + destNationId: 2, + fixturePatches: { + nations: { 1: { typeCode: 'che_종횡가' } }, + diplomacy: { + '1:2': { state: 1, term: 12 }, + '2:1': { state: 1, term: 12 }, + }, + }, + completed: true, + expectedForwardState: 1, + expectedReverseState: 1, + expectedForwardTerm: 9, + expectedReverseTerm: 9, + expectedSourceFront: 0, + expectedDestFront: 1, + expectedStrategicCommandLimit: 5, + expectedPostReqTurn: 95, + }, + { + name: 'uses the actual eleven-general count for the nation cooldown', + destNationId: 2, + fixturePatches: { + nations: { 1: { generalCount: 11 } }, + generals: { + 4: { nationId: 1 }, + 5: { nationId: 1 }, + 6: { nationId: 1 }, + 7: { nationId: 1 }, + 8: { nationId: 1 }, + 9: { nationId: 1 }, + 10: { nationId: 1 }, + 11: { nationId: 1 }, + 12: { nationId: 1 }, + }, + diplomacy: { + '1:2': { state: 1, term: 12 }, + '2:1': { state: 1, term: 12 }, + }, + }, + completed: true, + expectedForwardState: 1, + expectedReverseState: 1, + expectedForwardTerm: 9, + expectedReverseTerm: 9, + expectedSourceFront: 0, + expectedDestFront: 1, + expectedPostReqTurn: 133, + }, +]; + +integration('nation surprise-attack target, diplomacy-term, front, and cooldown boundaries', () => { + it.each(surpriseAttackBoundaryCases)( + '$name matches legacy fallback, diplomacy, fronts, cooldown, logs, RNG, and semantic delta', + async ({ + destNationId, + fixturePatches, + completed = false, + coreResolution = true, + expectedForwardState, + expectedReverseState, + expectedForwardTerm, + expectedReverseTerm, + expectedSourceFront, + expectedDestFront, + expectedStrategicCommandLimit = 9, + expectedPostReqTurn, + expectedDestLogGeneralId = 2, + }) => { + const request = buildRequest('che_급습', { destNationID: destNationId }, fixturePatches); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed }); + if (coreResolution) { + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_급습', + actionKey: completed ? 'che_급습' : '휴식', + usedFallback: !completed, + }); + } else { + expect(core.execution.outcome).toBeUndefined(); + } + + for (const snapshot of [reference, core]) { + const actualDestNationId = destNationId === 1 ? 1 : 2; + const actualDestCityId = actualDestNationId === 1 ? 3 : 70; + const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); + const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); + const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1); + const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1); + const sourceCityBefore = snapshot.before.cities.find((entry) => entry.id === 3); + const sourceCityAfter = snapshot.after.cities.find((entry) => entry.id === 3); + const destCityBefore = snapshot.before.cities.find((entry) => entry.id === actualDestCityId); + const destCityAfter = snapshot.after.cities.find((entry) => entry.id === actualDestCityId); + const forwardBefore = snapshot.before.diplomacy.find( + (entry) => entry.fromNationId === 1 && entry.toNationId === actualDestNationId + ); + const forwardAfter = snapshot.after.diplomacy.find( + (entry) => entry.fromNationId === 1 && entry.toNationId === actualDestNationId + ); + const reverseBefore = snapshot.before.diplomacy.find( + (entry) => entry.fromNationId === actualDestNationId && entry.toNationId === 1 + ); + const reverseAfter = snapshot.after.diplomacy.find( + (entry) => entry.fromNationId === actualDestNationId && entry.toNationId === 1 + ); + + expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); + expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); + expect( + readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience') + ).toBe(completed ? 5 : 0); + expect( + readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication') + ).toBe(completed ? 5 : 0); + + if (completed) { + expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(expectedStrategicCommandLimit); + expect(readNumericField(forwardAfter, 'state')).toBe(expectedForwardState); + expect(readNumericField(reverseAfter, 'state')).toBe(expectedReverseState); + expect(readNumericField(forwardAfter, 'term')).toBe(expectedForwardTerm); + expect(readNumericField(reverseAfter, 'term')).toBe(expectedReverseTerm); + expect(readNumericField(sourceCityAfter, 'frontState')).toBe(expectedSourceFront); + expect(readNumericField(destCityAfter, 'frontState')).toBe(expectedDestFront); + + const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + expect(addedLogs.map((entry) => entry.text)).toEqual( + expect.arrayContaining([expect.stringContaining('급습 발동')]) + ); + expect( + addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('급습')) + ).toBe(true); + expect( + addedLogs.some( + (entry) => + entry.generalId === expectedDestLogGeneralId && + String(entry.text).includes('아국에 급습이 발동되었습니다.') + ) + ).toBe(true); + } else { + expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( + readNumericField(nationBefore, 'strategicCommandLimit') + ); + expect(forwardAfter).toEqual(forwardBefore); + expect(reverseAfter).toEqual(reverseBefore); + expect(readNumericField(sourceCityAfter, 'frontState')).toBe( + readNumericField(sourceCityBefore, 'frontState') + ); + expect(readNumericField(destCityAfter, 'frontState')).toBe( + readNumericField(destCityBefore, 'frontState') + ); + } + } + + if (completed) { + const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!; + expect(reference.after.world.nationCooldowns).toEqual([ + { + nationId: 1, + actionName: '급습', + nextAvailableTurn: expectedNextAvailableTurn, + }, + ]); + expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns); + } + expect(reference.rng).toEqual([]); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); From 27ace41bfe82a18e3052cd7d49a91ea7827c53c4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 09:58:08 +0000 Subject: [PATCH 2/4] test: cover desperate survival boundaries --- ...urnCommandNationMatrix.integration.test.ts | 311 +++++++++++++++++- 1 file changed, 309 insertions(+), 2 deletions(-) diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 27d8de9..3a56111 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -354,13 +354,19 @@ const buildRequest = ( }), } : {}), - ...(action === 'che_백성동원' || action === 'che_이호경식' || action === 'che_급습' + ...(action === 'che_백성동원' || action === 'che_이호경식' || action === 'che_급습' || action === 'che_필사즉생' ? { nationCooldowns: [ { nationId: 1, actionName: - action === 'che_백성동원' ? '백성동원' : action === 'che_이호경식' ? '이호경식' : '급습', + action === 'che_백성동원' + ? '백성동원' + : action === 'che_이호경식' + ? '이호경식' + : action === 'che_급습' + ? '급습' + : '필사즉생', }, ], } @@ -3596,3 +3602,304 @@ integration('nation surprise-attack target, diplomacy-term, front, and cooldown 120_000 ); }); + +const desperateSurvivalBoundaryCases: Array<{ + name: string; + fixturePatches?: FixturePatches; + outcome: 'fallback' | 'intermediate' | 'completed'; + coreResolution?: boolean; + expectedLastTerm?: number; + expectedStrategicCommandLimit?: number; + expectedPostReqTurn?: number; +}> = [ + { + name: 'rejects a nation without an outgoing war', + outcome: 'fallback', + }, + { + name: 'rejects a reverse-only war', + fixturePatches: { + diplomacy: { + '1:2': { state: 3 }, + '2:1': { state: 0 }, + }, + }, + outcome: 'fallback', + }, + { + name: 'rejects a source city occupied by another nation', + fixturePatches: { + cities: { 3: { nationId: 2 } }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'fallback', + }, + { + name: 'rejects an actor below chief rank', + fixturePatches: { + generals: { 1: { officerLevel: 4, officerCityId: 0 } }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'fallback', + coreResolution: false, + }, + { + name: 'rejects a remaining strategic-command delay', + fixturePatches: { + nations: { 1: { strategicCommandLimit: 1 } }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'fallback', + }, + { + name: 'allows an unsupplied city at the first intermediate turn', + fixturePatches: { + cities: { 3: { supplyState: 0 } }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'intermediate', + expectedLastTerm: 1, + }, + { + name: 'advances the second intermediate turn without side effects', + fixturePatches: { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '필사즉생', arg: {}, term: 1 }, + }, + }, + }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'intermediate', + expectedLastTerm: 2, + }, + { + name: 'completes with an outgoing war even when the reverse relation is trade', + fixturePatches: { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '필사즉생', arg: {}, term: 2 }, + }, + }, + }, + diplomacy: { + '1:2': { state: 0, term: 0 }, + '2:1': { state: 3, term: 0 }, + }, + }, + outcome: 'completed', + expectedPostReqTurn: 87, + }, + { + name: 'preserves training and morale values already above one hundred', + fixturePatches: { + generals: { + 1: { train: 120, atmos: 110 }, + 3: { train: 105, atmos: 130 }, + }, + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '필사즉생', arg: {}, term: 2 }, + }, + }, + }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'completed', + expectedPostReqTurn: 87, + }, + { + name: 'accepts an explicit self-war as the outgoing war relation', + fixturePatches: { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '필사즉생', arg: {}, term: 2 }, + }, + }, + }, + diplomacy: { + '1:1': { state: 0 }, + }, + }, + outcome: 'completed', + expectedPostReqTurn: 87, + }, + { + name: 'restarts at term one after a different command interrupted the stack', + fixturePatches: { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '급습', arg: { destNationID: 2 }, term: 2 }, + }, + }, + }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'intermediate', + expectedLastTerm: 1, + }, + { + name: 'applies the strategist command and global-delay modifiers', + fixturePatches: { + nations: { + 1: { + typeCode: 'che_종횡가', + turnLastByOfficerLevel: { + 12: { command: '필사즉생', arg: {}, term: 2 }, + }, + }, + }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'completed', + expectedStrategicCommandLimit: 5, + expectedPostReqTurn: 65, + }, + { + name: 'uses the actual eleven-general count for the nation cooldown', + fixturePatches: { + nations: { + 1: { + generalCount: 11, + turnLastByOfficerLevel: { + 12: { command: '필사즉생', arg: {}, term: 2 }, + }, + }, + }, + generals: { + 4: { nationId: 1 }, + 5: { nationId: 1 }, + 6: { nationId: 1 }, + 7: { nationId: 1 }, + 8: { nationId: 1 }, + 9: { nationId: 1 }, + 10: { nationId: 1 }, + 11: { nationId: 1 }, + 12: { nationId: 1 }, + }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'completed', + expectedPostReqTurn: 92, + }, +]; + +integration('nation desperate-survival multistep, diplomacy, effects, and cooldown boundaries', () => { + it.each(desperateSurvivalBoundaryCases)( + '$name matches legacy progress, effects, cooldown, logs, RNG, and semantic delta', + async ({ + fixturePatches, + outcome, + coreResolution = true, + expectedLastTerm, + expectedStrategicCommandLimit = 9, + expectedPostReqTurn, + }) => { + const request = buildRequest('che_필사즉생', undefined, fixturePatches); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const completed = outcome === 'completed'; + const fallback = outcome === 'fallback'; + + expect(reference.execution.outcome).toMatchObject({ completed }); + if (coreResolution) { + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_필사즉생', + actionKey: fallback ? '휴식' : 'che_필사즉생', + usedFallback: fallback, + ...(fallback ? {} : { completed }), + }); + } else { + expect(core.execution.outcome).toBeUndefined(); + } + + for (const snapshot of [reference, core]) { + const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); + const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); + const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1); + const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1); + const teammateBefore = snapshot.before.generals.find((entry) => entry.id === 3); + const teammateAfter = snapshot.after.generals.find((entry) => entry.id === 3); + const foreignBefore = snapshot.before.generals.find((entry) => entry.id === 2); + const foreignAfter = snapshot.after.generals.find((entry) => entry.id === 2); + + expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); + expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); + expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe( + completed ? 15 : 0 + ); + expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe( + completed ? 15 : 0 + ); + expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( + completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit') + ); + + for (const [before, after] of [ + [actorBefore, actorAfter], + [teammateBefore, teammateAfter], + ] as const) { + expect(readNumericField(after, 'train')).toBe( + completed ? Math.max(100, readNumericField(before, 'train')) : readNumericField(before, 'train') + ); + expect(readNumericField(after, 'atmos')).toBe( + completed ? Math.max(100, readNumericField(before, 'atmos')) : readNumericField(before, 'atmos') + ); + } + expect(readNumericField(foreignAfter, 'train')).toBe(readNumericField(foreignBefore, 'train')); + expect(readNumericField(foreignAfter, 'atmos')).toBe(readNumericField(foreignBefore, 'atmos')); + + if (completed) { + const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + expect(addedLogs.map((entry) => entry.text)).toEqual( + expect.arrayContaining([expect.stringContaining('필사즉생 발동')]) + ); + expect( + addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('필사즉생')) + ).toBe(true); + } + } + + if (outcome === 'intermediate') { + expect(reference.execution.outcome).toMatchObject({ + lastTurn: { + command: '필사즉생', + term: expectedLastTerm, + }, + }); + expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({ + command: '필사즉생', + term: expectedLastTerm, + }); + } + if (completed) { + const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!; + expect(reference.after.world.nationCooldowns).toEqual([ + { + nationId: 1, + actionName: '필사즉생', + nextAvailableTurn: expectedNextAvailableTurn, + }, + ]); + expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns); + } + expect(reference.rng).toEqual([]); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); From 465d9f02348678980c495ed3b96748a92f17c1d5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 10:01:38 +0000 Subject: [PATCH 3/4] feat: restore gateway logout and main status --- app/game-api/src/router/general/index.ts | 117 ++++++ .../test/mainFrontStatusRouter.test.ts | 127 +++++++ .../src/components/main/MainFrontStatus.vue | 99 +++++ app/game-frontend/src/stores/mainDashboard.ts | 78 +++- app/game-frontend/src/views/MainView.vue | 84 ++++- app/gateway-api/test/authFlow.test.ts | 18 + app/gateway-frontend/e2e/logout.spec.ts | 135 +++++++ .../e2e/playwright.config.mjs | 2 +- app/gateway-frontend/src/views/LobbyView.vue | 80 +++- packages/infra/src/db.ts | 2 + .../main-front-status.playwright.config.mjs | 54 +++ .../main-front-status.spec.ts | 341 ++++++++++++++++++ .../frontend-legacy-parity/map-trend.spec.ts | 10 + .../reference-gateway-logout.mjs | 63 ++++ .../reference-main-front-status.mjs | 74 ++++ .../run-main-front-status-live.mjs | 57 +++ 16 files changed, 1314 insertions(+), 27 deletions(-) create mode 100644 app/game-api/test/mainFrontStatusRouter.test.ts create mode 100644 app/game-frontend/src/components/main/MainFrontStatus.vue create mode 100644 app/gateway-frontend/e2e/logout.spec.ts create mode 100644 tools/frontend-legacy-parity/main-front-status.playwright.config.mjs create mode 100644 tools/frontend-legacy-parity/main-front-status.spec.ts create mode 100644 tools/frontend-legacy-parity/reference-gateway-logout.mjs create mode 100644 tools/frontend-legacy-parity/reference-main-front-status.mjs create mode 100644 tools/frontend-legacy-parity/run-main-front-status-live.mjs diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 8294f99..32ab3ed 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -5,7 +5,9 @@ import { LogCategory, LogScope } from '@sammo-ts/infra'; import { asRecord } from '@sammo-ts/common'; import { authedProcedure, router } from '../../trpc.js'; +import { resolveAccessWindows } from '../../services/generalAccess.js'; import { getMyGeneral } from '../shared/general.js'; +import { resolveNationNotice } from '../nation/shared.js'; const zGeneralSettings = z.object({ tnmt: z.number().int().optional(), @@ -381,4 +383,119 @@ export const generalRouter = router({ history: trimRecentRecords(history, input.lastWorldHistoryId), }; }), + getFrontStatus: authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + const worldState = await ctx.db.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { + tickSeconds: true, + meta: true, + }, + }); + if (!worldState) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); + } + + const now = new Date(); + const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta); + const [onlineAccess, ownNation, latestVote] = await Promise.all([ + ctx.db.generalAccessLog.findMany({ + where: { + lastRefresh: { + gte: scoreStartedAt, + }, + }, + select: { generalId: true }, + }), + me.nationId > 0 + ? ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }) + : Promise.resolve(null), + ctx.db.votePoll.findFirst({ + where: { + startAt: { lte: now }, + closedAt: null, + OR: [{ endAt: null }, { endAt: { gte: now } }], + }, + orderBy: { id: 'desc' }, + select: { + id: true, + title: true, + }, + }), + ]); + + const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId); + const onlineGenerals = + onlineGeneralIds.length > 0 + ? await ctx.db.general.findMany({ + where: { id: { in: onlineGeneralIds } }, + orderBy: { id: 'asc' }, + select: { + id: true, + name: true, + nationId: true, + }, + }) + : []; + const nationIds = [...new Set(onlineGenerals.map((general) => general.nationId).filter((id) => id > 0))]; + const nations = + nationIds.length > 0 + ? await ctx.db.nation.findMany({ + where: { id: { in: nationIds } }, + select: { + id: true, + name: true, + }, + }) + : []; + const nationNames = new Map(nations.map((nation) => [nation.id, nation.name])); + const onlineByNation = new Map(); + for (const general of onlineGenerals) { + const bucket = onlineByNation.get(general.nationId) ?? []; + bucket.push(general); + onlineByNation.set(general.nationId, bucket); + } + const onlineNations = [...onlineByNation.entries()] + .sort((left, right) => right[1].length - left[1].length || left[0] - right[0]) + .map(([nationId]) => `【${nationId === 0 ? '재야' : (nationNames.get(nationId) ?? `세력 ${nationId}`)}】`) + .join(', '); + const myOnlineGenerals = onlineGenerals + .filter((general) => general.nationId === me.nationId) + .map((general) => general.name) + .join(', '); + const myVote = latestVote + ? await ctx.db.vote.findFirst({ + where: { + voteId: latestVote.id, + generalId: me.id, + }, + select: { id: true }, + }) + : null; + const worldMeta = asRecord(worldState.meta); + const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime; + const parsedLastExecuted = + typeof rawLastExecuted === 'string' || rawLastExecuted instanceof Date ? new Date(rawLastExecuted) : null; + + return { + onlineUserCount: onlineGenerals.length, + onlineNations, + onlineGenerals: myOnlineGenerals, + nationNotice: ownNation ? resolveNationNotice(asRecord(ownNation.meta)) : '', + lastExecuted: + parsedLastExecuted && Number.isFinite(parsedLastExecuted.getTime()) + ? parsedLastExecuted.toISOString() + : null, + latestVote: latestVote + ? { + id: latestVote.id, + title: latestVote.title, + hasVoted: Boolean(myVote), + } + : null, + }; + }), }); diff --git a/app/game-api/test/mainFrontStatusRouter.test.ts b/app/game-api/test/mainFrontStatusRouter.test.ts new file mode 100644 index 0000000..ee10cff --- /dev/null +++ b/app/game-api/test/mainFrontStatusRouter.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; + +import type { DatabaseClient, GameApiContext } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: 'front-status-owner', + user: { + id: 'owner', + username: 'owner', + displayName: 'Owner', + roles: [], + }, + sanctions: {}, +}; + +const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean } = {}) => + ({ + auth: options.auth === undefined ? auth : options.auth, + db: { + general: { + findFirst: vi.fn(async () => ({ + id: 7, + userId: 'owner', + nationId: 2, + })), + findMany: vi.fn(async () => [ + { id: 7, name: '유비', nationId: 2 }, + { id: 8, name: '관우', nationId: 2 }, + { id: 9, name: '조조', nationId: 3 }, + ]), + }, + worldState: { + findFirst: vi.fn(async () => ({ + tickSeconds: 3600, + meta: { + lastTurnTime: '2026-07-26T10:00:00.000Z', + }, + })), + }, + generalAccessLog: { + findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }]), + }, + nation: { + findUnique: vi.fn(async () => ({ + meta: { + notice: '

북벌 준비

', + }, + })), + findMany: vi.fn(async () => [ + { id: 2, name: '촉' }, + { id: 3, name: '위' }, + ]), + }, + votePoll: { + findFirst: vi.fn(async () => ({ + id: 12, + title: '다음 시즌 턴 시간', + })), + }, + vote: { + findFirst: vi.fn(async () => (options.hasVoted ? { id: 21 } : null)), + }, + } as unknown as DatabaseClient, + }) as GameApiContext; + +describe('general.getFrontStatus', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-26T10:30:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns ref-compatible current-turn online, nation notice, and new vote data', async () => { + const context = buildContext(); + const caller = appRouter.createCaller(context); + + const result = await caller.general.getFrontStatus(); + + expect(result).toEqual({ + onlineUserCount: 3, + onlineNations: '【촉】, 【위】', + onlineGenerals: '유비, 관우', + nationNotice: '

북벌 준비

', + lastExecuted: '2026-07-26T10:00:00.000Z', + latestVote: { + id: 12, + title: '다음 시즌 턴 시간', + hasVoted: false, + }, + }); + expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({ + where: { + lastRefresh: { + gte: new Date('2026-07-26T10:00:00.000Z'), + }, + }, + select: { generalId: true }, + }); + }); + + it('reports that the session-owned general already voted', async () => { + const caller = appRouter.createCaller(buildContext({ hasVoted: true })); + + await expect(caller.general.getFrontStatus()).resolves.toMatchObject({ + latestVote: { + id: 12, + hasVoted: true, + }, + }); + }); + + it('requires a game session and does not expose names or policy publicly', async () => { + const caller = appRouter.createCaller(buildContext({ auth: null })); + + await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); +}); diff --git a/app/game-frontend/src/components/main/MainFrontStatus.vue b/app/game-frontend/src/components/main/MainFrontStatus.vue new file mode 100644 index 0000000..85b39b5 --- /dev/null +++ b/app/game-frontend/src/components/main/MainFrontStatus.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index a79a0d5..c02c3c5 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -27,10 +27,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type BoardAccess = Awaited>; type ReservedTurnView = Awaited>[number]; type RecentRecord = Awaited>['global'][number]; + type FrontStatus = Awaited>; const loading = ref(false); const error = ref(null); const recordsError = ref(null); + const frontStatusError = ref(null); const realtimeEnabled = ref(true); const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle'); @@ -47,6 +49,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const globalRecords = ref([]); const generalRecords = ref([]); const worldHistory = ref([]); + const frontStatus = ref(null); + const surveyNotice = ref | null>(null); let lastGeneralRecordId = 0; let lastWorldHistoryId = 0; let recordGeneralId: number | null = null; @@ -199,6 +203,28 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; + const updateFrontStatus = (nextStatus: FrontStatus) => { + frontStatus.value = nextStatus; + const latestVote = nextStatus.latestVote; + if (!latestVote || latestVote.hasVoted || typeof window === 'undefined') { + surveyNotice.value = null; + return; + } + const serverId = session.profile?.split(':', 1)[0] ?? 'game'; + const storageKey = `state.${serverId}.lastVote`; + const lastSeenVoteId = Number.parseInt(window.localStorage.getItem(storageKey) ?? '0', 10); + if (latestVote.id <= (Number.isFinite(lastSeenVoteId) ? lastSeenVoteId : 0)) { + surveyNotice.value = null; + return; + } + window.localStorage.setItem(storageKey, latestVote.id.toString()); + surveyNotice.value = latestVote; + }; + + const dismissSurveyNotice = () => { + surveyNotice.value = null; + }; + const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => { const merged = new Map(current.map((entry) => [entry.id, entry])); for (const entry of incoming) { @@ -214,6 +240,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { lastGeneralRecordId = 0; lastWorldHistoryId = 0; recordGeneralId = id; + frontStatus.value = null; + surveyNotice.value = null; }; const loadMainData = async () => { @@ -223,6 +251,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { loading.value = true; error.value = null; recordsError.value = null; + frontStatusError.value = null; try { const context = await trpc.general.me.query(); @@ -256,19 +285,35 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { recordsError.value = resolveErrorMessage(err); return null; }); - const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns, records] = - await Promise.all([ - layoutPromise, - trpc.lobby.info.query(), - trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), - trpc.turns.getCommandTable.query({ generalId: id }), - trpc.messages.getRecent.query({ generalId: id }), - trpc.messages.getContacts.query({ generalId: id }), - trpc.board.getAccess.query(), - generalTurnsPromise, - nationTurnsPromise, - recordsPromise, - ]); + const frontStatusPromise = trpc.general.getFrontStatus.query().catch((err: unknown) => { + frontStatusError.value = resolveErrorMessage(err); + return null; + }); + const [ + layout, + lobby, + map, + commands, + messageData, + contacts, + access, + generalTurns, + nationTurns, + records, + nextFrontStatus, + ] = await Promise.all([ + layoutPromise, + trpc.lobby.info.query(), + trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), + trpc.turns.getCommandTable.query({ generalId: id }), + trpc.messages.getRecent.query({ generalId: id }), + trpc.messages.getContacts.query({ generalId: id }), + trpc.board.getAccess.query(), + generalTurnsPromise, + nationTurnsPromise, + recordsPromise, + frontStatusPromise, + ]); mapLayout.value = layout; lobbyInfo.value = lobby; @@ -290,6 +335,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { ); lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0); } + if (nextFrontStatus) { + updateFrontStatus(nextFrontStatus); + } if (initializedMailboxGeneralId !== id) { targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId; initializedMailboxGeneralId = id; @@ -639,6 +687,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { loading, error, recordsError, + frontStatusError, realtimeEnabled, realtimeStatus, generalContext, @@ -658,12 +707,15 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { globalRecords, generalRecords, worldHistory, + frontStatus, + surveyNotice, messageDraftText, targetMailbox, mailboxGroups, statusLine, realtimeLabel, setRealtimeEnabled, + dismissSurveyNotice, loadMainData, refreshMessages, sendMessage, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 023dde4..bdb24bd 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -1,5 +1,5 @@