Files
core2026/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts
T

275 lines
9.9 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
createGamePostgresConnector,
type GamePrisma,
type GamePrismaClient,
} from '@sammo-ts/infra';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.SELECT_POOL_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalId = 990_904;
const cityId = 990_904;
const scenarioCode = 'select-pool-release-integration';
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
if (!schema?.endsWith('select_pool_integration')) {
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
}
};
integration('select pool release during general deletion', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
beforeAll(async () => {
assertDedicatedDatabase(databaseUrl!);
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"');
await db.selectPoolEntry.deleteMany({
where: { uniqueName: 'release-candidate' },
});
await db.general.deleteMany({ where: { id: generalId } });
await db.city.deleteMany({ where: { id: cityId } });
await db.worldState.deleteMany({ where: { scenarioCode } });
await db.worldState.create({
data: {
scenarioCode,
currentYear: 180,
currentMonth: 1,
tickSeconds: 300,
config: {
npcMode: 2,
turnTermMinutes: 5,
stat: {
total: 165,
min: 15,
max: 80,
npcTotal: 165,
npcMax: 80,
npcMin: 15,
chiefMin: 40,
},
iconPath: '.',
map: {
targetGeneralPool: 'SPoolUnderU30',
generalPoolAllowOption: ['ego'],
},
const: {},
environment: {
mapName: 'che',
unitSet: 'che',
},
},
meta: {
hiddenSeed: 'select-pool-release-seed',
killturn: 5,
turntime: '2026-07-30T12:00:00.000Z',
},
},
});
await db.city.create({
data: {
id: cityId,
name: '해제성',
level: 5,
nationId: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
region: 1,
},
});
await db.general.create({
data: {
id: generalId,
userId: 'select-pool-release-user',
name: '해제대상',
nationId: 0,
cityId,
troopId: 0,
npcState: 0,
affinity: 1,
bornYear: 160,
deadYear: 240,
picture: 'default.jpg',
imageServer: 0,
leadership: 50,
strength: 50,
intel: 50,
experience: 2_000,
dedication: 2_000,
officerLevel: 0,
turnTime: new Date('2026-07-30T12:01:00.000Z'),
age: 20,
startAge: 20,
personalCode: 'che_안전',
specialCode: 'che_event_신산',
special2Code: 'che_무쌍',
meta: {
killturn: 5,
dex1: 100_000,
dex2: 100_000,
dex3: 100_000,
dex4: 100_000,
dex5: 100_000,
},
},
});
await db.selectPoolEntry.create({
data: {
uniqueName: 'release-candidate',
ownerUserId: null,
generalId,
reservedUntil: null,
info: {
uniqueName: 'release-candidate',
generalName: '해제대상',
leadership: 50,
strength: 50,
intel: 50,
specialDomestic: 'che_event_신산',
dex: [100_000, 100_000, 100_000, 100_000, 100_000],
imgsvr: 0,
picture: 'default.jpg',
} as GamePrisma.InputJsonValue,
},
});
});
afterAll(async () => {
if (!db) {
await closeDb?.();
return;
}
await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"');
await db.selectPoolEntry.deleteMany({
where: { uniqueName: 'release-candidate' },
});
await db.general.deleteMany({ where: { id: generalId } });
await db.city.deleteMany({ where: { id: cityId } });
await db.worldState.deleteMany({ where: { scenarioCode } });
await closeDb?.();
});
it('round-trips independent event-domestic and war trait slots through a dirty flush', async () => {
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
});
const before = world.getGeneralById(generalId);
expect(before?.role).toMatchObject({
specialDomestic: 'che_event_신산',
specialWar: 'che_무쌍',
});
expect(world.updateGeneral(generalId, { gold: (before?.gold ?? 0) + 1 })).not.toBeNull();
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
await hooks.hooks.flushChanges?.({
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
} finally {
await hooks.close();
}
await expect(db.general.findUniqueOrThrow({ where: { id: generalId } })).resolves.toMatchObject({
specialCode: 'che_event_신산',
special2Code: 'che_무쌍',
});
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(
reloaded.snapshot.generals.find((general) => general.id === generalId)?.role
).toMatchObject({
specialDomestic: 'che_event_신산',
specialWar: 'che_무쌍',
});
});
it('rolls back a failed flush, then releases all Ref fields before deleting the general', async () => {
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
});
expect(world.removeGeneral(generalId)).toBe(true);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
await db.$executeRawUnsafe(`
CREATE TABLE "select_pool_delete_blocker" (
"general_id" INTEGER PRIMARY KEY
REFERENCES "general"("id") ON DELETE RESTRICT
)
`);
await db.$executeRawUnsafe(
`INSERT INTO "select_pool_delete_blocker" ("general_id") VALUES (${generalId})`
);
await expect(
hooks.hooks.flushChanges?.({
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
})
).rejects.toThrow();
await expect(db.general.findUnique({ where: { id: generalId } })).resolves.not.toBeNull();
await expect(
db.selectPoolEntry.findUniqueOrThrow({
where: { uniqueName: 'release-candidate' },
})
).resolves.toMatchObject({
generalId,
ownerUserId: null,
reservedUntil: null,
});
await db.$executeRawUnsafe('DROP TABLE "select_pool_delete_blocker"');
await hooks.hooks.flushChanges?.({
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
await expect(db.general.findUnique({ where: { id: generalId } })).resolves.toBeNull();
await expect(
db.selectPoolEntry.findUniqueOrThrow({
where: { uniqueName: 'release-candidate' },
})
).resolves.toMatchObject({
generalId: null,
ownerUserId: null,
reservedUntil: null,
});
} finally {
await hooks.close();
}
});
});