diff --git a/app/game-engine/src/turn/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts index 0a7d915..dc10235 100644 --- a/app/game-engine/src/turn/monthlyInvaderAction.ts +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -443,7 +443,7 @@ export const createRaiseInvaderHandler = (options: { scope: LogScope.SYSTEM, category: LogCategory.HISTORY, text, - format: LogFormat.NOTICE_YEAR_MONTH, + format: LogFormat.YEAR_MONTH, year: environment.year, month: environment.month, }); @@ -524,7 +524,7 @@ export const createInvaderEndingHandler = (options: { scope: LogScope.SYSTEM, category: LogCategory.HISTORY, text, - format: LogFormat.NOTICE_YEAR_MONTH, + format: LogFormat.YEAR_MONTH, year: environment.year, month: environment.month, }); diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts index 11ae819..3732691 100644 --- a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -1,3 +1,7 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import type { City, Nation } from '@sammo-ts/logic'; @@ -25,6 +29,60 @@ const invaderCityId = 991_101; const sourceEventId = 991_100; const createdEventIds = [991_101, 991_102]; +type ReferenceInvaderLifecycleTrace = { + phases: { + afterRaise: { + createdNationCount: number; + generalCountsPerNation: number[]; + diplomacyCountsPerNation: number[]; + diplomacyStates: string[]; + isunited: number; + blockChangeScout: boolean; + }; + atWar: { results: string[]; autoDeleteEventCount: number }; + atPeace: { + results: string[]; + autoDeleteEventCount: number; + rulerWanderTurnCount: number; + endingEventPresent: boolean; + }; + afterUserWin: { + result: string; + endingEventPresent: boolean; + isunited: number; + refreshLimit: number; + logs: string[]; + }; + }; +}; + +const runReferenceInvaderLifecycle = (): ReferenceInvaderLifecycleTrace | null => { + const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; + if (process.env.TURN_DIFFERENTIAL_REFERENCE !== '1' || !workspaceRoot) { + return null; + } + const stackDirectory = path.join(workspaceRoot, 'docker_compose_files/reference'); + const runnerScript = + process.env.MONTHLY_INVADER_LIFECYCLE_RUNNER_SCRIPT ?? + path.join(workspaceRoot, 'ref/sam/hwe/compare/monthly_event_trace.php'); + const fixturePath = + process.env.MONTHLY_INVADER_LIFECYCLE_FIXTURE ?? + path.join(path.dirname(runnerScript), 'fixtures/monthly_invader_lifecycle.json'); + const stdout = execFileSync('./scripts/run-turn-differential-case.sh', ['-'], { + cwd: stackDirectory, + input: fs.readFileSync(fixturePath, 'utf8'), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + COMPOSE_PROJECT_NAME: 'sam-rebuild-reference', + TURN_DIFFERENTIAL_RUNNER_SCRIPT: runnerScript, + TURN_DIFFERENTIAL_COMPARE_DIR: path.dirname(runnerScript), + }, + }); + return JSON.parse(stdout) as ReferenceInvaderLifecycleTrace; +}; + const buildCity = (id: number, nationId: number, level: number, name: string): City => ({ id, name, @@ -155,6 +213,7 @@ integration('RaiseInvader database persistence', () => { }); it('commits invader entities, dynamic events, diplomacy, turns, ranks, city state, and logs atomically', async () => { + const referenceTrace = runReferenceInvaderLifecycle(); const createdGeneralIds = Array.from({ length: 10 }, (_, index) => firstCreatedGeneralId + index); const createCity = (city: City) => db.city.create({ @@ -386,6 +445,15 @@ integration('RaiseInvader database persistence', () => { expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({ meta: expect.objectContaining({ isunited: 1, block_change_scout: false }), }); + if (referenceTrace) { + expect(referenceTrace.phases.afterRaise).toMatchObject({ + generalCountsPerNation: [10], + diplomacyCountsPerNation: [2], + diplomacyStates: ['1:24'], + isunited: 1, + blockChangeScout: false, + }); + } const sequenceProbe = await db.event.create({ data: { targetCode: 'month', @@ -399,6 +467,23 @@ integration('RaiseInvader database persistence', () => { await db.event.delete({ where: { id: sequenceProbe.id } }); expect(world.removeEvent(sourceEventId)).toBe(true); + await world.advanceMonth(new Date('0200-02-01T00:00:00.000Z')); + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: world.getState().lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.event.findUnique({ where: { id: createdEventIds[0]! } })).not.toBeNull(); + if (referenceTrace) { + expect(referenceTrace.phases.atWar).toEqual({ + results: ['On War'], + autoDeleteEventCount: referenceTrace.phases.afterRaise.createdNationCount, + }); + } + world.applyDiplomacyPatch({ srcNationId: createdNationId, destNationId: existingNationId, @@ -409,7 +494,7 @@ integration('RaiseInvader database persistence', () => { destNationId: createdNationId, patch: { state: 2, term: 0 }, }); - await world.advanceMonth(new Date('0200-02-01T00:00:00.000Z')); + await world.advanceMonth(new Date('0200-03-01T00:00:00.000Z')); await dbHooks.hooks.flushChanges?.({ lastTurnTime: world.getState().lastTurnTime.toISOString(), processedGenerals: 0, @@ -426,13 +511,24 @@ integration('RaiseInvader database persistence', () => { }) ).toEqual(Array.from({ length: 30 }, () => ({ actionCode: 'che_방랑' }))); expect(await db.event.findUnique({ where: { id: createdEventIds[1]! } })).not.toBeNull(); + if (referenceTrace) { + expect(referenceTrace.phases.atPeace).toMatchObject({ + results: ['Deleted'], + autoDeleteEventCount: 0, + endingEventPresent: true, + }); + expect( + referenceTrace.phases.atPeace.rulerWanderTurnCount / + referenceTrace.phases.afterRaise.createdNationCount + ).toBe(30); + } for (const generalId of createdGeneralIds) { expect(world.removeGeneral(generalId)).toBe(true); } expect(world.removeNation(createdNationId)).toBe(true); world.updateCity(invaderCityId, { nationId: existingNationId }); - await world.advanceMonth(new Date('0200-03-01T00:00:00.000Z')); + await world.advanceMonth(new Date('0200-04-01T00:00:00.000Z')); await dbHooks.hooks.flushChanges?.({ lastTurnTime: world.getState().lastTurnTime.toISOString(), processedGenerals: 0, @@ -447,14 +543,26 @@ integration('RaiseInvader database persistence', () => { }); expect( await db.logEntry.findMany({ - where: { year: 200, month: 3, text: { contains: '【이벤트】' } }, + where: { year: 200, month: 4, text: { contains: '【이벤트】' } }, orderBy: { id: 'asc' }, select: { text: true }, }) ).toEqual([ - { text: '★200년 3월:【이벤트】이민족을 모두 소탕했습니다!' }, - { text: '★200년 3월:【이벤트】중원은 당분간 태평성대를 누릴 것입니다.' }, + { text: '●200년 4월:【이벤트】이민족을 모두 소탕했습니다!' }, + { text: '●200년 4월:【이벤트】중원은 당분간 태평성대를 누릴 것입니다.' }, ]); + if (referenceTrace) { + expect(referenceTrace.phases.afterUserWin).toEqual({ + result: 'Deleted', + endingEventPresent: false, + isunited: 3, + refreshLimit: 300, + logs: [ + '●200년 4월:【이벤트】이민족을 모두 소탕했습니다!', + '●200년 4월:【이벤트】중원은 당분간 태평성대를 누릴 것입니다.', + ], + }); + } } finally { await dbHooks.close(); }