feat: port scenario 903 select-pool flow

This commit is contained in:
2026-07-30 23:27:59 +00:00
parent 9bd057456b
commit 115218ded8
80 changed files with 6859 additions and 48 deletions
@@ -93,4 +93,48 @@ integration('database command queue', () => {
lockedBy: 'active-worker',
});
});
it('retries an owned command twice and then records a terminal failure', async () => {
const requestId = 'integration:engine:bounded-failure';
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'vacation',
payload: {
type: 'vacation',
requestId,
generalId: 10,
} as GamePrisma.InputJsonValue,
},
});
const owner = new DatabaseTurnDaemonCommandQueue(db);
const stale = new DatabaseTurnDaemonCommandQueue(db);
for (const attempt of [1, 2, 3]) {
await expect(owner.drain()).resolves.toEqual([
{ type: 'vacation', requestId, generalId: 10 },
]);
await stale.publishCommandError(requestId, new Error('stale worker failure'));
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
).resolves.toMatchObject({
status: 'PROCESSING',
attempts: attempt,
});
await owner.publishCommandError(requestId, new Error('injected command failure'));
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
).resolves.toMatchObject({
status: attempt < 3 ? 'PENDING' : 'FAILED',
attempts: attempt,
error: 'injected command failure',
lockedBy: null,
leaseUntil: null,
});
}
await expect(owner.drain()).resolves.toEqual([]);
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { loadGeneralPoolEntries } from '../src/scenario/generalPoolLoader.js';
describe('SPoolUnderU30 resource', () => {
it('preserves the Ref UnderS30 row contract and ordering', async () => {
const entries = await loadGeneralPoolEntries('SPoolUnderU30');
const weights = entries.map((entry) => {
const dex = entry.info.dex as number[];
return dex.reduce((sum, value) => sum + value, 0);
});
expect(entries).toHaveLength(1844);
expect(new Set(entries.map((entry) => entry.uniqueName)).size).toBe(1844);
expect(Math.min(...weights)).toBe(100122);
expect(Math.max(...weights)).toBe(2582699);
const traitFrequencies = entries.reduce<Record<string, number>>((result, entry) => {
const key = String(entry.info.specialDomestic);
result[key] = (result[key] ?? 0) + 1;
return result;
}, {});
expect(traitFrequencies).toEqual({
che_event_격노: 152,
che_event_견고: 91,
che_event_공성: 8,
che_event_궁병: 12,
che_event_귀병: 38,
che_event_기병: 12,
che_event_돌격: 98,
che_event_무쌍: 100,
che_event_반계: 81,
che_event_보병: 10,
che_event_신산: 99,
che_event_신중: 106,
che_event_위압: 85,
che_event_의술: 37,
che_event_저격: 251,
che_event_집중: 125,
che_event_징병: 169,
che_event_척사: 166,
che_event_필살: 156,
che_event_환술: 48,
});
expect(entries[0]).toEqual({
uniqueName: '⑨탈곡기',
info: {
generalName: '⑨탈곡기',
leadership: 69,
strength: 12,
intel: 80,
specialDomestic: 'che_event_징병',
dex: [12066, 27302, 29463, 307356, 16448],
imgsvr: 1,
picture: '9ed8be6.gif?=20190417',
uniqueName: '⑨탈곡기',
},
});
expect(entries.at(-1)?.uniqueName).toBe('④야부키 나코');
});
it('rejects an unsupported pool instead of silently substituting data', async () => {
await expect(loadGeneralPoolEntries('SPoolUnknown')).rejects.toThrow('Unsupported general pool');
});
});
@@ -254,6 +254,7 @@ describe('input event atomicity', () => {
resolveError = resolve;
});
const publishCommandResult = vi.fn(async () => {});
const publishCommandError = vi.fn(async () => {});
let engineState = { value: 'before' };
const stateManager = new EngineStateManager();
stateManager.register('test', {
@@ -287,6 +288,7 @@ describe('input event atomicity', () => {
commandResponder: {
publishStatus: async () => {},
publishCommandResult,
publishCommandError,
},
},
{
@@ -305,6 +307,10 @@ describe('input event atomicity', () => {
lastError: 'injected commit failure',
});
expect(publishCommandResult).not.toHaveBeenCalled();
expect(publishCommandError).toHaveBeenCalledWith(
'event-2',
expect.objectContaining({ message: 'injected commit failure' })
);
expect(engineState).toEqual({ value: 'before' });
expect(stateManager.getRevision()).toBe(0);
@@ -320,6 +326,7 @@ describe('input event atomicity', () => {
});
const commitCommand = vi.fn(async () => {});
const publishCommandResult = vi.fn(async () => {});
const publishCommandError = vi.fn(async () => {});
let engineState = { value: 'before' };
const stateManager = new EngineStateManager();
stateManager.register('test', {
@@ -351,6 +358,7 @@ describe('input event atomicity', () => {
commandResponder: {
publishStatus: async () => {},
publishCommandResult,
publishCommandError,
},
},
{
@@ -370,6 +378,10 @@ describe('input event atomicity', () => {
});
expect(commitCommand).not.toHaveBeenCalled();
expect(publishCommandResult).not.toHaveBeenCalled();
expect(publishCommandError).toHaveBeenCalledWith(
'event-3',
expect.objectContaining({ message: 'injected handler failure' })
);
expect(engineState).toEqual({ value: 'before' });
expect(stateManager.getRevision()).toBe(0);
+171
View File
@@ -30,6 +30,12 @@ type ScenarioSeederPrismaClient = {
count(): Promise<number>;
findFirst(): Promise<{ age: number; startAge: number; meta: unknown } | null>;
};
selectPoolEntry: {
count(): Promise<number>;
findFirst(args: {
orderBy: { id: 'asc' | 'desc' };
}): Promise<{ uniqueName: string; info: unknown } | null>;
};
diplomacy: {
count(): Promise<number>;
findFirst(args: {
@@ -48,6 +54,10 @@ type ScenarioSeederPrismaClient = {
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'];
@@ -254,6 +264,167 @@ describeDb('scenario database seed', () => {
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<void> => {
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<void> => {
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<void> => {
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<string, unknown> })?.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<string, unknown>)?.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<string, unknown> })?.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', () => {
@@ -0,0 +1,274 @@
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();
}
});
});
+4 -1
View File
@@ -135,7 +135,7 @@ describe('InMemoryTurnProcessor ordering', () => {
currentMonth: 1,
tickSeconds: 3600,
lastTurnTime: baseTime,
meta: {},
meta: { lastGeneralId: 1 },
};
const world = new InMemoryTurnWorld(state, snapshot, {
@@ -157,5 +157,8 @@ describe('InMemoryTurnProcessor ordering', () => {
});
expect(executed).toEqual([2, 3, 1]);
expect(world.getNextGeneralId()).toBe(4);
expect(world.getNextGeneralId()).toBe(5);
expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 });
});
});