fix: 등용장을 발송 국가 멸망 시 함께 만료

This commit is contained in:
2026-09-06 23:55:20 +00:00
parent 9d5447dd3f
commit 69f461612d
5 changed files with 395 additions and 100 deletions
@@ -4,6 +4,8 @@ import { SystemClock } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
import { buildScoutMessageDraft } from '@sammo-ts/logic/messages/scoutMessage.js';
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
@@ -17,9 +19,9 @@ import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const worldId = 991_731;
const generalId = 991_731;
const cityId = 991_731;
const existingNationId = 991_730;
const generalId = 731;
const cityId = 731;
const existingNationId = 730;
const requestId = 'integration:engine:immediate-action-uprising';
const occupiedUniqueItem = 'che_무기_12_칠성검';
@@ -179,7 +181,8 @@ integration('immediate general action persistence', () => {
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
},
});
await db.general.deleteMany({ where: { id: generalId } });
await db.message.deleteMany({ where: { mailbox: { in: [generalId, generalId + 1] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, generalId + 1] } } });
await db.city.deleteMany({ where: { id: cityId } });
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
await db.worldState.deleteMany({ where: { id: worldId } });
@@ -288,13 +291,193 @@ integration('immediate general action persistence', () => {
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
},
});
await db.general.deleteMany({ where: { id: generalId } });
await db.message.deleteMany({ where: { mailbox: { in: [generalId, generalId + 1] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, generalId + 1] } } });
await db.city.deleteMany({ where: { id: cityId } });
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
await db.worldState.deleteMany({ where: { id: worldId } });
await disconnect?.();
});
it.each([
'normal',
'resigned',
'transferred',
'deleted',
'ruler',
'collapsed',
'legacyCollapsed',
'random',
] as const)('persists the recruitment-letter lifecycle: %s', async (mode) => {
const recruiterId = generalId + 1;
await db.general.create({
data: {
id: recruiterId,
name: '권유자',
meta: { killturn: 24 },
nationId: existingNationId,
officerLevel: 1,
cityId,
turnTime: general.turnTime,
},
});
await db.nation.update({
where: { id: existingNationId },
data: { capitalCityId: cityId, meta: { gennum: 1 } },
});
await db.city.update({ where: { id: cityId }, data: { nationId: existingNationId } });
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const handler = createTurnDaemonCommandHandler({ world, scenarioMeta, map });
const flush = () =>
hooks.hooks.flushChanges!({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
try {
const draft = buildScoutMessageDraft({
srcGeneral: world.getGeneralById(recruiterId)!,
destGeneral: world.getGeneralById(generalId)!,
srcNation: world.getNationById(existingNationId),
destNation: null,
time: world.gameTickToDate(0),
});
expect(draft).not.toBeNull();
world.queueMessage(draft!);
await flush();
const letter = await db.message.findFirstOrThrow({
where: { mailbox: generalId },
include: { action: true },
});
expect(letter.action?.status).toBe('PENDING');
const envelopeWallTime = letter.createdAtWall;
if (mode === 'collapsed') {
world.queueMessage({
...draft!,
src: { ...draft!.src, nationId: existingNationId + 10 },
text: '다른 국가의 등용장',
});
world.queueMessage({ ...draft!, option: {}, text: '일반 서신' });
await flush();
}
while (hooks.takeCommittedReadModelChangeReceipt()) {
/* discard setup receipts */
}
if (mode === 'resigned' || mode === 'transferred') {
world.updateGeneral(recruiterId, { nationId: mode === 'resigned' ? 0 : existingNationId + 10 });
} else if (mode === 'deleted') {
world.removeGeneral(recruiterId);
} else if (mode === 'ruler') {
world.updateGeneral(generalId, { nationId: existingNationId + 10, officerLevel: 12 });
} else if (mode === 'random') {
world.updateWorldConfig({ joinMode: 'onlyRandom' });
} else if (mode === 'collapsed' || mode === 'legacyCollapsed') {
world.removeNation(existingNationId);
}
if (mode === 'collapsed') {
// Force failure after nation deletion: the envelope/action and nation must roll back together.
await db.$executeRawUnsafe(
"ALTER TABLE message_action ADD CONSTRAINT scout_test_pending CHECK (status = 'PENDING')"
);
await expect(flush()).rejects.toThrow();
expect(await db.nation.findUnique({ where: { id: existingNationId } })).not.toBeNull();
expect((await db.messageAction.findUniqueOrThrow({ where: { messageId: letter.id } })).status).toBe(
'PENDING'
);
await db.$executeRawUnsafe('ALTER TABLE message_action DROP CONSTRAINT scout_test_pending');
}
await flush();
if (mode === 'legacyCollapsed') {
// Simulate an old deployment which deleted the nation but left this action pending.
await db.messageAction.update({
where: { messageId: letter.id },
data: { status: 'PENDING', resolvedGameTick: null },
});
}
const currentLetter = await db.message.findUniqueOrThrow({
where: { id: letter.id },
include: { action: true },
});
expect(currentLetter.createdAtWall).toEqual(envelopeWallTime);
expect(currentLetter.action?.status).toBe(mode === 'collapsed' ? 'RESOLVED' : 'PENDING');
if (mode === 'collapsed') {
expect(currentLetter.validUntilTick).not.toBeNull();
const receipt = hooks.takeCommittedReadModelChangeReceipt();
expect(receipt?.invalidation.revisions).toContainEqual(
expect.objectContaining({
domain: 'messages.mailbox',
entityId: generalId,
})
);
const controls = await db.message.findMany({
where: { mailbox: generalId, id: { not: letter.id } },
include: { action: true },
orderBy: { id: 'asc' },
});
expect(controls.map((entry) => entry.action?.status ?? null)).toEqual(['PENDING', null]);
expect(controls.every((entry) => entry.validUntil.getUTCFullYear() === 9999)).toBe(true);
}
const actionRequestId = `${requestId}:scout:${mode}`;
const payload = {
type: 'messageRespond' as const,
requestId: actionRequestId,
userId: general.userId!,
generalId,
messageId: letter.id,
response: true,
};
await db.inputEvent.create({
data: {
requestId: actionRequestId,
target: 'ENGINE',
eventType: 'messageRespond',
actorUserId: general.userId,
payload,
},
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await queue.initialize();
const commands = await queue.drain();
expect(commands).toHaveLength(1);
const result = await hooks.hooks.executeCommand!(actionRequestId, async (ctx) => {
const value = await handler.handle(commands[0]!, ctx);
if (!value) throw new Error('missing message response');
return value;
});
if (mode === 'legacyCollapsed') {
expect(hooks.takeCommittedReadModelChangeReceipt()?.invalidation.revisions).toContainEqual(
expect.objectContaining({ domain: 'messages.mailbox', entityId: generalId })
);
}
const accepted = ['normal', 'resigned', 'transferred', 'deleted'].includes(mode);
expect(result).toMatchObject(
accepted ? { ok: true, reason: 'success' } : { reason: expect.not.stringMatching(/^success$/) }
);
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find(({ id }) => id === generalId)).toMatchObject({
nationId: accepted ? existingNationId : mode === 'ruler' ? existingNationId + 10 : 0,
officerLevel: accepted ? 1 : mode === 'ruler' ? 12 : 0,
});
expect((await db.messageAction.findUniqueOrThrow({ where: { messageId: letter.id } })).status).toBe(
mode === 'ruler' || mode === 'random' ? 'PENDING' : 'RESOLVED'
);
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId: actionRequestId } })).status).toBe(
'SUCCEEDED'
);
expect(await queue.drain()).toEqual([]);
} finally {
await db.$executeRawUnsafe('ALTER TABLE message_action DROP CONSTRAINT IF EXISTS scout_test_pending');
await hooks.close();
}
});
it('commits pre-opening uprising with rollback/retry while scheduled turns remain stopped', async () => {
const snapshot: TurnWorldSnapshot = {
generals: [general],