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
@@ -52,7 +52,21 @@ const buildWorld = (): InMemoryTurnWorld => {
const snapshot: TurnWorldSnapshot = {
generals: [actor],
cities: [],
nations: [],
nations: [
{
id: 2,
name: '촉',
color: '#000000',
level: 1,
capitalCityId: 2,
chiefGeneralId: 8,
gold: 0,
rice: 0,
power: 0,
typeCode: 'che_중립',
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
@@ -211,6 +225,37 @@ describe('actionable message response', () => {
expect(world.peekDirtyState().messages).toHaveLength(0);
});
it.each([true, false])(
'invalidates a surviving letter from a collapsed nation on response=%s',
async (response) => {
const world = buildWorld();
world.removeNation(source.nationId);
const { db, actionUpdateMany, updateMany } = buildDb([[buildRow('scout')]]);
const executor = buildExecutor();
const result = await respondToActionableMessage({
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: 29,
response,
});
expect(result).toEqual({ ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' });
expect(executor.execute).not.toHaveBeenCalled();
expect(actionUpdateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { messageId: { in: [29] }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick: expect.any(BigInt) },
})
);
expect(updateMany).toHaveBeenCalledOnce();
expect(world.getGeneralById(actor.id)?.nationId).toBe(actor.nationId);
expect(world.peekDirtyState().messages).toHaveLength(0);
}
);
it('treats a legacy truthy used value as an invalid scout letter', async () => {
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
const world = buildWorld();
@@ -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],
@@ -600,102 +600,126 @@ describe('my information world commands', () => {
expect(nextIntInclusive).not.toHaveBeenCalled();
});
it('loads the internal recruitment acceptance action outside the selectable command profile', async () => {
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
const recipient = buildGeneral({
id: 8,
userId: 'user-8',
name: '재야장수',
nationId: 0,
cityId: 1,
officerLevel: 0,
lastTurn: originalLastTurn,
});
const recruiter = buildGeneral({
id: 9,
userId: 'user-9',
name: '등용장수',
nationId: 2,
cityId: 2,
});
const map = {
id: 'test',
name: 'test',
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
};
const fixture = buildImmediateActionWorld({
general: recipient,
additionalGenerals: [recruiter],
cities: [
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
] as TurnWorldSnapshot['cities'],
nations: [
{
id: 2,
name: '등용국',
color: '#222222',
typeCode: 'che_중립',
level: 1,
capitalCityId: 2,
chiefGeneralId: recruiter.id,
gold: 0,
rice: 0,
power: 0,
meta: { gennum: 1 },
it.each([
{ recruiterNationId: 2, ruler: false },
{ recruiterNationId: 0, ruler: false },
{ recruiterNationId: 3, ruler: false },
{ recruiterNationId: 2, ruler: true },
])(
'checks original-nation acceptance with $recruiterNationId / ruler=$ruler',
async ({ recruiterNationId, ruler }) => {
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
const recipient = buildGeneral({
id: 8,
userId: 'user-8',
name: '재야장수',
nationId: 0,
cityId: 1,
officerLevel: 0,
lastTurn: originalLastTurn,
});
const recruiter = buildGeneral({
id: 9,
userId: 'user-9',
name: '등용장수',
nationId: 2,
cityId: 2,
});
const map = {
id: 'test',
name: 'test',
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
};
const fixture = buildImmediateActionWorld({
general: recipient,
additionalGenerals: [recruiter],
cities: [
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
] as TurnWorldSnapshot['cities'],
nations: [
{
id: 2,
name: '등용국',
color: '#222222',
typeCode: 'che_중립',
level: 1,
capitalCityId: 2,
chiefGeneralId: recruiter.id,
gold: 0,
rice: 0,
power: 0,
meta: { gennum: 1 },
},
] as TurnWorldSnapshot['nations'],
map,
});
const executor = await createImmediateGeneralActionExecutor({
world: fixture.world,
reservedTurns: fixture.reservedTurns,
scenarioMeta: fixture.scenarioMeta,
map,
commandProfile: {
general: ['che_등용'],
nation: [],
},
] as TurnWorldSnapshot['nations'],
map,
});
const executor = await createImmediateGeneralActionExecutor({
world: fixture.world,
reservedTurns: fixture.reservedTurns,
scenarioMeta: fixture.scenarioMeta,
map,
commandProfile: {
general: ['che_등용'],
nation: [],
},
});
});
await expect(
executor.execute({
actionKey: 'che_등용수락',
generalId: recipient.id,
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
args: { destNationId: 2, destGeneralId: recruiter.id },
})
).resolves.toEqual({ ok: true });
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
nationId: 2,
cityId: 2,
officerLevel: 1,
lastTurn: originalLastTurn,
});
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
experience: recruiter.experience + 100,
dedication: recruiter.dedication + 100,
});
const actionLogs = fixture.world
.consumeDirtyState()
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
expect(actionLogs.map((log) => log.text)).toEqual([
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('망명하여 수도로'),
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('등용에 성공했습니다.'),
]);
expect(actionLogs.map((log) => log.format)).toEqual([
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
]);
});
fixture.world.updateGeneral(recruiter.id, { nationId: recruiterNationId });
if (ruler) {
fixture.world.updateGeneral(recipient.id, { officerLevel: 12 });
const before = fixture.world.captureState();
await expect(
executor.execute({
actionKey: 'che_등용수락',
generalId: recipient.id,
rng: new RandUtil(new LiteHashDRBG('reject-ruler-letter')),
args: { destNationId: 2, destGeneralId: recruiter.id },
})
).resolves.toEqual({ ok: false, reason: '군주는 등용장을 수락할 수 없습니다 등용수락 실패.' });
expect(fixture.world.captureState()).toEqual(before);
return;
}
await expect(
executor.execute({
actionKey: 'che_등용수락',
generalId: recipient.id,
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
args: { destNationId: 2, destGeneralId: recruiter.id },
})
).resolves.toEqual({ ok: true });
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
nationId: 2,
cityId: 2,
officerLevel: 1,
lastTurn: originalLastTurn,
});
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
experience: recruiter.experience + 100,
dedication: recruiter.dedication + 100,
});
const actionLogs = fixture.world
.consumeDirtyState()
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
expect(actionLogs.map((log) => log.text)).toEqual([
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('망명하여 수도로'),
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('등용에 성공했습니다.'),
]);
expect(actionLogs.map((log) => log.format)).toEqual([
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
]);
}
);
it('rejects recruitment-letter acceptance in a random-appointment-only world', async () => {
const recipient = buildGeneral({