fix: 후계자 없는 군주 사망 시 국가 멸망을 함께 처리

This commit is contained in:
2026-09-06 12:09:09 +00:00
parent d3443ec7f5
commit 4d64978cb6
5 changed files with 335 additions and 1 deletions
@@ -337,6 +337,62 @@ describe('legacy general turn lifecycle', () => {
expect(harness.world.peekDirtyState().deletedGenerals).toContain(1);
});
it('dissolves a dying ruler nation when only troop-leader NPCs remain', async () => {
const leader = makeGeneral({ officerLevel: 12, meta: { killturn: 1 } });
const troopLeader = makeGeneral({
id: 2,
userId: null,
npcState: 5,
officerLevel: 11,
troopId: 2,
turnTime: new Date(start.getTime() + 3_600_000),
meta: { killturn: 24, officer_city: 1, belong: 5, permission: 'ambassador' },
});
const snapshot = makeSnapshot([leader, troopLeader]);
snapshot.cities[0]!.conflict = { '1': 1 };
const harness = await createTurnTestHarness({ snapshot, state: makeState(), schedule, map });
await harness.runOneTick();
expect(harness.world.getGeneralById(1)).toBeNull();
expect(harness.world.getNationById(1)).toBeNull();
expect(harness.world.getCityById(1)).toMatchObject({ nationId: 0, frontState: 0, conflict: [] });
expect(harness.world.getGeneralById(2)).toMatchObject({
nationId: 0,
officerLevel: 0,
troopId: 0,
gold: troopLeader.gold,
rice: troopLeader.rice,
meta: { officer_city: 0, officerCity: 0, belong: 0, permission: 'normal' },
});
const dirty = harness.world.peekDirtyState();
expect(dirty.deletedNations).toContain(1);
expect(dirty.deletedNationSnapshots[0]?.generalIds).toEqual([1, 2]);
expect(dirty.logs.filter((log) => log.text.includes('【멸망】'))).toHaveLength(1);
});
it('repairs an already orphaned nation once but refuses a nation with a successor', async () => {
const troopLeader = makeGeneral({ id: 2, userId: null, npcState: 5, officerLevel: 11 });
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([troopLeader]),
state: makeState(),
schedule,
map,
});
expect(harness.world.dissolveNationWithoutSuccessor(1)).toBe(true);
expect(harness.world.dissolveNationWithoutSuccessor(1)).toBe(false);
expect(harness.world.getGeneralById(2)?.nationId).toBe(0);
const intact = await createTurnTestHarness({
snapshot: makeSnapshot([makeGeneral({ officerLevel: 9 })]),
state: makeState(),
schedule,
map,
});
expect(() => intact.world.dissolveNationWithoutSuccessor(1)).toThrow('still has a ruler or successor');
expect(intact.world.getCityById(1)?.nationId).toBe(1);
expect(intact.world.getNationById(1)).not.toBeNull();
});
it('deletes an expired NPC even when its in-memory lifespan metadata is missing', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
@@ -457,4 +457,157 @@ integration('monthly wandering nation persistence', () => {
await hooks.close();
}
});
it('atomically repairs a rulerless nation with only troop NPCs and preserves its archive', async () => {
await cleanup();
const nation = buildNation(nationIds[1]!, '방랑국', 3, 0);
const city = buildCity(cityIds[0]!, '방랑성', nation.id);
const general = buildGeneral({
id: generalIds[0]!,
name: '부대장',
nationId: nation.id,
cityId: city.id,
officerLevel: 11,
npcState: 5,
gold: 2345,
rice: 6789,
belong: 5,
turnTime: new Date('0195-01-01T00:05:00.000Z'),
});
await db.nation.create({
data: {
id: nation.id,
name: nation.name,
color: nation.color,
level: nation.level,
typeCode: nation.typeCode,
chiefGeneralId: 12345,
},
});
await db.city.create({
data: {
id: city.id,
name: city.name,
level: city.level,
nationId: nation.id,
region: 1,
population: 1000,
populationMax: 2000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
frontState: 1,
},
});
await db.general.create({
data: {
id: general.id,
name: general.name,
nationId: nation.id,
cityId: city.id,
officerLevel: 11,
npcState: 5,
gold: general.gold,
rice: general.rice,
turnTime: general.turnTime,
},
});
await db.nationTurn.create({
data: { nationId: nation.id, officerLevel: 12, turnIdx: 0, actionCode: '휴식', arg: {} },
});
const row = await db.worldState.create({
data: {
scenarioCode,
currentYear: 195,
currentMonth: 1,
tickSeconds: 600,
meta: { serverId },
},
});
const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
};
const world = new InMemoryTurnWorld(
{
id: row.id,
currentYear: 195,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0195-01-01T00:00:00.000Z'),
meta: { serverId },
},
{
scenarioConfig,
scenarioMeta: {
title: 'test',
startYear: 193,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map: { id: 'test', name: 'test', cities: [] },
nations: [nation],
cities: [city],
generals: [general],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
},
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
world.dissolveNationWithoutSuccessor(nation.id);
const result = {
lastTurnTime: world.getState().lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
};
// A stale clock fence must leave the entire repair uncommitted and retryable.
await db.worldState.update({ where: { id: row.id }, data: { clockRevision: 2 } });
await expect(hooks.hooks.flushChanges!(result)).rejects.toThrow('Game clock fence changed');
expect(await db.nation.count({ where: { id: nation.id } })).toBe(1);
expect((await db.general.findUniqueOrThrow({ where: { id: general.id } })).nationId).toBe(nation.id);
expect(await db.oldNation.count({ where: { serverId } })).toBe(0);
await db.worldState.update({ where: { id: row.id }, data: { clockRevision: 1 } });
await hooks.hooks.flushChanges!(result);
expect(await db.nation.findUnique({ where: { id: nation.id } })).toBeNull();
expect(await db.city.findUniqueOrThrow({ where: { id: city.id } })).toMatchObject({
nationId: 0,
frontState: 0,
});
expect(await db.general.findUniqueOrThrow({ where: { id: general.id } })).toMatchObject({
nationId: 0,
officerLevel: 0,
gold: 2345,
rice: 6789,
});
expect(await db.nationTurn.count({ where: { nationId: nation.id } })).toBe(0);
const archive = await db.oldNation.findUniqueOrThrow({
where: { serverId_nation_sourceId: { serverId, nation: nation.id, sourceId: 0 } },
});
expect(archive.data).toMatchObject({ nation: nation.id, generals: [general.id] });
const logCount = await db.logEntry.count({ where: { text: { contains: '방랑국' } } });
expect(logCount).toBe(3);
expect(world.dissolveNationWithoutSuccessor(nation.id)).toBe(false);
await hooks.hooks.flushChanges!(result);
expect(await db.logEntry.count({ where: { text: { contains: '방랑국' } } })).toBe(logCount);
} finally {
await hooks.close();
}
});
});