fix: 후계자 없는 군주 사망 시 국가 멸망을 함께 처리
This commit is contained in:
@@ -9,12 +9,13 @@ import type {
|
|||||||
TurnSchedule,
|
TurnSchedule,
|
||||||
UnitSetDefinition,
|
UnitSetDefinition,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic';
|
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
GAME_TICKS_PER_TURN,
|
GAME_TICKS_PER_TURN,
|
||||||
GameClock,
|
GameClock,
|
||||||
assertGameplayCommitAllowed,
|
assertGameplayCommitAllowed,
|
||||||
inferClockPhase,
|
inferClockPhase,
|
||||||
|
JosaUtil,
|
||||||
type GameClockMode,
|
type GameClockMode,
|
||||||
type GameClockPhase,
|
type GameClockPhase,
|
||||||
type TurnRecoveryWindow,
|
type TurnRecoveryWindow,
|
||||||
@@ -78,6 +79,7 @@ export interface GeneralTurnResult {
|
|||||||
troopIds?: number[];
|
troopIds?: number[];
|
||||||
};
|
};
|
||||||
destroyedNationIds?: number[];
|
destroyedNationIds?: number[];
|
||||||
|
successorlessNationId?: number;
|
||||||
lifecycleEvent?: GeneralLifecycleEvent;
|
lifecycleEvent?: GeneralLifecycleEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2028,6 +2030,11 @@ export class InMemoryTurnWorld {
|
|||||||
this.removeTroop(troopId);
|
this.removeTroop(troopId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (result.successorlessNationId !== undefined) {
|
||||||
|
// 사망 군주도 삭제 전 archive의 장수 목록과 멸망 로그에 포함한다.
|
||||||
|
this.generals.set(currentGeneral.id, result.general ?? currentGeneral);
|
||||||
|
this.dissolveNationWithoutSuccessor(result.successorlessNationId, currentGeneral.id);
|
||||||
|
}
|
||||||
if (result.deleted?.general) {
|
if (result.deleted?.general) {
|
||||||
this.removeGeneral(currentGeneral.id);
|
this.removeGeneral(currentGeneral.id);
|
||||||
}
|
}
|
||||||
@@ -2227,6 +2234,64 @@ export class InMemoryTurnWorld {
|
|||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dissolveNationWithoutSuccessor(nationId: number, dyingLordId?: number): boolean {
|
||||||
|
const nation = this.nations.get(nationId);
|
||||||
|
if (!nation) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const members = this.listGenerals().filter((general) => general.nationId === nationId);
|
||||||
|
const dyingLord = members.find((general) => general.id === dyingLordId);
|
||||||
|
if (
|
||||||
|
(dyingLordId !== undefined && dyingLord?.officerLevel !== 12) ||
|
||||||
|
members.some(
|
||||||
|
(general) => general.id !== dyingLordId && (general.npcState !== 5 || general.officerLevel === 12)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(`Nation ${nationId} still has a ruler or successor.`);
|
||||||
|
}
|
||||||
|
// Ref nextRuler() -> deleteNation(true): 부대장(npc=5)은 후계자가
|
||||||
|
// 될 수 없다. 자원 약탈·포상·난수 소비 없이 도시를 공백지로 돌린다.
|
||||||
|
for (const city of this.listCities()) {
|
||||||
|
if (city.nationId === nationId) {
|
||||||
|
this.updateCity(city.id, { nationId: 0, frontState: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const orderedMembers = members.sort((left, right) => {
|
||||||
|
if (left.id === dyingLordId) return 1;
|
||||||
|
if (right.id === dyingLordId) return -1;
|
||||||
|
return left.id - right.id;
|
||||||
|
});
|
||||||
|
const pushHistory = (): void => {
|
||||||
|
this.pushLog({
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
text: `<R><b>【멸망】</b></><D><b>${nation.name}</b></>${JosaUtil.pick(nation.name, '은')} <R>멸망</>했습니다.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
for (const general of orderedMembers) {
|
||||||
|
if (general.id === dyingLordId) pushHistory();
|
||||||
|
// Ref applyDB()는 개인 역사 bucket을 행동 bucket보다 먼저 저장한다.
|
||||||
|
this.pushLog({
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
generalId: general.id,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
text: `<D><b>${nation.name}</b></>${JosaUtil.pick(nation.name, '이')} <R>멸망</>`,
|
||||||
|
});
|
||||||
|
this.pushLog({
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
generalId: general.id,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
text: `<D><b>${nation.name}</b></>${JosaUtil.pick(nation.name, '이')} <R>멸망</>했습니다.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 과거 누락으로 군주가 이미 삭제된 국가의 운영 복구도 같은 정산을 쓴다.
|
||||||
|
if (dyingLordId === undefined) pushHistory();
|
||||||
|
return this.collapseNation(nationId);
|
||||||
|
}
|
||||||
|
|
||||||
collapseNation(nationId: number): boolean {
|
collapseNation(nationId: number): boolean {
|
||||||
const nation = this.nations.get(nationId);
|
const nation = this.nations.get(nationId);
|
||||||
if (!nation) {
|
if (!nation) {
|
||||||
|
|||||||
@@ -2252,6 +2252,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
? 'retired'
|
? 'retired'
|
||||||
: 'active';
|
: 'active';
|
||||||
let deleteGeneral = false;
|
let deleteGeneral = false;
|
||||||
|
let successorlessNationId: number | undefined;
|
||||||
const deletedTroopIds = Array.from(commandDeletedTroopIds);
|
const deletedTroopIds = Array.from(commandDeletedTroopIds);
|
||||||
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
|
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
|
||||||
if (currentGeneral.meta.killturn <= 0) {
|
if (currentGeneral.meta.killturn <= 0) {
|
||||||
@@ -2351,6 +2352,10 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
`<Y>${successor.name}</>이 <D><b>${currentNation.name}</b></>의 유지를 이어 받았습니다`
|
`<Y>${successor.name}</>이 <D><b>${currentNation.name}</b></>의 유지를 이어 받았습니다`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// Ref nextRuler()는 후계자가 없으면 군주 삭제 전에
|
||||||
|
// deleteNation($general, true)로 국가 전체를 정산한다.
|
||||||
|
successorlessNationId = currentNation.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentGeneral.troopId === currentGeneral.id) {
|
if (currentGeneral.troopId === currentGeneral.id) {
|
||||||
@@ -2424,6 +2429,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
}
|
}
|
||||||
: undefined),
|
: undefined),
|
||||||
...(destroyedNationIds.size > 0 ? { destroyedNationIds: [...destroyedNationIds] } : undefined),
|
...(destroyedNationIds.size > 0 ? { destroyedNationIds: [...destroyedNationIds] } : undefined),
|
||||||
|
...(successorlessNationId !== undefined ? { successorlessNationId } : {}),
|
||||||
lifecycleEvent: {
|
lifecycleEvent: {
|
||||||
generalId: currentGeneral.id,
|
generalId: currentGeneral.id,
|
||||||
outcome: lifecycleOutcome,
|
outcome: lifecycleOutcome,
|
||||||
|
|||||||
@@ -337,6 +337,62 @@ describe('legacy general turn lifecycle', () => {
|
|||||||
expect(harness.world.peekDirtyState().deletedGenerals).toContain(1);
|
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 () => {
|
it('deletes an expired NPC even when its in-memory lifespan metadata is missing', async () => {
|
||||||
const harness = await createTurnTestHarness({
|
const harness = await createTurnTestHarness({
|
||||||
snapshot: makeSnapshot([
|
snapshot: makeSnapshot([
|
||||||
|
|||||||
@@ -457,4 +457,157 @@ integration('monthly wandering nation persistence', () => {
|
|||||||
await hooks.close();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -168,6 +168,60 @@ const readFixture = (relativePath: string): TurnCommandFixtureRequest => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
integration('core ↔ legacy command-boundary differential', () => {
|
integration('core ↔ legacy command-boundary differential', () => {
|
||||||
|
it('dissolves a successorless ruler nation at the death boundary', async () => {
|
||||||
|
const request = readFixture('fixtures/turn-differential/live-sortie-conquest.json');
|
||||||
|
request.actorGeneralId = 2;
|
||||||
|
request.action = '휴식';
|
||||||
|
request.args = {};
|
||||||
|
request.includeLifecycle = true;
|
||||||
|
request.setup!.generals![1] = {
|
||||||
|
...request.setup!.generals![1],
|
||||||
|
npcState: 4,
|
||||||
|
killTurn: 1,
|
||||||
|
deadYear: 185,
|
||||||
|
};
|
||||||
|
request.setup!.generals!.push({
|
||||||
|
id: 3,
|
||||||
|
name: '부대장',
|
||||||
|
nationId: 2,
|
||||||
|
cityId: 70,
|
||||||
|
officerLevel: 11,
|
||||||
|
npcState: 5,
|
||||||
|
killTurn: 24,
|
||||||
|
gold: 2345,
|
||||||
|
rice: 6789,
|
||||||
|
crew: 0,
|
||||||
|
});
|
||||||
|
request.observe = {
|
||||||
|
...request.observe,
|
||||||
|
generalIds: [1, 2, 3],
|
||||||
|
nationIds: [1, 2],
|
||||||
|
cityIds: [3, 70],
|
||||||
|
includeGlobalHistoryLogs: true,
|
||||||
|
includeNationHistoryLogs: true,
|
||||||
|
};
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
for (const result of [reference, core]) {
|
||||||
|
expect(result.after.generals.find((general) => general.id === 2)).toBeUndefined();
|
||||||
|
expect(result.after.nations.find((nation) => nation.id === 2)).toBeUndefined();
|
||||||
|
expect(result.after.cities.find((city) => city.id === 70)).toMatchObject({ nationId: 0, frontState: 0 });
|
||||||
|
expect(result.after.generals.find((general) => general.id === 3)).toMatchObject({
|
||||||
|
nationId: 0,
|
||||||
|
officerLevel: 0,
|
||||||
|
gold: 2345,
|
||||||
|
rice: 6789,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(semanticLogSignatures(core.after.logs.filter((log) => String(log.text).includes('멸망')))).toEqual(
|
||||||
|
semanticLogSignatures(reference.after.logs.filter((log) => String(log.text).includes('멸망')))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['nation declaration', 'fixtures/turn-differential/nation-declaration.json'],
|
['nation declaration', 'fixtures/turn-differential/nation-declaration.json'],
|
||||||
['live sortie conquest', 'fixtures/turn-differential/live-sortie-conquest.json'],
|
['live sortie conquest', 'fixtures/turn-differential/live-sortie-conquest.json'],
|
||||||
|
|||||||
Reference in New Issue
Block a user