359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { SystemClock } from '@sammo-ts/common';
|
|
import {
|
|
createDatabaseTurnHooks,
|
|
DatabaseTurnDaemonCommandQueue,
|
|
EngineStateManager,
|
|
InMemoryTurnStateStore,
|
|
InMemoryTurnWorld,
|
|
loadTurnWorldFromDatabase,
|
|
TurnDaemonLifecycle,
|
|
type TurnGeneral,
|
|
type TurnWorldSnapshot,
|
|
type TurnWorldState,
|
|
} from '@sammo-ts/game-engine';
|
|
import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js';
|
|
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
|
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
|
|
|
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
|
import { AccountIconResetReconciler } from '../src/services/accountIconResetReconciler.js';
|
|
|
|
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
|
const integration = describe.skipIf(!databaseUrl);
|
|
const generalId = 991_744;
|
|
const userId = 'account-icon-reset-reconcile-user';
|
|
const revision = '2026-07-31T10:00:00.001Z';
|
|
const requestId = `general:adjustIcon:${userId}:${revision}`;
|
|
const retryRequestId = `${requestId}:retry:1`;
|
|
const lifecycleGeneralId = 991_745;
|
|
const lifecycleWorldId = 991_745;
|
|
const lifecycleUserId = 'account-icon-reset-lifecycle-user';
|
|
const lifecycleRevision = '2026-07-31T10:20:00.001Z';
|
|
const lifecycleRequestId = `general:adjustIcon:${lifecycleUserId}:${lifecycleRevision}`;
|
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
|
|
|
const scenarioConfig: ScenarioConfig = {
|
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
|
iconPath: '',
|
|
map: {},
|
|
const: {},
|
|
environment: { mapName: 'che', unitSet: 'che' },
|
|
};
|
|
const scenarioMeta: ScenarioMeta = {
|
|
title: '계정 아이콘 reset lifecycle 통합',
|
|
startYear: 190,
|
|
life: null,
|
|
fiction: null,
|
|
history: [],
|
|
ignoreDefaultEvents: false,
|
|
};
|
|
const map: MapDefinition = { id: 'account-icon-reset-lifecycle', name: scenarioMeta.title, cities: [] };
|
|
const lifecycleState: TurnWorldState = {
|
|
id: lifecycleWorldId,
|
|
currentYear: 190,
|
|
currentMonth: 1,
|
|
tickSeconds: 600,
|
|
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
|
|
meta: { killturn: 24, scenarioMeta },
|
|
};
|
|
const lifecycleGeneral: TurnGeneral = {
|
|
id: lifecycleGeneralId,
|
|
userId: lifecycleUserId,
|
|
name: '초기화lifecycle장수',
|
|
nationId: 0,
|
|
cityId: 0,
|
|
troopId: 0,
|
|
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
|
turnTime: new Date('2026-07-31T10:30:00.000Z'),
|
|
recentWarTime: null,
|
|
role: {
|
|
items: { horse: null, weapon: null, book: null, item: null },
|
|
personality: null,
|
|
specialDomestic: null,
|
|
specialWar: null,
|
|
},
|
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
|
meta: { killturn: 24, accountIconUpdatedAt: '2026-07-31T10:10:00.000Z', preserved: 'yes' },
|
|
penalty: {},
|
|
officerLevel: 0,
|
|
experience: 0,
|
|
dedication: 0,
|
|
injury: 0,
|
|
gold: 1_000,
|
|
rice: 1_000,
|
|
crew: 0,
|
|
crewTypeId: 0,
|
|
train: 0,
|
|
atmos: 0,
|
|
age: 20,
|
|
npcState: 0,
|
|
picture: 'before-lifecycle-reset.png',
|
|
imageServer: 1,
|
|
};
|
|
|
|
const assertDedicatedDatabase = (rawUrl: string): void => {
|
|
const schema = new URL(rawUrl).searchParams.get('schema');
|
|
if (!schema?.endsWith('immediate_action_integration')) {
|
|
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
|
|
}
|
|
};
|
|
|
|
integration('account icon reset reconciliation PostgreSQL queue', () => {
|
|
let db: GamePrismaClient;
|
|
let disconnect: (() => Promise<void>) | undefined;
|
|
|
|
beforeAll(async () => {
|
|
assertDedicatedDatabase(databaseUrl!);
|
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
|
await connector.connect();
|
|
db = connector.prisma;
|
|
disconnect = () => connector.disconnect();
|
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
|
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: lifecycleRequestId } } });
|
|
await db.general.deleteMany({ where: { id: { in: [generalId, lifecycleGeneralId] } } });
|
|
await db.worldState.deleteMany({ where: { id: lifecycleWorldId } });
|
|
await db.worldState.create({
|
|
data: {
|
|
id: lifecycleWorldId,
|
|
scenarioCode: 'account-icon-reset-lifecycle',
|
|
currentYear: lifecycleState.currentYear,
|
|
currentMonth: lifecycleState.currentMonth,
|
|
tickSeconds: lifecycleState.tickSeconds,
|
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
|
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
|
|
},
|
|
});
|
|
await db.general.create({
|
|
data: {
|
|
id: generalId,
|
|
userId,
|
|
name: '초기화복구장수',
|
|
turnTime: new Date('2026-07-31T10:10:00.000Z'),
|
|
picture: 'before-reset.png',
|
|
imageServer: 1,
|
|
meta: { killturn: 24, accountIconUpdatedAt: '2026-07-31T09:00:00.000Z' },
|
|
},
|
|
});
|
|
await db.general.create({
|
|
data: {
|
|
id: lifecycleGeneral.id,
|
|
userId: lifecycleGeneral.userId,
|
|
name: lifecycleGeneral.name,
|
|
turnTime: lifecycleGeneral.turnTime,
|
|
picture: lifecycleGeneral.picture,
|
|
imageServer: lifecycleGeneral.imageServer,
|
|
meta: lifecycleGeneral.meta,
|
|
},
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (db) {
|
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
|
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: lifecycleRequestId } } });
|
|
await db.general.deleteMany({ where: { id: { in: [generalId, lifecycleGeneralId] } } });
|
|
await db.worldState.deleteMany({ where: { id: lifecycleWorldId } });
|
|
}
|
|
await disconnect?.();
|
|
});
|
|
|
|
it('deduplicates an active durable enqueue and creates one bounded retry after terminal failure', async () => {
|
|
const source = {
|
|
listResets: vi.fn(async () => [
|
|
{
|
|
userId,
|
|
resetRevision: revision,
|
|
current: {
|
|
revision,
|
|
picture: 'default.jpg',
|
|
imageServer: 0,
|
|
},
|
|
},
|
|
]),
|
|
};
|
|
const reconciler = new AccountIconResetReconciler(
|
|
db,
|
|
source,
|
|
new DatabaseTurnDaemonTransport(db, 1_000),
|
|
30_000
|
|
);
|
|
|
|
await reconciler.reconcileOnce();
|
|
await reconciler.reconcileOnce();
|
|
|
|
await expect(db.inputEvent.findMany({ where: { requestId } })).resolves.toEqual([
|
|
expect.objectContaining({
|
|
requestId,
|
|
target: 'ENGINE',
|
|
eventType: 'adjustGeneralIcon',
|
|
status: 'PENDING',
|
|
actorUserId: userId,
|
|
payload: {
|
|
type: 'adjustGeneralIcon',
|
|
requestId,
|
|
userId,
|
|
picture: 'default.jpg',
|
|
imageServer: 0,
|
|
iconRevision: revision,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
await db.inputEvent.update({
|
|
where: { requestId },
|
|
data: {
|
|
status: 'FAILED',
|
|
attempts: 3,
|
|
error: 'simulated terminal failure',
|
|
completedAt: new Date(),
|
|
},
|
|
});
|
|
await reconciler.reconcileOnce();
|
|
await reconciler.reconcileOnce();
|
|
|
|
await expect(
|
|
db.inputEvent.findMany({
|
|
where: { requestId: { startsWith: requestId } },
|
|
orderBy: { sequence: 'asc' },
|
|
})
|
|
).resolves.toEqual([
|
|
expect.objectContaining({ requestId, status: 'FAILED' }),
|
|
expect.objectContaining({
|
|
requestId: retryRequestId,
|
|
status: 'PENDING',
|
|
actorUserId: userId,
|
|
payload: {
|
|
type: 'adjustGeneralIcon',
|
|
requestId: retryRequestId,
|
|
userId,
|
|
picture: 'default.jpg',
|
|
imageServer: 0,
|
|
iconRevision: revision,
|
|
},
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('flows from reconciliation through the durable daemon lifecycle and persists one reset', async () => {
|
|
const source = {
|
|
listResets: vi.fn(async () => [
|
|
{
|
|
userId: lifecycleUserId,
|
|
resetRevision: lifecycleRevision,
|
|
current: {
|
|
revision: lifecycleRevision,
|
|
picture: 'default.jpg',
|
|
imageServer: 0,
|
|
},
|
|
},
|
|
]),
|
|
};
|
|
const reconciler = new AccountIconResetReconciler(
|
|
db,
|
|
source,
|
|
new DatabaseTurnDaemonTransport(db, 1_000),
|
|
30_000
|
|
);
|
|
await reconciler.reconcileOnce();
|
|
|
|
const snapshot: TurnWorldSnapshot = {
|
|
generals: [lifecycleGeneral],
|
|
cities: [],
|
|
nations: [],
|
|
troops: [],
|
|
diplomacy: [],
|
|
events: [],
|
|
initialEvents: [],
|
|
scenarioConfig,
|
|
scenarioMeta,
|
|
map,
|
|
};
|
|
const world = new InMemoryTurnWorld(lifecycleState, snapshot, { schedule });
|
|
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
|
await queue.initialize();
|
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
|
const stateManager = new EngineStateManager();
|
|
stateManager.register('world', {
|
|
capture: () => world.captureState(),
|
|
restore: (captured) => world.restoreState(captured),
|
|
});
|
|
const lifecycle = new TurnDaemonLifecycle(
|
|
{
|
|
clock: new SystemClock(),
|
|
controlQueue: queue,
|
|
commandResponder: queue,
|
|
getNextTickTime: () => new Date(Date.now() + 3_600_000),
|
|
stateStore: new InMemoryTurnStateStore(world),
|
|
processor: {
|
|
run: async () => {
|
|
throw new Error('scheduled turn must not run in account icon lifecycle integration');
|
|
},
|
|
},
|
|
commandHandler: createTurnDaemonCommandHandler({ world }),
|
|
hooks: hooks.hooks,
|
|
stateManager,
|
|
},
|
|
{
|
|
profile: 'account-icon-reset-lifecycle',
|
|
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
|
|
}
|
|
);
|
|
const waitForSuccess = async () => {
|
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
const event = await db.inputEvent.findUnique({ where: { requestId: lifecycleRequestId } });
|
|
if (event?.status === 'SUCCEEDED' && event.lockedBy === null) return event;
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
const observed = await db.inputEvent.findUnique({ where: { requestId: lifecycleRequestId } });
|
|
throw new Error(
|
|
`Timed out waiting for ${lifecycleRequestId} to succeed: ${JSON.stringify(
|
|
observed && {
|
|
status: observed.status,
|
|
attempts: observed.attempts,
|
|
error: observed.error,
|
|
result: observed.result,
|
|
lockedBy: observed.lockedBy,
|
|
}
|
|
)}`
|
|
);
|
|
};
|
|
|
|
let loop: Promise<void> | undefined;
|
|
try {
|
|
loop = lifecycle.start();
|
|
await expect(waitForSuccess()).resolves.toMatchObject({
|
|
attempts: 1,
|
|
result: {
|
|
type: 'adjustGeneralIcon',
|
|
ok: true,
|
|
generalId: lifecycleGeneralId,
|
|
updated: true,
|
|
},
|
|
});
|
|
} finally {
|
|
await lifecycle.stop('account icon reset integration finished');
|
|
await loop;
|
|
await hooks.close();
|
|
}
|
|
|
|
await expect(db.general.findUniqueOrThrow({ where: { id: lifecycleGeneralId } })).resolves.toMatchObject({
|
|
picture: 'default.jpg',
|
|
imageServer: 0,
|
|
meta: { accountIconUpdatedAt: lifecycleRevision, preserved: 'yes' },
|
|
});
|
|
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === lifecycleGeneralId)).toMatchObject({
|
|
picture: 'default.jpg',
|
|
imageServer: 0,
|
|
meta: { accountIconUpdatedAt: lifecycleRevision, preserved: 'yes' },
|
|
});
|
|
|
|
await reconciler.reconcileOnce();
|
|
await expect(db.inputEvent.count({ where: { requestId: { startsWith: lifecycleRequestId } } })).resolves.toBe(
|
|
1
|
|
);
|
|
}, 15_000);
|
|
});
|