From 484f012f19714c32d23375a14c65db697853e251 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 27 Jul 2026 11:02:12 +0000 Subject: [PATCH 1/2] fix NPC nation lifecycle history parity --- .../src/turn/monthlyRaiseNpcNationAction.ts | 2 +- ...seNpcNationPersistence.integration.test.ts | 148 +++++++++++++++++- 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts index fa86b5e..a437ed6 100644 --- a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts +++ b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts @@ -390,7 +390,7 @@ export const createRaiseNpcNationHandler = (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/monthlyRaiseNpcNationPersistence.integration.test.ts b/app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts index d7143f1..1fe9024 100644 --- a/app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts @@ -1,9 +1,14 @@ +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, MapDefinition, Nation } from '@sammo-ts/logic'; import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createProvideNpcTroopLeaderHandler } from '../src/turn/monthlyProvideNpcTroopLeaderAction.js'; import { createRaiseNpcNationHandler } from '../src/turn/monthlyRaiseNpcNationAction.js'; import { createMonthlyEventHandler } from '../src/turn/monthlyEventHandler.js'; import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; @@ -17,6 +22,54 @@ const createdNationId = 990_086; const occupiedCityId = 990_083; const targetCityId = 990_086; const createdGeneralId = 990_086; +const existingNationLeaderId = 990_087; +const createdNationLeaderId = 990_088; +const createdGeneralIds = [createdGeneralId, existingNationLeaderId, createdNationLeaderId]; + +type ReferenceNpcNationLifecycleTrace = { + phases: { + afterRaise: { + createdNationCount: number; + createdGeneralCount: number; + generalCountsPerCreatedNation: number[]; + historyLogs: string[]; + }; + afterProvide: { + createdLeaderCount: number; + leaderCountsPerActiveNation: number[]; + troopCount: number; + gatherTurnCounts: number[]; + leaderCounterDelta: number; + }; + }; +}; + +const runReferenceNpcNationLifecycle = (): ReferenceNpcNationLifecycleTrace | 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_NPC_LIFECYCLE_RUNNER_SCRIPT ?? + path.join(workspaceRoot, 'ref/sam/hwe/compare/monthly_event_trace.php'); + const fixturePath = + process.env.MONTHLY_NPC_LIFECYCLE_FIXTURE ?? + path.join(path.dirname(runnerScript), 'fixtures/monthly_npc_nation_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 ReferenceNpcNationLifecycleTrace; +}; const buildCity = (id: number, nationId: number, name: string): City => ({ id, @@ -91,7 +144,7 @@ const event: TurnEvent = { targetCode: 'month', priority: 1_000, condition: true, - action: [['RaiseNPCNation']], + action: [['RaiseNPCNation'], ['ProvideNPCTroopLeader']], meta: {}, }; @@ -103,14 +156,15 @@ integration('RaiseNPCNation database persistence', () => { await db.logEntry.deleteMany({ where: { OR: [ - { generalId: createdGeneralId }, + { generalId: { in: createdGeneralIds } }, { year: 200, month: 1, text: { contains: '공백지에 임의의 국가' } }, ], }, }); - await db.generalTurn.deleteMany({ where: { generalId: createdGeneralId } }); - await db.rankData.deleteMany({ where: { generalId: createdGeneralId } }); - await db.general.deleteMany({ where: { id: createdGeneralId } }); + await db.generalTurn.deleteMany({ where: { generalId: { in: createdGeneralIds } } }); + await db.rankData.deleteMany({ where: { generalId: { in: createdGeneralIds } } }); + await db.troop.deleteMany({ where: { troopLeaderId: { in: createdGeneralIds } } }); + await db.general.deleteMany({ where: { id: { in: createdGeneralIds } } }); await db.nationTurn.deleteMany({ where: { nationId: createdNationId } }); await db.diplomacy.deleteMany({ where: { @@ -135,6 +189,7 @@ integration('RaiseNPCNation database persistence', () => { }); it('commits the nation, city, diplomacy, ruler, turns, ranks, and history in one flush', async () => { + const referenceTrace = runReferenceNpcNationLifecycle(); const createCity = (city: City) => db.city.create({ data: { @@ -191,6 +246,7 @@ integration('RaiseNPCNation database persistence', () => { hiddenSeed: 'raise-npc-nation-persistence', lastGeneralId: createdGeneralId - 1, lastNationId: createdNationId - 1, + lastNPCTroopLeaderID: 40, serverId: 'raise-npc-nation-persistence', }, }, @@ -205,6 +261,7 @@ integration('RaiseNPCNation database persistence', () => { hiddenSeed: 'raise-npc-nation-persistence', lastGeneralId: createdGeneralId - 1, lastNationId: createdNationId - 1, + lastNPCTroopLeaderID: 40, serverId: 'raise-npc-nation-persistence', }, }; @@ -240,12 +297,20 @@ integration('RaiseNPCNation database persistence', () => { map, loadArchivedNationMaxId: async () => 0, }); + const provide = createProvideNpcTroopLeaderHandler({ + getWorld: () => world, + reservedTurns, + env: buildCommandEnv(snapshot.scenarioConfig), + }); world = new InMemoryTurnWorld(state, snapshot, { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, calendarHandler: createMonthlyEventHandler({ getWorld: () => world, startYear: 190, - actions: new Map([['RaiseNPCNation', handler]]), + actions: new Map([ + ['RaiseNPCNation', handler], + ['ProvideNPCTroopLeader', provide], + ]), }), }); const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); @@ -296,11 +361,80 @@ integration('RaiseNPCNation database persistence', () => { }, }) ).toBe(2); + expect( + await db.general.findMany({ + where: { id: { in: [existingNationLeaderId, createdNationLeaderId] } }, + orderBy: { id: 'asc' }, + select: { + id: true, + name: true, + nationId: true, + cityId: true, + troopId: true, + npcState: true, + }, + }) + ).toEqual([ + { + id: existingNationLeaderId, + name: '㉥부대장 41', + nationId: existingNationId, + cityId: occupiedCityId, + troopId: existingNationLeaderId, + npcState: 5, + }, + { + id: createdNationLeaderId, + name: '㉥부대장 42', + nationId: createdNationId, + cityId: targetCityId, + troopId: createdNationLeaderId, + npcState: 5, + }, + ]); + expect( + await db.troop.count({ + where: { troopLeaderId: { in: [existingNationLeaderId, createdNationLeaderId] } }, + }) + ).toBe(2); + for (const leaderId of [existingNationLeaderId, createdNationLeaderId]) { + expect( + await db.generalTurn.count({ + where: { generalId: leaderId, actionCode: 'che_집합' }, + }) + ).toBe(30); + expect(await db.rankData.count({ where: { generalId: leaderId } })).toBe(44); + } + expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({ + meta: expect.objectContaining({ lastNPCTroopLeaderID: 42 }), + }); expect( await db.logEntry.findFirst({ where: { year: 200, month: 1, text: { contains: '공백지에 임의의 국가' } }, + select: { text: true }, }) - ).not.toBeNull(); + ).toEqual({ + text: '●200년 1월:【공지】공백지에 임의의 국가가 생성되었습니다.', + }); + if (referenceTrace) { + expect(referenceTrace.phases).toEqual({ + afterRaise: { + createdNationCount: 1, + createdGeneralCount: 1, + generalCountsPerCreatedNation: [1], + historyLogs: [ + '●200년 1월:【공지】공백지에 임의의 국가가 생성되었습니다.', + ], + }, + afterProvide: { + createdLeaderCount: 2, + leaderCountsPerActiveNation: [1], + troopCount: 2, + gatherTurnCounts: [30], + leaderCounterDelta: 2, + }, + }); + } } finally { await dbHooks.close(); await db.worldState.delete({ where: { id: stateRow.id } }); From 9f402a3d672d2097994516201a1a62344bb3ac8a Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 27 Jul 2026 11:13:49 +0000 Subject: [PATCH 2/2] test nation betting API lifecycle --- .../test/monthlyCatalogCoverage.test.ts | 6 +- ...ionBettingApiLifecycle.integration.test.ts | 559 ++++++++++++++++++ 2 files changed, 564 insertions(+), 1 deletion(-) create mode 100644 tools/integration-tests/test/monthlyNationBettingApiLifecycle.integration.test.ts diff --git a/app/game-engine/test/monthlyCatalogCoverage.test.ts b/app/game-engine/test/monthlyCatalogCoverage.test.ts index c2c551e..68e94a4 100644 --- a/app/game-engine/test/monthlyCatalogCoverage.test.ts +++ b/app/game-engine/test/monthlyCatalogCoverage.test.ts @@ -102,7 +102,11 @@ const segments = [ kind: 'multi-month', actions: ['OpenNationBetting', 'FinishNationBetting'], coreEvidence: ['monthlyNationBettingPersistence.integration.test.ts'], - refEvidence: ['monthly_open_nation_betting.json', 'monthly_finish_nation_betting.json'], + refEvidence: [ + 'monthly_open_nation_betting.json', + 'monthly_finish_nation_betting.json', + 'monthly_nation_betting_lifecycle.json', + ], }, { name: 'scout-block-boundaries', diff --git a/tools/integration-tests/test/monthlyNationBettingApiLifecycle.integration.test.ts b/tools/integration-tests/test/monthlyNationBettingApiLifecycle.integration.test.ts new file mode 100644 index 0000000..325a4c6 --- /dev/null +++ b/tools/integration-tests/test/monthlyNationBettingApiLifecycle.integration.test.ts @@ -0,0 +1,559 @@ +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 type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { InMemoryFlushStore } from '@sammo-ts/game-api/auth/flushStore.js'; +import { RedisAccessTokenStore } from '@sammo-ts/game-api/auth/accessTokenStore.js'; +import { InMemoryBattleSimTransport } from '@sammo-ts/game-api/battleSim/inMemoryTransport.js'; +import type { GameApiContext } from '@sammo-ts/game-api/context.js'; +import { InMemoryTurnDaemonTransport } from '@sammo-ts/game-api/daemon/inMemoryTransport.js'; +import { appRouter } from '@sammo-ts/game-api/router.js'; +import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js'; +import { + createFinishNationBettingHandler, + createOpenNationBettingHandler, +} from '@sammo-ts/game-engine/turn/monthlyNationBettingAction.js'; +import { createMonthlyEventHandler } from '@sammo-ts/game-engine/turn/monthlyEventHandler.js'; +import type { + TurnEvent, + TurnGeneral, + TurnWorldSnapshot, + TurnWorldState, +} from '@sammo-ts/game-engine/turn/types.js'; +import { + createGamePostgresConnector, + type GamePrismaClient, + type RedisConnector, +} from '@sammo-ts/infra'; +import type { City, Nation } from '@sammo-ts/logic'; + +import { findTurnDifferentialWorkspaceRoot } from '../src/turn-differential/referenceSnapshot.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const workspaceRoot = + process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd()); +const integration = describe.skipIf(!databaseUrl); +const nationIds = [990_091, 990_092] as const; +const cityIds = [990_091, 990_092] as const; +const generalIds = [9_991, 9_992] as const; +const userIds = ['monthly-betting-api-user-1', 'monthly-betting-api-user-2'] as const; +const bettingId = 990_091; +const sourceEventId = 990_091; +const finishEventId = 990_093; + +type ReferenceBettingLifecycle = { + phases: { + afterOpen: { + candidateNationIds: number[]; + systemBonus: number; + finishEventCount: number; + historyLogs: string[]; + }; + afterBets: { + bets: Array<{ + generalId: number; + ownerId: number | null; + selectionKey: string; + amount: number; + }>; + inheritancePrevious: number[]; + spentRank: number[]; + }; + afterFinish: { + result: boolean; + finished: boolean; + winner: number[]; + inheritancePrevious: number[]; + earnedRank: number[]; + historyLogs: string[]; + }; + }; +}; + +const runReferenceLifecycle = (): ReferenceBettingLifecycle | null => { + if (process.env.TURN_DIFFERENTIAL_REFERENCE !== '1' || !workspaceRoot) { + return null; + } + const stackDirectory = path.join(workspaceRoot, 'docker_compose_files/reference'); + const runnerScript = + process.env.MONTHLY_BETTING_LIFECYCLE_RUNNER_SCRIPT ?? + path.join(workspaceRoot, 'ref/sam/hwe/compare/monthly_event_trace.php'); + const fixturePath = + process.env.MONTHLY_BETTING_LIFECYCLE_FIXTURE ?? + path.join(path.dirname(runnerScript), 'fixtures/monthly_nation_betting_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 ReferenceBettingLifecycle; +}; + +const buildNation = (id: number, power: number): Nation => ({ + id, + name: id === nationIds[0] ? '위' : '촉', + color: id === nationIds[0] ? '#111111' : '#222222', + capitalCityId: id, + chiefGeneralId: id, + gold: id === nationIds[0] ? 1_000 : 3_000, + rice: id === nationIds[0] ? 2_000 : 4_000, + power, + level: 2, + typeCode: id === nationIds[0] ? 'che_유가' : 'che_병가', + meta: { tech: id === nationIds[0] ? 100 : 200 }, +}); + +const buildCity = (id: number, nationId: number): City => ({ + id, + name: id === cityIds[0] ? '허창' : '성도', + nationId, + level: 3, + state: 0, + population: 1_000, + populationMax: 2_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: { region: 1, trust: 50, trade: 100 }, +}); + +const buildGeneral = (id: number, userId: string, nationId: number): TurnGeneral => ({ + id, + userId, + name: id === generalIds[0] ? '장수1' : '장수2', + nationId, + cityId: nationId, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 1_100, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + bornYear: 170, + deadYear: 250, + affinity: 1, + picture: 'default.jpg', + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + lastTurn: { command: '휴식' }, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { killturn: 1_000 }, +}); + +const buildAuth = (index: 0 | 1): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:2', + issuedAt: '2026-07-27T00:00:00.000Z', + expiresAt: '2026-07-28T00:00:00.000Z', + sessionId: `monthly-betting-api-session-${index + 1}`, + user: { + id: userIds[index], + username: `bettor-${index + 1}`, + displayName: `Bettor ${index + 1}`, + roles: ['user'], + }, + sanctions: {}, +}); + +const event: TurnEvent = { + id: sourceEventId, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['OpenNationBetting', 1, 100]], + meta: {}, +}; + +integration('monthly nation betting API lifecycle', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const buildContext = (index: 0 | 1): GameApiContext => { + const redisClient = { + get: async () => null, + set: async () => null, + }; + return { + requestId: `monthly-betting-api-${index + 1}`, + db, + redis: redisClient as unknown as RedisConnector['client'], + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile: { id: 'che', scenario: '2', name: 'che:2' }, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + auth: buildAuth(index), + accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + }; + + const cleanFixture = async () => { + await db.inputEvent.deleteMany({ where: { actorUserId: { in: [...userIds] } } }); + await db.event.deleteMany({ where: { id: { in: [sourceEventId, sourceEventId + 1, finishEventId] } } }); + await db.nationBetting.deleteMany({ where: { id: bettingId } }); + await db.rankData.deleteMany({ where: { generalId: { in: [...generalIds] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [...userIds] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [...userIds] } } }); + await db.message.deleteMany({ where: { mailbox: { in: [...generalIds] } } }); + await db.logEntry.deleteMany({ + where: { + OR: [ + { text: { contains: '천하통일 후보를 점치는' } }, + { text: { contains: '천통국 예상 내기의 결과' } }, + ], + }, + }); + await db.diplomacy.deleteMany({ + where: { + OR: [{ srcNationId: { in: [...nationIds] } }, { destNationId: { in: [...nationIds] } }], + }, + }); + await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: { in: [...nationIds] } } }); + await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-nation-betting-api-lifecycle' } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await cleanFixture(); + }); + + afterAll(async () => { + await cleanFixture(); + await closeDb?.(); + }); + + it('opens in the engine, accepts session-owned API bets, and settles in the next monthly event', async () => { + const reference = runReferenceLifecycle(); + const nations = [buildNation(nationIds[0], 100), buildNation(nationIds[1], 300)]; + const cities = [buildCity(cityIds[0], nationIds[0]), buildCity(cityIds[1], nationIds[1])]; + const generals = [ + buildGeneral(generalIds[0], userIds[0], nationIds[0]), + buildGeneral(generalIds[1], userIds[1], nationIds[1]), + ]; + await db.nation.createMany({ + data: nations.map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, + gold: nation.gold, + rice: nation.rice, + tech: nation.id === nationIds[0] ? 100 : 200, + level: nation.level, + typeCode: nation.typeCode, + meta: {}, + })), + }); + await db.city.createMany({ + data: cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + nationId: city.nationId, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + trust: 50, + trade: 100, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + meta: {}, + })), + }); + await db.general.createMany({ + data: generals.map((general) => ({ + id: general.id, + userId: general.userId, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + officerLevel: general.officerLevel, + turnTime: general.turnTime, + meta: general.meta, + })), + }); + await db.inheritancePoint.createMany({ + data: userIds.map((userId) => ({ userId, key: 'previous', value: 1_000 })), + }); + const row = await db.worldState.create({ + data: { + scenarioCode: 'monthly-nation-betting-api-lifecycle', + currentYear: 199, + currentMonth: 12, + tickSeconds: 600, + config: {}, + meta: { lastBettingId: bettingId - 1 }, + }, + }); + const state: TurnWorldState = { + id: row.id, + currentYear: 199, + currentMonth: 12, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-27T00:00:00.000Z'), + meta: { lastBettingId: bettingId - 1 }, + }; + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }; + let world: InMemoryTurnWorld | null = null; + const open = createOpenNationBettingHandler({ getWorld: () => world }); + const finish = createFinishNationBettingHandler({ getWorld: () => world }); + world = new InMemoryTurnWorld( + state, + { + scenarioConfig, + map: { id: 'test', name: 'test', cities: [] }, + generals, + cities, + nations, + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }, + { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: createMonthlyEventHandler({ + getWorld: () => world, + startYear: 190, + actions: new Map([ + ['OpenNationBetting', open], + ['FinishNationBetting', finish], + ]), + }), + } + ); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + + try { + await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); + await hooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + const opened = await db.nationBetting.findUniqueOrThrow({ where: { id: bettingId } }); + const candidates = Array.isArray(opened.candidates) ? opened.candidates : []; + const candidateNationIds = candidates.flatMap((candidate) => { + if (!candidate || typeof candidate !== 'object' || !('aux' in candidate)) { + return []; + } + const aux = candidate.aux; + return aux && typeof aux === 'object' && 'nation' in aux && typeof aux.nation === 'number' + ? [aux.nation] + : []; + }); + const openHistory = await db.logEntry.findMany({ + where: { text: { contains: '천하통일 후보를 점치는' } }, + orderBy: { id: 'asc' }, + select: { text: true }, + }); + const createdFinishEvent = await db.event.findUniqueOrThrow({ + where: { id: sourceEventId + 1 }, + select: { action: true }, + }); + expect(createdFinishEvent.action).toEqual([ + ['FinishNationBetting', bettingId], + ['DeleteEvent'], + ]); + expect({ + candidateNationIds, + systemBonus: ( + await db.nationBet.findFirstOrThrow({ + where: { bettingId, generalId: 0 }, + select: { amount: true }, + }) + ).amount, + finishEventCount: 1, + historyLogs: openHistory.map((entry) => entry.text), + }).toEqual({ + candidateNationIds: [nationIds[1], nationIds[0]], + systemBonus: reference?.phases.afterOpen.systemBonus ?? 100, + finishEventCount: reference?.phases.afterOpen.finishEventCount ?? 1, + historyLogs: + reference?.phases.afterOpen.historyLogs ?? + [ + '●200년 1월:【내기】천하통일 후보를 점치는 내기가 진행중입니다! 호사가의 참여를 기다립니다!', + ], + }); + + await expect( + appRouter.createCaller(buildContext(0)).betting.bet({ + bettingId, + bettingType: [1], + amount: 100, + }) + ).resolves.toEqual({ result: true }); + await expect( + appRouter.createCaller(buildContext(1)).betting.bet({ + bettingId, + bettingType: [0], + amount: 100, + }) + ).resolves.toEqual({ result: true }); + + const bets = await db.nationBet.findMany({ + where: { bettingId }, + orderBy: { generalId: 'asc' }, + select: { generalId: true, userId: true, selectionKey: true, amount: true }, + }); + const afterBets = { + selections: bets.map((bet) => ({ selectionKey: bet.selectionKey, amount: bet.amount })), + inheritancePrevious: await Promise.all( + userIds.map(async (userId) => { + const point = await db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId, key: 'previous' } }, + }); + return point.value; + }) + ), + spentRank: await Promise.all( + generalIds.map(async (generalId) => { + const rank = await db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId, type: 'inherit_spent_dyn' } }, + }); + return rank.value; + }) + ), + }; + expect(afterBets).toEqual({ + selections: (reference?.phases.afterBets.bets ?? [ + { selectionKey: '[-1]', amount: 100 }, + { selectionKey: '[1]', amount: 100 }, + { selectionKey: '[0]', amount: 100 }, + ]).map((bet) => ({ selectionKey: bet.selectionKey, amount: bet.amount })), + inheritancePrevious: reference?.phases.afterBets.inheritancePrevious ?? [900, 900], + spentRank: reference?.phases.afterBets.spentRank ?? [100, 100], + }); + + world.updateNation(nationIds[0], { level: 0 }); + expect(world.removeEvent(sourceEventId)).toBe(true); + expect( + world.addEvent({ + id: finishEventId, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['FinishNationBetting', bettingId]], + meta: {}, + }) + ).toBe(true); + await world.advanceMonth(new Date('0200-02-01T00:00:00.000Z')); + await hooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + const settled = await db.nationBetting.findUniqueOrThrow({ where: { id: bettingId } }); + const finishHistory = await db.logEntry.findMany({ + where: { + OR: [ + { text: { contains: '천하통일 후보를 점치는' } }, + { text: { contains: '천통국 예상 내기의 결과' } }, + ], + }, + orderBy: { id: 'asc' }, + select: { text: true }, + }); + const afterFinish = { + finished: settled.finished, + winner: settled.winner, + inheritancePrevious: await Promise.all( + userIds.map(async (userId) => { + const point = await db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId, key: 'previous' } }, + }); + return point.value; + }) + ), + earnedRank: await Promise.all( + generalIds.map(async (generalId) => { + const rank = await db.rankData.findUnique({ + where: { generalId_type: { generalId, type: 'inherit_earned_act' } }, + }); + return rank?.value ?? 0; + }) + ), + historyLogs: finishHistory.map((entry) => entry.text), + }; + expect(afterFinish).toEqual({ + finished: reference?.phases.afterFinish.finished ?? true, + winner: reference?.phases.afterFinish.winner ?? [0], + inheritancePrevious: reference?.phases.afterFinish.inheritancePrevious ?? [900, 1_200], + earnedRank: reference?.phases.afterFinish.earnedRank ?? [0, 300], + historyLogs: + reference?.phases.afterFinish.historyLogs ?? + [ + '●200년 1월:【내기】천하통일 후보를 점치는 내기가 진행중입니다! 호사가의 참여를 기다립니다!', + '●200년 2월:【내기】 200년 1월에 열렸던 천통국 예상 내기의 결과가 나왔습니다!', + ], + }); + } finally { + await hooks.close(); + } + }); +});