diff --git a/app/game-engine/test/unificationInvaderResume.test.ts b/app/game-engine/test/unificationInvaderResume.test.ts new file mode 100644 index 00000000..2532fe11 --- /dev/null +++ b/app/game-engine/test/unificationInvaderResume.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ManualClock } from '@sammo-ts/common'; +import type { GamePrisma } from '@sammo-ts/infra'; +import type { City, MapDefinition, MessagePayload, Nation, ScenarioConfig } from '@sammo-ts/logic'; + +import { InMemoryControlQueue } from '../src/lifecycle/inMemoryControlQueue.js'; +import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js'; +import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; +import { InMemoryTurnStateStore } from '../src/turn/inMemoryStateStore.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); + +const liveLastTurnTime = new Date('2026-08-19T21:14:00.000Z'); +const liveWallAnchor = new Date('2026-08-20T06:21:58.611Z'); +const acceptedAt = addMinutes(liveWallAnchor, 3); + +const buildGeneral = (options: { + id: number; + name: string; + userId: string | null; + officerLevel: number; + npcState: number; + turnMinute: number; +}): TurnGeneral => ({ + id: options.id, + userId: options.userId, + name: options.name, + nationId: 6, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + turnTime: addMinutes(liveLastTurnTime, options.turnMinute), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 240, dex1: 10, dex2: 20, dex3: 30, dex4: 40, dex5: 50 }, + penalty: {}, + officerLevel: options.officerLevel, + experience: 1_000, + dedication: 1_000, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: options.npcState, +}); + +const city = (id: number, level: number): City => ({ + id, + name: id === 1 ? '업' : '남만', + nationId: 6, + level, + state: 0, + population: 10_000, + populationMax: 100_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: {}, +}); + +describe('HWE-shaped unification invader resume', () => { + it('drains the recipient response while united is halted and resumes the next scheduled month', async () => { + const recipient = buildGeneral({ + id: 882, + name: '운영자', + userId: 'hwe-recipient-882', + officerLevel: 11, + npcState: 0, + turnMinute: 1, + }); + const generals = [ + buildGeneral({ + id: 41, + name: 'ⓝ곽가', + userId: null, + officerLevel: 12, + npcState: 2, + turnMinute: 2, + }), + recipient, + buildGeneral({ + id: 966, + name: '유여', + userId: 'hwe-recipient-966', + officerLevel: 10, + npcState: 0, + turnMinute: 3, + }), + ]; + const nation: Nation = { + id: 6, + name: '㉿곽가', + color: '#777777', + capitalCityId: 1, + chiefGeneralId: 41, + gold: 1_000, + rice: 1_000, + power: 0, + level: 4, + typeCode: 'che_유가', + meta: { tech: 100, war: 1, scout: 1 }, + }; + const scenarioConfig: ScenarioConfig = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }; + const map: MapDefinition = { + id: 'test', + name: 'test', + cities: [], + }; + const state: TurnWorldState = { + id: 1, + currentYear: 226, + currentMonth: 3, + tickSeconds: 60, + lastTurnTime: liveLastTurnTime, + clockBaseTime: new Date('2026-08-19T12:00:00.000Z'), + clockTick: 19_980_000_000, + clockMode: 'realtime', + clockWallAnchor: liveWallAnchor, + lastTurnTick: 19_944_000_000, + meta: { + hiddenSeed: 'hwe-invader-resume-fixture', + serverId: 'hwe:default_snapshot', + isUnited: 2, + isunited: 2, + refreshLimit: 3_000, + maxGeneralsPerMinute: 1_000, + }, + }; + const snapshot: TurnWorldSnapshot = { + generals, + cities: [city(1, 3), city(2, 4)], + nations: [nation], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig, + map, + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] }, + }); + const reservedTurns = new InMemoryReservedTurnStore( + { + generalTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() }, + nationTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() }, + } as never, + { maxGeneralTurns: 30, maxNationTurns: 12 } + ); + const message: MessagePayload = { + src: { generalId: 0, generalName: '', nationId: 0, nationName: 'System', color: '#000000', icon: '' }, + dest: { + generalId: recipient.id, + generalName: recipient.name, + nationId: nation.id, + nationName: nation.name, + color: nation.color, + icon: '', + }, + text: '이벤트 게임으로 이민족[쉬움]을 소환', + option: { action: 'raiseInvader', args: [-1, -1, -0.8, 0], used: false }, + }; + const requestId = 'hwe-invader-resume-message-1014'; + const commandDb = { + inputEvent: { + findUnique: vi.fn(async () => ({ + actorUserId: recipient.userId, + target: 'ENGINE', + eventType: 'messageRespond', + createdAt: acceptedAt, + })), + }, + $queryRaw: vi.fn(async () => [ + { + id: 1014, + mailbox: recipient.id, + type: 'private', + validUntil: new Date('9999-12-31T00:00:00.000Z'), + message, + }, + ]), + } as unknown as GamePrisma.TransactionClient; + const queue = new InMemoryControlQueue(); + queue.enqueue({ + type: 'messageRespond', + requestId, + userId: recipient.userId!, + generalId: recipient.id, + messageId: 1014, + response: true, + }); + const commandHandler = createTurnDaemonCommandHandler({ + world, + reservedTurns, + scenarioMeta: { + title: 'HWE snapshot', + startYear: 180, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map, + loadArchivedNationMaxId: async () => 57, + }); + const processor = new InMemoryTurnProcessor(world); + const run = vi.fn(async (...args: Parameters) => { + const result = await processor.run(...args); + queue.enqueue({ type: 'shutdown', reason: 'one resumed monthly boundary verified' }); + return result; + }); + const lifecycle = new TurnDaemonLifecycle( + { + clock: new ManualClock(acceptedAt.getTime()), + controlQueue: queue, + getNextTickTime: (value) => addMinutes(value, 1), + stateStore: new InMemoryTurnStateStore(world), + processor: { run }, + commandHandler, + hooks: { + executeCommand: async (_commandRequestId, execute) => execute({ db: commandDb }), + }, + }, + { + profile: 'hwe:default-snapshot', + defaultBudget: { budgetMs: 5_000, maxGenerals: 200, catchUpCap: 1 }, + } + ); + + await lifecycle.start(); + + expect(commandDb.inputEvent.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { requestId } }) + ); + expect(commandDb.$queryRaw).toHaveBeenCalledOnce(); + expect(world.getState()).toMatchObject({ + currentYear: 226, + currentMonth: 4, + meta: { isUnited: 1, isunited: 1 }, + }); + expect(world.listNations().filter((entry) => entry.name.startsWith('ⓞ'))).toHaveLength(1); + expect(world.listGenerals().filter((entry) => entry.npcState === 9)).toHaveLength(10); + expect(run).toHaveBeenCalledOnce(); + expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-19T21:15:00.000Z'); + }); +});