import { createGamePostgresConnector } from '@sammo-ts/infra'; import { describe, expect, test } from 'vitest'; import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js'; import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js'; import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js'; const scenarioId = 1010; const schema = process.env.POSTGRES_SCHEMA ?? 'public'; process.env.POSTGRES_SCHEMA = schema; const databaseUrl = await resolveDatabaseUrl({ schema }); type ScenarioSeederPrismaClient = { $queryRawUnsafe(query: string): Promise; nation: { count(): Promise; }; city: { count(): Promise; findUnique(args: { where: { id: number } }): Promise<{ population: number; agriculture: number; commerce: number; security: number; trust: number; defence: number; wall: number; } | null>; }; general: { count(): Promise; findFirst(): Promise<{ age: number; startAge: number; meta: unknown } | null>; }; selectPoolEntry: { count(): Promise; findFirst(args: { orderBy: { id: 'asc' | 'desc' }; }): Promise<{ uniqueName: string; info: unknown } | null>; }; diplomacy: { count(): Promise; findFirst(args: { where: { srcNationId: number; destNationId: number }; }): Promise<{ stateCode: number; term: number } | null>; }; event: { count(): Promise; }; worldState: { findFirst(): Promise<{ config: unknown; meta: unknown; tickSeconds: number; currentYear: number; currentMonth: number; } | null>; }; gameHistory: { findUnique(args: { where: { serverId: string } }): Promise<{ env: unknown } | null>; deleteMany(args: { where: { serverId: string } }): Promise<{ count: number }>; }; }; const requiredTables = ['world_state', 'nation', 'city', 'general', 'diplomacy', 'troop', 'event']; const hasRequiredTables = async (prisma: ScenarioSeederPrismaClient, schemaName: string): Promise => { for (const table of requiredTables) { const result = (await prisma.$queryRawUnsafe( `SELECT to_regclass('${schemaName}.${table}')::text as regclass` )) as Array<{ regclass: string | null }>; if (!Array.isArray(result) || result.length === 0 || result[0]?.regclass === null) { return false; } } return true; }; const canConnectToDatabase = async (url: string): Promise => { const connector = createGamePostgresConnector({ url }); try { await connector.connect(); const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; await prisma.$queryRawUnsafe('SELECT 1'); if (!(await hasRequiredTables(prisma, schema))) { return false; } return true; } catch { return false; } finally { await connector.disconnect(); } }; const canRun = await canConnectToDatabase(databaseUrl); const describeDb = describe.runIf(canRun); describeDb('scenario database seed', () => { test('writes scenario data into tables', async () => { const { seed } = await seedScenarioToDatabase({ scenarioId, databaseUrl, }); const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); try { const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; const [nationCount, cityCount, generalCount, diplomacyCount, eventCount] = await Promise.all([ prisma.nation.count(), prisma.city.count(), prisma.general.count(), prisma.diplomacy.count(), prisma.event.count(), ]); expect(nationCount).toBe(seed.nations.length); expect(cityCount).toBe(seed.cities.length); expect(generalCount).toBe(seed.generals.length); expect(diplomacyCount).toBe(seed.nations.length * Math.max(0, seed.nations.length - 1)); expect(eventCount).toBe(seed.events.length); expect(generalCount).toBeGreaterThan(0); const seededGeneral = await prisma.general.findFirst(); expect(seededGeneral?.startAge).toBe(seededGeneral?.age); expect(seededGeneral?.meta).toEqual( expect.objectContaining({ specage: expect.any(Number), specage2: expect.any(Number), }) ); const freeCity = seed.cities.find((city) => city.nationId === 0); const occupiedCity = seed.cities.find((city) => city.nationId !== 0); expect(freeCity).toMatchObject({ population: freeCity ? Math.round(freeCity.populationMax * 0.7) : undefined, agriculture: freeCity ? Math.round(freeCity.agricultureMax * 0.7) : undefined, commerce: freeCity ? Math.round(freeCity.commerceMax * 0.7) : undefined, security: freeCity ? Math.round(freeCity.securityMax * 0.7) : undefined, trust: 80, }); expect(occupiedCity).toMatchObject({ population: occupiedCity ? Math.round(occupiedCity.populationMax * 0.7) : undefined, defence: occupiedCity ? Math.round(occupiedCity.defenceMax * 0.7) : undefined, wall: occupiedCity ? Math.round(occupiedCity.wallMax * 0.7) : undefined, trust: 80, }); for (const city of [freeCity, occupiedCity]) { expect(city).toBeDefined(); if (!city) { continue; } expect(await prisma.city.findUnique({ where: { id: city.id } })).toMatchObject({ population: city.population, agriculture: city.agriculture, commerce: city.commerce, security: city.security, trust: city.trust, defence: city.defence, wall: city.wall, }); } if (seed.diplomacy.length > 0) { const sample = seed.diplomacy[0]; const row = await prisma.diplomacy.findFirst({ where: { srcNationId: sample.fromNationId, destNationId: sample.toNationId, }, }); expect(row).not.toBeNull(); if (row) { expect(row.stateCode).toBe(sample.state); expect(row.term).toBe(sample.durationMonths); } const reverse = await prisma.diplomacy.findFirst({ where: { srcNationId: sample.toNationId, destNationId: sample.fromNationId, }, }); expect(reverse).not.toBeNull(); if (reverse) { expect(reverse.stateCode).toBe(sample.state); expect(reverse.term).toBe(sample.durationMonths); } } } finally { await connector.disconnect(); } }); test('applies install options to world state', async () => { const scenario = await loadScenarioDefinitionById(scenarioId); const { seed } = await seedScenarioToDatabase({ scenarioId, databaseUrl, now: new Date('2030-01-01T00:00:00Z'), installOptions: { turnTermMinutes: 60, sync: false, fiction: 1, extend: false, blockGeneralCreate: 2, npcMode: 0, showImgLevel: 3, tournamentTrig: true, joinMode: 'full', autorunUser: { limitMinutes: 60, options: { develop: true, }, }, }, }); // Future-born entries are converted to delayed events, so the raw // scenario array length is not the installed general count. expect(seed.generals.length).toBeGreaterThan(0); expect(seed.generals.length).toBeLessThan( scenario.generals.length + scenario.generalsNeutral.length + scenario.generalsEx.length ); const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); try { const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; const worldState = await prisma.worldState.findFirst(); expect(worldState).not.toBeNull(); if (!worldState) { return; } expect(worldState.tickSeconds).toBe(3600); expect(worldState.currentMonth).toBe(1); const config = (worldState.config ?? {}) as Record; expect(config.extendedGeneral).toBe(false); expect(config.joinMode).toBe('full'); const meta = (worldState.meta ?? {}) as Record; const autorun = (meta.autorun_user ?? {}) as Record; const autorunOptions = (autorun.options ?? {}) as Record; expect(autorunOptions.develop).toBe(true); } finally { await connector.disconnect(); } }); test('persists a tracked scenario effect in the world configuration', async () => { await seedScenarioToDatabase({ scenarioId: 906, databaseUrl, }); const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); try { const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; const worldState = await prisma.worldState.findFirst(); const config = (worldState?.config ?? {}) as Record; const environment = (config.environment ?? {}) as Record; expect(environment.scenarioEffect).toBe('event_StrongAttacker'); } finally { await connector.disconnect(); } }); test('seeds the exact scenario 903 UnderS30 selection pool', async () => { await seedScenarioToDatabase({ scenarioId: 903, databaseUrl, }); const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); try { const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; expect(await prisma.selectPoolEntry.count()).toBe(1844); expect(await prisma.selectPoolEntry.findFirst({ orderBy: { id: 'asc' } })).toMatchObject({ uniqueName: '⑨탈곡기', info: { generalName: '⑨탈곡기', specialDomestic: 'che_event_징병', }, }); expect(await prisma.selectPoolEntry.findFirst({ orderBy: { id: 'desc' } })).toMatchObject({ uniqueName: '④야부키 나코', }); } finally { await connector.disconnect(); } }); test('clears general lifecycle rows before reusing general ids on reseed', async () => { await seedScenarioToDatabase({ scenarioId: 903, databaseUrl, }); const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); try { const prisma = connector.prisma; const generalId = 990_903; const createSelectedGeneral = async (): Promise => { await prisma.general.create({ data: { id: generalId, userId: 'scenario-reseed-user', name: '재설치선택장수', turnTime: new Date('2026-07-30T12:00:00.000Z'), }, }); }; const createLifecycleRows = async (): Promise => { await prisma.generalTurn.create({ data: { generalId, turnIdx: 0, actionCode: '휴식', }, }); await prisma.generalTurnRevision.create({ data: { generalId, revision: 7, }, }); await prisma.rankData.create({ data: { nationId: 0, generalId, type: 'experience', value: 123, }, }); await prisma.generalAccessLog.create({ data: { generalId, userId: 'scenario-reseed-user', }, }); }; const expectLifecycleRows = async (count: number): Promise => { await expect( Promise.all([ prisma.generalTurn.count({ where: { generalId } }), prisma.generalTurnRevision.count({ where: { generalId } }), prisma.rankData.count({ where: { generalId } }), prisma.generalAccessLog.count({ where: { generalId } }), ]) ).resolves.toEqual([count, count, count, count]); }; await createSelectedGeneral(); await createLifecycleRows(); await expectLifecycleRows(1); await seedScenarioToDatabase({ scenarioId: 903, databaseUrl, }); await expectLifecycleRows(0); await expect(prisma.general.findUnique({ where: { id: generalId } })).resolves.toBeNull(); await createSelectedGeneral(); await createLifecycleRows(); await expectLifecycleRows(1); await seedScenarioToDatabase({ scenarioId: 903, databaseUrl, }); await expectLifecycleRows(0); } finally { await connector.disconnect(); } }); test('persists a private hidden seed without copying it into game history', async () => { const envName = 'INTEGRATION_WORLD_SEED'; const originalSeed = process.env[envName]; const serverId = 'scenario-seeder-hidden-seed-test'; const connector = createGamePostgresConnector({ url: databaseUrl }); try { process.env[envName] = 'scenario-seeder-explicit-hidden-seed'; await seedScenarioToDatabase({ scenarioId: 903, databaseUrl, installOptions: { serverId }, }); await connector.connect(); const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; const explicitWorld = await prisma.worldState.findFirst(); expect(explicitWorld?.meta).toMatchObject({ hiddenSeed: 'scenario-seeder-explicit-hidden-seed', }); const explicitHistory = await prisma.gameHistory.findUnique({ where: { serverId } }); expect((explicitHistory?.env as { meta?: Record })?.meta).not.toHaveProperty( 'hiddenSeed' ); delete process.env[envName]; await seedScenarioToDatabase({ scenarioId: 903, databaseUrl, installOptions: { serverId }, }); const randomWorld = await prisma.worldState.findFirst(); const randomSeed = (randomWorld?.meta as Record)?.hiddenSeed; 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' ); await prisma.gameHistory.deleteMany({ where: { serverId } }); } finally { if (originalSeed === undefined) { delete process.env[envName]; } else { process.env[envName] = originalSeed; } await connector.disconnect(); } }); }); describe('tracked scenario composition', () => { test('loads the shared event and buyable unique extensions', async () => { const standard = await loadScenarioDefinitionById(1010); const expanded = await loadScenarioDefinitionById(2141); const mixedConst = await loadScenarioDefinitionById(2904); expect(standard.events).toHaveLength(1); expect(standard.initialEvents).toHaveLength(1); expect(expanded.events).toHaveLength(7); expect(expanded.initialEvents).toHaveLength(1); expect(expanded.config.const.availableSpecialWar).toHaveLength(20); expect(Object.keys(expanded.config.const.allItems as object)).toEqual(['horse', 'weapon', 'book', 'item']); expect(mixedConst.config.const.scenarioEffect).toBe('event_StrongAttacker'); expect(mixedConst.config.const.availableSpecialWar).toEqual(expanded.config.const.availableSpecialWar); expect(mixedConst.config.const.allItems).toEqual(expanded.config.const.allItems); }); });