diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index fc6e5a7c..1ce594c5 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -1,6 +1,11 @@ import { randomBytes } from 'node:crypto'; -import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra'; +import { + createGamePostgresConnector, + type GamePrisma, + type InputJsonValue, + type TurnEngineEventCreateManyInput, +} from '@sammo-ts/infra'; import { asNumber, asRecord } from '@sammo-ts/common'; import { buildScenarioBootstrap, @@ -47,6 +52,8 @@ export interface ScenarioInstallOptions { preopenAt?: Date | null; season?: number; serverId?: string; + installOperationId?: string; + installCommitSha?: string; } export interface ScenarioSeedOptions { @@ -63,38 +70,17 @@ export interface ScenarioSeedOptions { includeNeutralNationInSeed?: boolean; defaultGeneralGold?: number; defaultGeneralRice?: number; + onBeforeCommit?: (transaction: GamePrisma.TransactionClient, result: ScenarioSeedResult) => Promise; } export interface ScenarioSeedResult { seed: WorldSeedPayload; warnings: ScenarioBootstrapWarning[]; + applied: boolean; } const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; -type RawQueryClient = { - $queryRawUnsafe(query: string): Promise; -}; - -const resolveSchemaName = (databaseUrl: string): string => { - try { - return new URL(databaseUrl).searchParams.get('schema') ?? 'public'; - } catch { - return 'public'; - } -}; - -const hasEventTable = async (prisma: RawQueryClient, schema: string): Promise => { - try { - const result = await prisma.$queryRawUnsafe>( - `SELECT to_regclass('${schema}.event')::text as regclass` - ); - return Array.isArray(result) && result.length > 0 && result[0]?.regclass !== null; - } catch { - return false; - } -}; - const formatDateTime = (date: Date): string => { const pad = (value: number): string => String(value).padStart(2, '0'); return [ @@ -280,6 +266,12 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom if (typeof install?.serverId === 'string' && install.serverId.trim()) { worldMeta.serverId = install.serverId.trim(); } + if (typeof install?.installOperationId === 'string' && install.installOperationId.trim()) { + worldMeta.installOperationId = install.installOperationId.trim(); + } + if (typeof install?.installCommitSha === 'string' && install.installCommitSha.trim()) { + worldMeta.installCommitSha = install.installCommitSha.trim(); + } const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]?.trim(); worldMeta.hiddenSeed = @@ -300,278 +292,337 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom await connector.connect(); try { - const prisma = connector.prisma; - const schema = resolveSchemaName(options.databaseUrl); - const eventTableReady = await hasEventTable(prisma, schema); - - if (options.resetTables ?? true) { - if (eventTableReady) { - await prisma.event.deleteMany(); - } - await prisma.selectPoolEntry.deleteMany(); - await prisma.generalTurn.deleteMany(); - await prisma.generalTurnRevision.deleteMany(); - await prisma.rankData.deleteMany(); - await prisma.generalAccessLog.deleteMany(); - await prisma.diplomacy.deleteMany(); - await prisma.general.deleteMany(); - await prisma.troop.deleteMany(); - await prisma.city.deleteMany(); - await prisma.nation.deleteMany(); - await prisma.worldState.deleteMany(); - } - - await prisma.worldState.create({ - data: { - scenarioCode: String(options.scenarioId), - currentYear: startState.currentYear, - currentMonth: startState.currentMonth, - tickSeconds, - config: asJson({ ...scenarioConfig, ...worldConfig }), - meta: asJson(worldMeta), - }, - }); - - if (generalPoolEntries.length > 0) { - await prisma.selectPoolEntry.createMany({ - data: generalPoolEntries.map((entry) => ({ - uniqueName: entry.uniqueName, - info: asJson(entry.info), - })), - }); - } - - if (typeof worldMeta.serverId === 'string' && worldMeta.serverId) { - await prisma.gameHistory.upsert({ - where: { serverId: worldMeta.serverId }, - create: { - serverId: worldMeta.serverId, - date: now, - winnerNation: null, - map: scenario.config.environment.mapName ?? null, - season: - typeof worldMeta.season === 'number' && Number.isFinite(worldMeta.season) - ? Math.floor(worldMeta.season) - : 1, - scenario: options.scenarioId, - scenarioName: String(seed.scenarioMeta?.title ?? ''), - env: asJson({ - config: scenarioConfig, - meta: archivedWorldMeta, - }), - }, - update: { - date: now, - winnerNation: null, - map: scenario.config.environment.mapName ?? null, - season: - typeof worldMeta.season === 'number' && Number.isFinite(worldMeta.season) - ? Math.floor(worldMeta.season) - : 1, - scenario: options.scenarioId, - scenarioName: String(seed.scenarioMeta?.title ?? ''), - env: asJson({ - config: scenarioConfig, - meta: archivedWorldMeta, - }), - }, - }); - } - - if (seed.nations.length > 0) { - await prisma.nation.createMany({ - data: seed.nations.map((nation) => ({ - id: nation.id, - name: nation.name, - color: nation.color, - capitalCityId: nation.capitalCityId ?? null, - gold: nation.gold, - rice: nation.rice, - tech: nation.tech, - level: nation.level, - typeCode: nation.typeCode, - meta: asJson({ - infoText: nation.infoText, - cityIds: nation.cityIds, - }), - })), - }); - } - - if (seed.cities.length > 0) { - await prisma.city.createMany({ - data: seed.cities.map((city) => ({ - id: city.id, - name: city.name, - level: city.level, - nationId: city.nationId, - supplyState: city.supplyState, - frontState: city.frontState, - 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: city.trust, - trade: city.trade, - defence: city.defence, - defenceMax: city.defenceMax, - wall: city.wall, - wallMax: city.wallMax, - region: city.region, - conflict: asJson({}), - meta: asJson({ - position: city.position, - connections: city.connections, - state: city.state, - ...city.meta, - }), - })), - }); - } - - if (seed.generals.length > 0) { - await prisma.general.createMany({ - data: seed.generals.map((general) => ({ - id: general.id, - name: general.name, - nationId: general.nationId, - cityId: general.cityId, - npcState: general.npcType, - affinity: general.affinity, - bornYear: general.birthYear, - deadYear: general.deathYear, - picture: general.picture === null ? null : String(general.picture), - leadership: general.stats.leadership, - strength: general.stats.strength, - intel: general.stats.intelligence, - officerLevel: general.officerLevel, - gold: generalGold, - rice: generalRice, - crewTypeId: general.crewTypeId, - horseCode: general.horse ?? 'None', - weaponCode: general.weapon ?? 'None', - bookCode: general.book ?? 'None', - itemCode: general.item ?? 'None', - turnTime: now, - age: resolveGeneralAge(scenario.startYear ?? null, general.birthYear), - startAge: resolveGeneralAge(scenario.startYear ?? null, general.birthYear), - personalCode: general.personality ?? 'None', - specialCode: general.special ?? 'None', - special2Code: general.specialWar ?? 'None', - lastTurn: asJson({}), - meta: asJson( - (() => { - const meta = { ...general.meta } as Record; - if (typeof meta.birthYear !== 'number' || !Number.isFinite(meta.birthYear)) { - meta.birthYear = general.birthYear; - } - delete meta.deathYear; - delete meta.deadYear; - const fallbackKillturn = - typeof meta.killturn === 'number' && Number.isFinite(meta.killturn) ? meta.killturn : 0; - const deathMonth = - typeof meta.deathMonth === 'number' && - Number.isInteger(meta.deathMonth) && - meta.deathMonth >= 1 && - meta.deathMonth <= 12 - ? meta.deathMonth - : resolveScenarioGeneralDeathMonth({ - scenarioTitle: String(seed.scenarioMeta?.title ?? ''), - startYear: seed.scenarioMeta?.startYear ?? null, - contextLabel: typeof meta.source === 'string' ? meta.source : 'general', - generalId: general.id, - generalName: general.name, - deathYear: general.deathYear, - }); - const killturn = resolveKillturnFromDeathYear( - startState.currentYear, - startState.currentMonth, - general.deathYear, - deathMonth, - fallbackKillturn + const result: ScenarioSeedResult = { seed, warnings, applied: true }; + const applied = await connector.prisma.$transaction( + async (prisma) => { + await prisma.$queryRawUnsafe( + 'SELECT pg_advisory_xact_lock(hashtextextended(current_schema(), 0))::text AS lock_result' + ); + const requestedInstallOperationId = worldMeta.installOperationId; + const requestedInstallCommitSha = worldMeta.installCommitSha; + if (typeof requestedInstallOperationId === 'string') { + const existingWorld = await prisma.worldState.findFirst({ select: { meta: true } }); + const existingWorldMeta = asRecord(existingWorld?.meta); + if (existingWorldMeta.installOperationId === requestedInstallOperationId) { + if (existingWorldMeta.installCommitSha !== requestedInstallCommitSha) { + throw new Error( + `Install operation ${requestedInstallOperationId} belongs to a different source commit.` ); - return { - ...meta, - killturn, - deathMonth, - npcType: general.npcType, - crewTypeId: general.crewTypeId, - } satisfies GeneralMeta; - })() - ), - penalty: asJson({}), - })), - }); - } - - if (seed.troops.length > 0) { - await prisma.troop.createMany({ - data: seed.troops.map((troop) => ({ - troopLeaderId: troop.id, - nationId: troop.nationId, - name: troop.name, - })), - }); - } - - const diplomacyMap = new Map< - string, - { srcNationId: number; destNationId: number; state: number; term: number } - >(); - const nationIds = seed.nations.map((nation) => nation.id); - for (const srcNationId of nationIds) { - for (const destNationId of nationIds) { - if (srcNationId === destNationId) { - continue; + } + return false; + } + } + if (options.resetTables ?? true) { + await prisma.inputEvent.deleteMany(); + await prisma.turnDaemonLease.deleteMany(); + await prisma.npcSelectionToken.deleteMany(); + await prisma.trafficPeriodGeneral.deleteMany(); + await prisma.trafficPeriod.deleteMany(); + await prisma.messageReadState.deleteMany(); + await prisma.message.deleteMany(); + await prisma.nationTurn.deleteMany(); + await prisma.nationTurnRevision.deleteMany(); + await prisma.generalTurn.deleteMany(); + await prisma.generalTurnRevision.deleteMany(); + await prisma.selectPoolEntry.deleteMany(); + await prisma.generalAccessLog.deleteMany(); + await prisma.rankData.deleteMany(); + await prisma.diplomacyLetter.deleteMany(); + await prisma.diplomacy.deleteMany(); + await prisma.auctionBid.deleteMany(); + await prisma.auction.deleteMany(); + await prisma.nationBet.deleteMany(); + await prisma.nationBetting.deleteMany(); + await prisma.boardComment.deleteMany(); + await prisma.boardPost.deleteMany(); + await prisma.voteComment.deleteMany(); + await prisma.vote.deleteMany(); + await prisma.votePoll.deleteMany(); + await prisma.logEntry.deleteMany(); + await prisma.event.deleteMany(); + await prisma.general.deleteMany(); + await prisma.troop.deleteMany(); + await prisma.city.deleteMany(); + await prisma.nation.deleteMany(); + await prisma.worldState.deleteMany(); } - diplomacyMap.set(`${srcNationId}:${destNationId}`, { - srcNationId, - destNationId, - state: 2, - term: 0, - }); - } - } - for (const row of seed.diplomacy) { - diplomacyMap.set(`${row.fromNationId}:${row.toNationId}`, { - srcNationId: row.fromNationId, - destNationId: row.toNationId, - state: row.state, - term: row.durationMonths, - }); - diplomacyMap.set(`${row.toNationId}:${row.fromNationId}`, { - srcNationId: row.toNationId, - destNationId: row.fromNationId, - state: row.state, - term: row.durationMonths, - }); - } - const diplomacyRows = Array.from(diplomacyMap.values()); - if (diplomacyRows.length > 0) { - await prisma.diplomacy.createMany({ - data: diplomacyRows.map((row) => ({ - srcNationId: row.srcNationId, - destNationId: row.destNationId, - stateCode: row.state, - term: row.term, - meta: asJson({}), - })), - }); - } - const eventRows = buildEventRows(seed.events); - if (eventRows.length > 0 && eventTableReady) { - await prisma.event.createMany({ - data: eventRows, - }); - } + await prisma.worldState.create({ + data: { + scenarioCode: String(options.scenarioId), + currentYear: startState.currentYear, + currentMonth: startState.currentMonth, + tickSeconds, + config: asJson({ ...scenarioConfig, ...worldConfig }), + meta: asJson(worldMeta), + }, + }); + + if (generalPoolEntries.length > 0) { + await prisma.selectPoolEntry.createMany({ + data: generalPoolEntries.map((entry) => ({ + uniqueName: entry.uniqueName, + info: asJson(entry.info), + })), + }); + } + + if (typeof worldMeta.serverId === 'string' && worldMeta.serverId) { + const existingHistory = await prisma.gameHistory.findUnique({ + where: { serverId: worldMeta.serverId }, + select: { env: true }, + }); + const requestedInstallOperationId = worldMeta.installOperationId; + const requestedInstallCommitSha = worldMeta.installCommitSha; + if (existingHistory && typeof requestedInstallOperationId === 'string') { + const existingHistoryMeta = asRecord(asRecord(existingHistory.env).meta); + if ( + existingHistoryMeta.installOperationId !== requestedInstallOperationId || + existingHistoryMeta.installCommitSha !== requestedInstallCommitSha + ) { + throw new Error( + `Game history serverId collision for install operation ${requestedInstallOperationId}.` + ); + } + } + await prisma.gameHistory.upsert({ + where: { serverId: worldMeta.serverId }, + create: { + serverId: worldMeta.serverId, + date: now, + winnerNation: null, + map: scenario.config.environment.mapName ?? null, + season: + typeof worldMeta.season === 'number' && Number.isFinite(worldMeta.season) + ? Math.floor(worldMeta.season) + : 1, + scenario: options.scenarioId, + scenarioName: String(seed.scenarioMeta?.title ?? ''), + env: asJson({ + config: scenarioConfig, + meta: archivedWorldMeta, + }), + }, + update: { + date: now, + winnerNation: null, + map: scenario.config.environment.mapName ?? null, + season: + typeof worldMeta.season === 'number' && Number.isFinite(worldMeta.season) + ? Math.floor(worldMeta.season) + : 1, + scenario: options.scenarioId, + scenarioName: String(seed.scenarioMeta?.title ?? ''), + env: asJson({ + config: scenarioConfig, + meta: archivedWorldMeta, + }), + }, + }); + } + + if (seed.nations.length > 0) { + await prisma.nation.createMany({ + data: seed.nations.map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId ?? null, + gold: nation.gold, + rice: nation.rice, + tech: nation.tech, + level: nation.level, + typeCode: nation.typeCode, + meta: asJson({ + infoText: nation.infoText, + cityIds: nation.cityIds, + }), + })), + }); + } + + if (seed.cities.length > 0) { + await prisma.city.createMany({ + data: seed.cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + nationId: city.nationId, + supplyState: city.supplyState, + frontState: city.frontState, + 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: city.trust, + trade: city.trade, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: city.region, + conflict: asJson({}), + meta: asJson({ + position: city.position, + connections: city.connections, + state: city.state, + ...city.meta, + }), + })), + }); + } + + if (seed.generals.length > 0) { + await prisma.general.createMany({ + data: seed.generals.map((general) => ({ + id: general.id, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + npcState: general.npcType, + affinity: general.affinity, + bornYear: general.birthYear, + deadYear: general.deathYear, + picture: general.picture === null ? null : String(general.picture), + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + officerLevel: general.officerLevel, + gold: generalGold, + rice: generalRice, + crewTypeId: general.crewTypeId, + horseCode: general.horse ?? 'None', + weaponCode: general.weapon ?? 'None', + bookCode: general.book ?? 'None', + itemCode: general.item ?? 'None', + turnTime: now, + age: resolveGeneralAge(scenario.startYear ?? null, general.birthYear), + startAge: resolveGeneralAge(scenario.startYear ?? null, general.birthYear), + personalCode: general.personality ?? 'None', + specialCode: general.special ?? 'None', + special2Code: general.specialWar ?? 'None', + lastTurn: asJson({}), + meta: asJson( + (() => { + const meta = { ...general.meta } as Record; + if (typeof meta.birthYear !== 'number' || !Number.isFinite(meta.birthYear)) { + meta.birthYear = general.birthYear; + } + delete meta.deathYear; + delete meta.deadYear; + const fallbackKillturn = + typeof meta.killturn === 'number' && Number.isFinite(meta.killturn) + ? meta.killturn + : 0; + const deathMonth = + typeof meta.deathMonth === 'number' && + Number.isInteger(meta.deathMonth) && + meta.deathMonth >= 1 && + meta.deathMonth <= 12 + ? meta.deathMonth + : resolveScenarioGeneralDeathMonth({ + scenarioTitle: String(seed.scenarioMeta?.title ?? ''), + startYear: seed.scenarioMeta?.startYear ?? null, + contextLabel: + typeof meta.source === 'string' ? meta.source : 'general', + generalId: general.id, + generalName: general.name, + deathYear: general.deathYear, + }); + const killturn = resolveKillturnFromDeathYear( + startState.currentYear, + startState.currentMonth, + general.deathYear, + deathMonth, + fallbackKillturn + ); + return { + ...meta, + killturn, + deathMonth, + npcType: general.npcType, + crewTypeId: general.crewTypeId, + } satisfies GeneralMeta; + })() + ), + penalty: asJson({}), + })), + }); + } + + if (seed.troops.length > 0) { + await prisma.troop.createMany({ + data: seed.troops.map((troop) => ({ + troopLeaderId: troop.id, + nationId: troop.nationId, + name: troop.name, + })), + }); + } + + const diplomacyMap = new Map< + string, + { srcNationId: number; destNationId: number; state: number; term: number } + >(); + const nationIds = seed.nations.map((nation) => nation.id); + for (const srcNationId of nationIds) { + for (const destNationId of nationIds) { + if (srcNationId === destNationId) { + continue; + } + diplomacyMap.set(`${srcNationId}:${destNationId}`, { + srcNationId, + destNationId, + state: 2, + term: 0, + }); + } + } + for (const row of seed.diplomacy) { + diplomacyMap.set(`${row.fromNationId}:${row.toNationId}`, { + srcNationId: row.fromNationId, + destNationId: row.toNationId, + state: row.state, + term: row.durationMonths, + }); + diplomacyMap.set(`${row.toNationId}:${row.fromNationId}`, { + srcNationId: row.toNationId, + destNationId: row.fromNationId, + state: row.state, + term: row.durationMonths, + }); + } + const diplomacyRows = Array.from(diplomacyMap.values()); + if (diplomacyRows.length > 0) { + await prisma.diplomacy.createMany({ + data: diplomacyRows.map((row) => ({ + srcNationId: row.srcNationId, + destNationId: row.destNationId, + stateCode: row.state, + term: row.term, + meta: asJson({}), + })), + }); + } + + const eventRows = buildEventRows(seed.events); + if (eventRows.length > 0) { + await prisma.event.createMany({ + data: eventRows, + }); + } + await options.onBeforeCommit?.(prisma, result); + return true; + }, + { maxWait: 10_000, timeout: 60_000 } + ); + result.applied = applied; + return result; } finally { await connector.disconnect(); } - - return { seed, warnings }; }; diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 6bf7594d..bd909ab7 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -32,9 +32,7 @@ type ScenarioSeederPrismaClient = { }; selectPoolEntry: { count(): Promise; - findFirst(args: { - orderBy: { id: 'asc' | 'desc' }; - }): Promise<{ uniqueName: string; info: unknown } | null>; + findFirst(args: { orderBy: { id: 'asc' | 'desc' } }): Promise<{ uniqueName: string; info: unknown } | null>; }; diplomacy: { count(): Promise; @@ -397,9 +395,7 @@ describeDb('scenario database seed', () => { hiddenSeed: 'scenario-seeder-explicit-hidden-seed', }); const explicitHistory = await prisma.gameHistory.findUnique({ where: { serverId } }); - expect((explicitHistory?.env as { meta?: Record })?.meta).not.toHaveProperty( - 'hiddenSeed' - ); + expect((explicitHistory?.env as { meta?: Record })?.meta).not.toHaveProperty('hiddenSeed'); delete process.env[envName]; await seedScenarioToDatabase({ @@ -412,9 +408,7 @@ describeDb('scenario database seed', () => { expect(randomSeed).toMatch(/^[0-9a-f]{32}$/); expect(randomSeed).not.toBe('scenario-seeder-explicit-hidden-seed'); const randomHistory = await prisma.gameHistory.findUnique({ where: { serverId } }); - expect((randomHistory?.env as { meta?: Record })?.meta).not.toHaveProperty( - 'hiddenSeed' - ); + expect((randomHistory?.env as { meta?: Record })?.meta).not.toHaveProperty('hiddenSeed'); await prisma.gameHistory.deleteMany({ where: { serverId } }); } finally { if (originalSeed === undefined) { @@ -425,6 +419,399 @@ describeDb('scenario database seed', () => { await connector.disconnect(); } }); + + test('serializes and skips the same committed install generation inside the seed transaction', async () => { + const envName = 'INTEGRATION_WORLD_SEED'; + const originalSeed = process.env[envName]; + const serverId = 'scenario-seeder-idempotent-generation'; + const installOperationId = 'scenario-seeder-idempotent-operation'; + const installCommitSha = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const connector = createGamePostgresConnector({ url: databaseUrl }); + try { + process.env[envName] = 'scenario-seeder-idempotent-hidden-seed'; + const results = await Promise.all([ + seedScenarioToDatabase({ + scenarioId: 1010, + databaseUrl, + installOptions: { serverId, installOperationId, installCommitSha }, + }), + seedScenarioToDatabase({ + scenarioId: 1010, + databaseUrl, + installOptions: { serverId, installOperationId, installCommitSha }, + }), + ]); + expect(results.map(({ applied }) => applied).sort()).toEqual([false, true]); + + await connector.connect(); + const worldBeforeMismatch = await connector.prisma.worldState.findFirstOrThrow(); + expect(worldBeforeMismatch.meta).toMatchObject({ + hiddenSeed: 'scenario-seeder-idempotent-hidden-seed', + installOperationId, + installCommitSha, + }); + await expect( + seedScenarioToDatabase({ + scenarioId: 903, + databaseUrl, + installOptions: { + serverId, + installOperationId, + installCommitSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + }) + ).rejects.toThrow('belongs to a different source commit'); + await expect(connector.prisma.worldState.findFirstOrThrow()).resolves.toEqual(worldBeforeMismatch); + await expect(connector.prisma.gameHistory.count({ where: { serverId } })).resolves.toBe(1); + } finally { + if (originalSeed === undefined) { + delete process.env[envName]; + } else { + process.env[envName] = originalSeed; + } + await connector.prisma.gameHistory.deleteMany({ where: { serverId } }); + await connector.disconnect(); + } + }); + + test('rolls back the complete season replacement when the final seed hook fails', async () => { + const envName = 'INTEGRATION_WORLD_SEED'; + const originalSeed = process.env[envName]; + const baselineServerId = 'scenario-seeder-rollback-baseline'; + const failedServerId = 'scenario-seeder-rollback-failed'; + const connector = createGamePostgresConnector({ url: databaseUrl }); + try { + process.env[envName] = 'scenario-seeder-rollback-hidden-seed'; + await seedScenarioToDatabase({ + scenarioId: 1010, + databaseUrl, + now: new Date('2031-01-01T00:00:00.000Z'), + installOptions: { + serverId: baselineServerId, + installOperationId: 'baseline-operation', + }, + }); + + await connector.connect(); + const prisma = connector.prisma; + const readSnapshot = async () => ({ + world: await prisma.worldState.findMany({ orderBy: { id: 'asc' } }), + nations: await prisma.nation.findMany({ orderBy: { id: 'asc' } }), + cities: await prisma.city.findMany({ orderBy: { id: 'asc' } }), + generals: await prisma.general.findMany({ orderBy: { id: 'asc' } }), + troops: await prisma.troop.findMany({ orderBy: { troopLeaderId: 'asc' } }), + diplomacy: await prisma.diplomacy.findMany({ orderBy: { id: 'asc' } }), + events: await prisma.event.findMany({ orderBy: { id: 'asc' } }), + history: await prisma.gameHistory.findMany({ orderBy: { id: 'asc' } }), + }); + const before = await readSnapshot(); + + await expect( + seedScenarioToDatabase({ + scenarioId: 903, + databaseUrl, + now: new Date('2032-01-01T00:00:00.000Z'), + installOptions: { + serverId: baselineServerId, + installOperationId: 'colliding-operation', + }, + }) + ).rejects.toThrow('Game history serverId collision'); + expect(await readSnapshot()).toEqual(before); + + await expect( + seedScenarioToDatabase({ + scenarioId: 903, + databaseUrl, + now: new Date('2032-02-02T00:00:00.000Z'), + installOptions: { + serverId: failedServerId, + installOperationId: 'failed-operation', + }, + onBeforeCommit: async () => { + throw new Error('injected final seed failure'); + }, + }) + ).rejects.toThrow('injected final seed failure'); + + expect(await readSnapshot()).toEqual(before); + await expect(prisma.gameHistory.findUnique({ where: { serverId: failedServerId } })).resolves.toBeNull(); + } finally { + if (originalSeed === undefined) { + delete process.env[envName]; + } else { + process.env[envName] = originalSeed; + } + await connector.prisma.gameHistory.deleteMany({ + where: { serverId: { in: [baselineServerId, failedServerId] } }, + }); + await connector.disconnect(); + } + }); + + test('clears current-season services while preserving archive and diagnostic data', async () => { + const marker = 'scenario-reset-boundary'; + const serverId = 'rst-boundary-server'; + const connector = createGamePostgresConnector({ url: databaseUrl }); + await seedScenarioToDatabase({ scenarioId: 1010, databaseUrl }); + await connector.connect(); + const prisma = connector.prisma; + try { + const world = await prisma.worldState.findFirstOrThrow(); + const general = await prisma.general.findFirstOrThrow({ orderBy: { id: 'asc' } }); + const nation = await prisma.nation.findFirstOrThrow({ orderBy: { id: 'asc' } }); + const trafficPeriod = await prisma.trafficPeriod.create({ + data: { + worldStateId: world.id, + year: 999, + month: 12, + startedAt: new Date('2033-01-01T00:00:00.000Z'), + lastRefresh: new Date('2033-01-01T00:00:00.000Z'), + }, + }); + await prisma.trafficPeriodGeneral.create({ + data: { + periodId: trafficPeriod.id, + generalId: general.id, + refresh: 1, + lastRefresh: new Date('2033-01-01T00:00:00.000Z'), + }, + }); + await prisma.inputEvent.create({ + data: { requestId: marker, target: 'API', eventType: marker }, + }); + await prisma.turnDaemonLease.create({ + data: { + profile: marker, + ownerId: marker, + leaseUntil: new Date('2033-01-01T00:00:00.000Z'), + }, + }); + await prisma.npcSelectionToken.create({ + data: { + ownerUserId: marker, + validUntil: new Date('2033-01-01T00:00:00.000Z'), + pickMoreFrom: new Date('2033-01-01T00:00:00.000Z'), + pickResult: {}, + nonce: 1, + }, + }); + await prisma.messageReadState.create({ data: { generalId: general.id } }); + await prisma.message.create({ + data: { + mailbox: general.id, + type: marker, + src: general.id, + dest: general.id, + time: new Date('2033-01-01T00:00:00.000Z'), + validUntil: new Date('2033-02-01T00:00:00.000Z'), + message: { marker }, + }, + }); + await prisma.nationTurn.create({ + data: { nationId: nation.id, officerLevel: 12, turnIdx: 0, actionCode: marker }, + }); + await prisma.nationTurnRevision.create({ + data: { nationId: nation.id, officerLevel: 12, revision: 1 }, + }); + await prisma.diplomacyLetter.create({ + data: { + srcNationId: nation.id, + destNationId: 0, + state: 'PROPOSED', + textBrief: marker, + textDetail: marker, + srcSignerId: general.id, + }, + }); + const auction = await prisma.auction.create({ + data: { + type: 'UNIQUE_ITEM', + targetCode: marker, + hostGeneralId: general.id, + status: 'OPEN', + closeAt: new Date('2033-01-01T00:00:00.000Z'), + }, + }); + await prisma.auctionBid.create({ + data: { + auctionId: auction.id, + generalId: general.id, + amount: 1, + eventId: marker, + eventAt: new Date('2033-01-01T00:00:00.000Z'), + }, + }); + const bettingId = 990_731; + await prisma.nationBetting.create({ + data: { + id: bettingId, + name: marker, + selectCount: 1, + openYearMonth: 99901, + closeYearMonth: 99902, + candidates: [{ id: nation.id }], + }, + }); + await prisma.nationBet.create({ + data: { + bettingId, + generalId: general.id, + userId: marker, + selection: [nation.id], + selectionKey: String(nation.id), + amount: 1, + }, + }); + const post = await prisma.boardPost.create({ + data: { + nationId: nation.id, + authorGeneralId: general.id, + authorName: marker, + title: marker, + contentHtml: marker, + }, + }); + await prisma.boardComment.create({ + data: { + postId: post.id, + nationId: nation.id, + authorGeneralId: general.id, + authorName: marker, + contentText: marker, + }, + }); + const poll = await prisma.votePoll.create({ + data: { + title: marker, + options: [marker], + revealMode: 'never', + openerGeneralId: general.id, + openerName: marker, + }, + }); + await prisma.vote.create({ + data: { voteId: poll.id, generalId: general.id, nationId: nation.id, selection: [0] }, + }); + await prisma.voteComment.create({ + data: { + voteId: poll.id, + generalId: general.id, + nationId: nation.id, + generalName: marker, + nationName: marker, + text: marker, + }, + }); + await prisma.logEntry.create({ + data: { scope: 'SYSTEM', category: 'HISTORY', year: 999, month: 12, text: marker }, + }); + + await prisma.errorLog.create({ data: { category: marker, message: marker } }); + await prisma.inheritancePoint.create({ data: { userId: marker, key: marker, value: 1 } }); + await prisma.inheritanceLog.create({ + data: { userId: marker, serverId, year: 999, month: 12, text: marker }, + }); + await prisma.inheritanceResult.create({ + data: { serverId, owner: marker, generalId: general.id, year: 999, month: 12, value: { marker } }, + }); + await prisma.inheritanceUserState.create({ data: { userId: marker, meta: { marker } } }); + await prisma.gameHistory.create({ + data: { + serverId, + date: new Date('2033-01-01T00:00:00.000Z'), + season: 1, + scenario: 1010, + scenarioName: marker, + env: { marker }, + }, + }); + await prisma.oldNation.create({ data: { serverId, nation: 1, sourceId: 1, data: { marker } } }); + await prisma.oldGeneral.create({ + data: { + serverId, + generalNo: general.id, + owner: marker, + name: marker, + lastYearMonth: 99912, + turnTime: new Date('2033-01-01T00:00:00.000Z'), + data: { marker }, + }, + }); + await prisma.emperor.create({ data: { serverId, name: marker, history: { marker }, aux: { marker } } }); + await prisma.yearbookHistory.create({ + data: { + profileName: marker, + year: 999, + month: 12, + map: {}, + nations: {}, + }, + }); + await prisma.legacyGameStorage.create({ + data: { sourceId: 990_731, namespace: marker, key: marker, value: {}, scope: marker }, + }); + await prisma.hallOfFame.create({ + data: { + serverId, + season: 1, + scenario: 1010, + generalNo: general.id, + type: 'reset-boundary', + value: 1, + }, + }); + + await seedScenarioToDatabase({ scenarioId: 903, databaseUrl }); + + await expect( + Promise.all([ + prisma.inputEvent.count({ where: { requestId: marker } }), + prisma.turnDaemonLease.count({ where: { profile: marker } }), + prisma.npcSelectionToken.count({ where: { ownerUserId: marker } }), + prisma.trafficPeriod.count({ where: { id: trafficPeriod.id } }), + prisma.message.count({ where: { type: marker } }), + prisma.nationTurn.count({ where: { actionCode: marker } }), + prisma.diplomacyLetter.count({ where: { textBrief: marker } }), + prisma.auction.count({ where: { targetCode: marker } }), + prisma.nationBetting.count({ where: { id: bettingId } }), + prisma.boardPost.count({ where: { title: marker } }), + prisma.votePoll.count({ where: { title: marker } }), + prisma.logEntry.count({ where: { text: marker } }), + ]) + ).resolves.toEqual(Array.from({ length: 12 }, () => 0)); + await expect( + Promise.all([ + prisma.errorLog.count({ where: { category: marker } }), + prisma.inheritancePoint.count({ where: { userId: marker } }), + prisma.inheritanceLog.count({ where: { userId: marker } }), + prisma.inheritanceResult.count({ where: { owner: marker } }), + prisma.inheritanceUserState.count({ where: { userId: marker } }), + prisma.gameHistory.count({ where: { serverId } }), + prisma.oldNation.count({ where: { serverId } }), + prisma.oldGeneral.count({ where: { serverId } }), + prisma.emperor.count({ where: { serverId } }), + prisma.yearbookHistory.count({ where: { profileName: marker } }), + prisma.legacyGameStorage.count({ where: { namespace: marker } }), + prisma.hallOfFame.count({ where: { serverId } }), + ]) + ).resolves.toEqual(Array.from({ length: 12 }, () => 1)); + } finally { + await prisma.errorLog.deleteMany({ where: { category: marker } }); + await prisma.inheritancePoint.deleteMany({ where: { userId: marker } }); + await prisma.inheritanceLog.deleteMany({ where: { userId: marker } }); + await prisma.inheritanceResult.deleteMany({ where: { owner: marker } }); + await prisma.inheritanceUserState.deleteMany({ where: { userId: marker } }); + await prisma.oldNation.deleteMany({ where: { serverId } }); + await prisma.oldGeneral.deleteMany({ where: { serverId } }); + await prisma.emperor.deleteMany({ where: { serverId } }); + await prisma.gameHistory.deleteMany({ where: { serverId } }); + await prisma.yearbookHistory.deleteMany({ where: { profileName: marker } }); + await prisma.legacyGameStorage.deleteMany({ where: { namespace: marker } }); + await prisma.hallOfFame.deleteMany({ where: { serverId } }); + await connector.disconnect(); + } + }); }); describe('tracked scenario composition', () => {