From d8220a18b38396795f744caa40271e04116961d5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 27 Jul 2026 00:02:48 +0000 Subject: [PATCH] test live sortie postgres persistence matrix --- .../liveSortiePersistence.integration.test.ts | 578 ++++++++++-------- 1 file changed, 318 insertions(+), 260 deletions(-) diff --git a/tools/integration-tests/test/liveSortiePersistence.integration.test.ts b/tools/integration-tests/test/liveSortiePersistence.integration.test.ts index 8319db2..4494bd7 100644 --- a/tools/integration-tests/test/liveSortiePersistence.integration.test.ts +++ b/tools/integration-tests/test/liveSortiePersistence.integration.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { asRecord } from '@sammo-ts/common'; import { buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js'; import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js'; @@ -49,10 +49,10 @@ const assertDedicatedDatabase = (rawUrl: string): void => { } }; -const readFixture = (): TurnCommandFixtureRequest => { +const readFixture = (fixtureName: string): TurnCommandFixtureRequest => { const fixturePath = path.join( workspaceRoot!, - 'docker_compose_files/reference/fixtures/turn-differential/live-sortie-defender.json' + `docker_compose_files/reference/fixtures/turn-differential/${fixtureName}` ); const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as TurnCommandFixtureRequest; return { @@ -81,6 +81,7 @@ const readFixture = (): TurnCommandFixtureRequest => { const cleanup = async (db: GamePrismaClient): Promise => { await db.logEntry.deleteMany(); + await db.oldNation.deleteMany(); await db.rankData.deleteMany(); await db.generalTurn.deleteMany(); await db.generalTurnRevision.deleteMany(); @@ -107,275 +108,332 @@ integration('live sortie PostgreSQL persistence retry', () => { await cleanup(db); }); + beforeEach(async () => { + await cleanup(db); + }); + afterAll(async () => { await cleanup(db); await disconnect?.(); }); - it('rolls back a lost queue lease, retains dirty state, and flushes the same sortie exactly once on retry', async () => { - const request = readFixture(); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const expected = await runCoreTurnCommandTrace(request, reference.before); - const coreArgs = resolveCoreTurnCommandArgs(request); - const unitSet = await loadUnitSetDefinitionByName('che'); - const map = await loadMapDefinitionByName('che'); - const { state, snapshot } = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map); + it.each([ + ['conquest and nation collapse', 'live-sortie-conquest.json'], + ['single defending general', 'live-sortie-defender.json'], + ['multiple defending generals', 'live-sortie-multiple-defenders.json'], + ['supply retreat', 'live-sortie-supply-retreat.json'], + ['noncapital conquest', 'live-sortie-noncapital-conquest.json'], + ['emergency capital', 'live-sortie-emergency-capital.json'], + ['conflict arbitration', 'live-sortie-conflict-arbitration.json'], + ['tied conflict', 'live-sortie-conflict-tie.json'], + ])( + '%s rolls back a lost lease and flushes exactly once on retry', + async (_label, fixtureName) => { + const request = readFixture(fixtureName); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const expected = await runCoreTurnCommandTrace(request, reference.before); + const coreArgs = resolveCoreTurnCommandArgs(request); + const unitSet = await loadUnitSetDefinitionByName('che'); + const map = await loadMapDefinitionByName('che'); + const { state, snapshot } = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map); - await db.worldState.create({ - data: { - id: state.id, - scenarioCode: 'live-sortie-persistence', - currentYear: state.currentYear, - currentMonth: state.currentMonth, - tickSeconds: state.tickSeconds, - config: asJson(snapshot.scenarioConfig), - meta: asJson(state.meta), - }, - }); - await db.nation.createMany({ - data: snapshot.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: Number(nation.meta.tech ?? 0), - level: nation.level, - typeCode: nation.typeCode, - meta: asJson(nation.meta), - })), - }); - await db.city.createMany({ - data: snapshot.cities.map((city) => { - const definition = map.cities.find((entry) => entry.id === city.id); - return { - id: city.id, - name: city.name, - level: city.level, - nationId: city.nationId, - supplyState: city.supplyState, - frontState: city.frontState, - population: Math.round(city.population), - populationMax: city.populationMax, - agriculture: Math.round(city.agriculture), - agricultureMax: city.agricultureMax, - commerce: Math.round(city.commerce), - commerceMax: city.commerceMax, - security: Math.round(city.security), - securityMax: city.securityMax, - trust: Number(city.meta.trust ?? 0), - trade: Number(city.meta.trade ?? 100), - defence: Math.round(city.defence), - defenceMax: city.defenceMax, - wall: Math.round(city.wall), - wallMax: city.wallMax, - region: definition?.region ?? 0, - conflict: asJson(city.conflict ?? {}), - meta: asJson({ ...city.meta, state: city.state }), - }; - }), - }); - await db.troop.createMany({ - data: snapshot.troops.map((troop) => ({ - troopLeaderId: troop.id, - nationId: troop.nationId, - name: troop.name, - })), - }); - await db.general.createMany({ - data: snapshot.generals.map((general) => ({ - id: general.id, - userId: general.userId, - name: general.name, - nationId: general.nationId, - cityId: general.cityId, - troopId: general.troopId, - npcState: general.npcState, - affinity: general.affinity, - bornYear: general.bornYear, - deadYear: general.deadYear, - picture: general.picture, - leadership: Math.round(general.stats.leadership), - strength: Math.round(general.stats.strength), - intel: Math.round(general.stats.intelligence), - injury: Math.round(general.injury), - experience: Math.round(general.experience), - dedication: Math.round(general.dedication), - officerLevel: general.officerLevel, - gold: Math.round(general.gold), - rice: Math.round(general.rice), - crew: Math.round(general.crew), - crewTypeId: general.crewTypeId, - train: Math.round(general.train), - atmos: Math.round(general.atmos), - age: general.age, - startAge: general.startAge, - personalCode: nullableCode(general.role.personality), - specialCode: nullableCode(general.role.specialDomestic), - special2Code: nullableCode(general.role.specialWar), - horseCode: nullableCode(general.role.items.horse), - weaponCode: nullableCode(general.role.items.weapon), - bookCode: nullableCode(general.role.items.book), - itemCode: nullableCode(general.role.items.item), - turnTime: general.turnTime, - recentWarTime: general.recentWarTime, - lastTurn: asJson(general.lastTurn ?? { command: '휴식' }), - meta: asJson(general.meta), - penalty: asJson(general.penalty ?? {}), - })), - }); - await db.rankData.createMany({ - data: snapshot.generals.flatMap((general) => - buildLegacyComparableRankRows(general).map((row) => ({ - generalId: row.generalId, - nationId: row.nationId, - type: row.type, - value: row.value, - })) - ), - }); - await db.diplomacy.createMany({ - data: snapshot.diplomacy.map((entry) => ({ - srcNationId: entry.fromNationId, - destNationId: entry.toNationId, - stateCode: entry.state, - term: entry.term, - isDead: entry.dead !== 0, - meta: asJson(entry.meta), - })), - }); - await db.generalTurn.createMany({ - data: snapshot.generals.flatMap((general) => - Array.from({ length: 30 }, (_, turnIdx) => ({ - generalId: general.id, - turnIdx, - actionCode: general.id === request.actorGeneralId && turnIdx === 0 ? request.action : '휴식', - arg: asJson(general.id === request.actorGeneralId && turnIdx === 0 ? coreArgs : {}), - })) - ), - }); - - const reservedTurns = new InMemoryReservedTurnStore(db, { - maxGeneralTurns: 30, - maxNationTurns: 12, - leaseOwner, - leaseDurationMs: 60_000, - }); - await reservedTurns.loadAll(); - await reservedTurns.prepareTurnsForExecution(request.actorGeneralId); - - let world: InMemoryTurnWorld | null = null; - const handler = await createReservedTurnHandler({ - reservedTurns, - scenarioConfig: snapshot.scenarioConfig, - scenarioMeta: snapshot.scenarioMeta, - map, - unitSet, - getWorld: () => world, - commandProfile: createCoreTurnCommandProfile(request), - }); - world = new InMemoryTurnWorld(state, snapshot, { - schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, - generalTurnHandler: handler, - }); - const actor = world.getGeneralById(request.actorGeneralId); - if (!actor) { - throw new Error('fixture actor is missing'); - } - world.executeGeneralTurn(actor); - - const dirtyBeforeFailure = world.peekDirtyState(); - expect(dirtyBeforeFailure.generals.length).toBeGreaterThan(0); - expect(dirtyBeforeFailure.logs.length).toBeGreaterThan(0); - expect( - dirtyBeforeFailure.generals.find((general) => general.id === request.actorGeneralId)?.recentWarTime - ).toEqual(new Date('2026-07-26T13:38:45.000Z')); - expect(dirtyBeforeFailure.generals.find((general) => general.id === 2)?.recentWarTime).toEqual( - new Date('2026-07-26T13:38:45.000Z') - ); - expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId); - - const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); - try { - await db.generalTurnRevision.update({ - where: { generalId: request.actorGeneralId }, + await db.worldState.create({ data: { - leaseOwner: 'lost-to-another-writer', - leaseExpiresAt: new Date(Date.now() + 60_000), + id: state.id, + scenarioCode: 'live-sortie-persistence', + currentYear: state.currentYear, + currentMonth: state.currentMonth, + tickSeconds: state.tickSeconds, + config: asJson(snapshot.scenarioConfig), + meta: asJson(state.meta), }, }); - - await expect(hooks.hooks.flushChanges?.(turnRunResult)).rejects.toBeInstanceOf( - ReservedTurnLeaseConflictError - ); - - const rolledBackActor = await db.general.findUniqueOrThrow({ - where: { id: request.actorGeneralId }, + await db.nation.createMany({ + data: snapshot.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: Number(nation.meta.tech ?? 0), + level: nation.level, + typeCode: nation.typeCode, + meta: asJson(nation.meta), + })), }); - const rolledBackTurns = await db.generalTurn.findMany({ - where: { generalId: request.actorGeneralId }, - orderBy: { turnIdx: 'asc' }, + await db.city.createMany({ + data: snapshot.cities.map((city) => { + const definition = map.cities.find((entry) => entry.id === city.id); + return { + id: city.id, + name: city.name, + level: city.level, + nationId: city.nationId, + supplyState: city.supplyState, + frontState: city.frontState, + population: Math.round(city.population), + populationMax: city.populationMax, + agriculture: Math.round(city.agriculture), + agricultureMax: city.agricultureMax, + commerce: Math.round(city.commerce), + commerceMax: city.commerceMax, + security: Math.round(city.security), + securityMax: city.securityMax, + trust: Number(city.meta.trust ?? 0), + trade: Number(city.meta.trade ?? 100), + defence: Math.round(city.defence), + defenceMax: city.defenceMax, + wall: Math.round(city.wall), + wallMax: city.wallMax, + region: definition?.region ?? 0, + conflict: asJson(city.conflict ?? {}), + meta: asJson({ ...city.meta, state: city.state }), + }; + }), }); - expect(rolledBackActor.turnTime.toISOString()).toBe( - expected.before.generals.find((general) => general.id === request.actorGeneralId)?.turnTime - ); - expect(rolledBackActor.recentWarTime).toBeNull(); - expect(rolledBackTurns[0]?.actionCode).toBe('che_출병'); - expect(await db.logEntry.count()).toBe(0); - expect(world.peekDirtyState().logs).toHaveLength(dirtyBeforeFailure.logs.length); + await db.troop.createMany({ + data: snapshot.troops.map((troop) => ({ + troopLeaderId: troop.id, + nationId: troop.nationId, + name: troop.name, + })), + }); + await db.general.createMany({ + data: snapshot.generals.map((general) => ({ + id: general.id, + userId: general.userId, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + npcState: general.npcState, + affinity: general.affinity, + bornYear: general.bornYear, + deadYear: general.deadYear, + picture: general.picture, + leadership: Math.round(general.stats.leadership), + strength: Math.round(general.stats.strength), + intel: Math.round(general.stats.intelligence), + injury: Math.round(general.injury), + experience: Math.round(general.experience), + dedication: Math.round(general.dedication), + officerLevel: general.officerLevel, + gold: Math.round(general.gold), + rice: Math.round(general.rice), + crew: Math.round(general.crew), + crewTypeId: general.crewTypeId, + train: Math.round(general.train), + atmos: Math.round(general.atmos), + age: general.age, + startAge: general.startAge, + personalCode: nullableCode(general.role.personality), + specialCode: nullableCode(general.role.specialDomestic), + special2Code: nullableCode(general.role.specialWar), + horseCode: nullableCode(general.role.items.horse), + weaponCode: nullableCode(general.role.items.weapon), + bookCode: nullableCode(general.role.items.book), + itemCode: nullableCode(general.role.items.item), + turnTime: general.turnTime, + recentWarTime: general.recentWarTime, + lastTurn: asJson(general.lastTurn ?? { command: '휴식' }), + meta: asJson(general.meta), + penalty: asJson(general.penalty ?? {}), + })), + }); + await db.rankData.createMany({ + data: snapshot.generals.flatMap((general) => + buildLegacyComparableRankRows(general).map((row) => ({ + generalId: row.generalId, + nationId: row.nationId, + type: row.type, + value: row.value, + })) + ), + }); + await db.diplomacy.createMany({ + data: snapshot.diplomacy.map((entry) => ({ + srcNationId: entry.fromNationId, + destNationId: entry.toNationId, + stateCode: entry.state, + term: entry.term, + isDead: entry.dead !== 0, + meta: asJson(entry.meta), + })), + }); + await db.generalTurn.createMany({ + data: snapshot.generals.flatMap((general) => + Array.from({ length: 30 }, (_, turnIdx) => ({ + generalId: general.id, + turnIdx, + actionCode: general.id === request.actorGeneralId && turnIdx === 0 ? request.action : '휴식', + arg: asJson(general.id === request.actorGeneralId && turnIdx === 0 ? coreArgs : {}), + })) + ), + }); + + const reservedTurns = new InMemoryReservedTurnStore(db, { + maxGeneralTurns: 30, + maxNationTurns: 12, + leaseOwner, + leaseDurationMs: 60_000, + }); + await reservedTurns.loadAll(); + await reservedTurns.prepareTurnsForExecution(request.actorGeneralId); + + let world: InMemoryTurnWorld | null = null; + const handler = await createReservedTurnHandler({ + reservedTurns, + scenarioConfig: snapshot.scenarioConfig, + scenarioMeta: snapshot.scenarioMeta, + map, + unitSet, + getWorld: () => world, + commandProfile: createCoreTurnCommandProfile(request), + }); + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: handler, + }); + const actor = world.getGeneralById(request.actorGeneralId); + if (!actor) { + throw new Error('fixture actor is missing'); + } + world.executeGeneralTurn(actor); + + const dirtyBeforeFailure = world.peekDirtyState(); + expect(dirtyBeforeFailure.generals.length).toBeGreaterThan(0); + expect(dirtyBeforeFailure.logs.length).toBeGreaterThan(0); + for (const dirtyGeneral of dirtyBeforeFailure.generals) { + expect(dirtyGeneral.recentWarTime?.toISOString() ?? null).toBe( + expected.after.generals.find((general) => general.id === dirtyGeneral.id)?.recentWarTime ?? null + ); + } expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId); - await db.generalTurnRevision.update({ - where: { generalId: request.actorGeneralId }, - data: { - leaseOwner, - leaseExpiresAt: new Date(Date.now() + 60_000), - }, - }); - await hooks.hooks.flushChanges?.(turnRunResult); - - const persistedActor = await db.general.findUniqueOrThrow({ - where: { id: request.actorGeneralId }, - }); - const persistedDefender = await db.general.findUniqueOrThrow({ where: { id: 2 } }); - const persistedTurns = await db.generalTurn.findMany({ - where: { generalId: request.actorGeneralId }, - orderBy: { turnIdx: 'asc' }, - }); - const expectedActor = expected.after.generals.find((general) => general.id === request.actorGeneralId); - const expectedDefender = expected.after.generals.find((general) => general.id === 2); - expect(persistedActor.turnTime.toISOString()).toBe(expectedActor?.turnTime); - expect(persistedActor.recentWarTime?.toISOString()).toBe(expectedActor?.recentWarTime); - expect(persistedDefender.recentWarTime?.toISOString()).toBe(expectedDefender?.recentWarTime); - expect(asRecord(persistedActor.meta).myset).toBe(expectedActor?.mySet); - expect(asRecord(persistedActor.meta).killturn).toBe(expectedActor?.killTurn); - expect(asRecord(persistedActor.lastTurn).command).toBe(asRecord(expectedActor?.lastTurn).command); - expect(persistedTurns[0]?.actionCode).toBe('휴식'); - expect(await db.logEntry.count()).toBe(expected.after.logs.length); - expect(world.peekDirtyState().logs).toEqual([]); - expect(reservedTurns.peekDirtyState().generalIds).toEqual([]); - - const committedRevision = await db.generalTurnRevision.findUniqueOrThrow({ - where: { generalId: request.actorGeneralId }, - }); - const logCount = await db.logEntry.count(); - await hooks.hooks.flushChanges?.(turnRunResult); - expect(await db.logEntry.count()).toBe(logCount); - expect( - await db.generalTurnRevision.findUniqueOrThrow({ + const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + try { + await db.generalTurnRevision.update({ where: { generalId: request.actorGeneralId }, - }) - ).toMatchObject({ - revision: committedRevision.revision, - leaseOwner: committedRevision.leaseOwner, - }); - } finally { - await hooks.close(); - } - }, 120_000); + data: { + leaseOwner: 'lost-to-another-writer', + leaseExpiresAt: new Date(Date.now() + 60_000), + }, + }); + + await expect(hooks.hooks.flushChanges?.(turnRunResult)).rejects.toBeInstanceOf( + ReservedTurnLeaseConflictError + ); + + const rolledBackActor = await db.general.findUniqueOrThrow({ + where: { id: request.actorGeneralId }, + }); + const rolledBackTurns = await db.generalTurn.findMany({ + where: { generalId: request.actorGeneralId }, + orderBy: { turnIdx: 'asc' }, + }); + expect(rolledBackActor.turnTime.toISOString()).toBe( + expected.before.generals.find((general) => general.id === request.actorGeneralId)?.turnTime + ); + expect(rolledBackActor.recentWarTime).toBeNull(); + expect(rolledBackTurns[0]?.actionCode).toBe('che_출병'); + expect(await db.logEntry.count()).toBe(0); + expect(await db.oldNation.count()).toBe(0); + expect(world.peekDirtyState().logs).toHaveLength(dirtyBeforeFailure.logs.length); + expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId); + + await db.generalTurnRevision.update({ + where: { generalId: request.actorGeneralId }, + data: { + leaseOwner, + leaseExpiresAt: new Date(Date.now() + 60_000), + }, + }); + await hooks.hooks.flushChanges?.(turnRunResult); + + const persistedTurns = await db.generalTurn.findMany({ + where: { generalId: request.actorGeneralId }, + orderBy: { turnIdx: 'asc' }, + }); + const expectedActor = expected.after.generals.find((general) => general.id === request.actorGeneralId); + const persistedActor = await db.general.findUniqueOrThrow({ + where: { id: request.actorGeneralId }, + }); + expect(persistedActor.turnTime.toISOString()).toBe(expectedActor?.turnTime); + expect(persistedActor.recentWarTime?.toISOString()).toBe(expectedActor?.recentWarTime); + expect(asRecord(persistedActor.meta).myset).toBe(expectedActor?.mySet); + expect(asRecord(persistedActor.meta).killturn).toBe(expectedActor?.killTurn); + expect(asRecord(persistedActor.lastTurn).command).toBe(asRecord(expectedActor?.lastTurn).command); + expect(persistedTurns[0]?.actionCode).toBe('휴식'); + for (const expectedGeneral of expected.after.generals) { + const persistedGeneral = await db.general.findUniqueOrThrow({ + where: { id: Number(expectedGeneral.id) }, + }); + expect(persistedGeneral).toMatchObject({ + nationId: expectedGeneral.nationId, + cityId: expectedGeneral.cityId, + crew: expectedGeneral.crew, + }); + expect(persistedGeneral.turnTime.toISOString()).toBe(expectedGeneral.turnTime); + expect(persistedGeneral.recentWarTime?.toISOString() ?? null).toBe(expectedGeneral.recentWarTime); + } + for (const expectedCity of expected.after.cities) { + const persistedCity = await db.city.findUniqueOrThrow({ + where: { id: Number(expectedCity.id) }, + }); + expect(persistedCity).toMatchObject({ + nationId: expectedCity.nationId, + defence: expectedCity.defence, + conflict: expectedCity.conflict, + }); + } + for (const beforeNation of expected.before.nations) { + const expectedNation = expected.after.nations.find((nation) => nation.id === beforeNation.id); + const persistedNation = await db.nation.findUnique({ + where: { id: Number(beforeNation.id) }, + }); + if (!expectedNation) { + expect(persistedNation).toBeNull(); + } else { + expect(persistedNation).toMatchObject({ + capitalCityId: expectedNation.capitalCityId, + gold: expectedNation.gold, + rice: expectedNation.rice, + }); + } + } + const deletedNationCount = expected.before.nations.filter( + (nation) => !expected.after.nations.some((afterNation) => afterNation.id === nation.id) + ).length; + expect(await db.oldNation.count()).toBe(deletedNationCount); + expect(await db.logEntry.count()).toBe(expected.after.logs.length); + expect(world.peekDirtyState().logs).toEqual([]); + expect(reservedTurns.peekDirtyState().generalIds).toEqual([]); + + const committedRevision = await db.generalTurnRevision.findUniqueOrThrow({ + where: { generalId: request.actorGeneralId }, + }); + const logCount = await db.logEntry.count(); + const oldNationCount = await db.oldNation.count(); + await hooks.hooks.flushChanges?.(turnRunResult); + expect(await db.logEntry.count()).toBe(logCount); + expect(await db.oldNation.count()).toBe(oldNationCount); + expect( + await db.generalTurnRevision.findUniqueOrThrow({ + where: { generalId: request.actorGeneralId }, + }) + ).toMatchObject({ + revision: committedRevision.revision, + leaseOwner: committedRevision.leaseOwner, + }); + } finally { + await hooks.close(); + } + }, + 120_000 + ); });