fix: purge collapsed nations from city conflicts

This commit is contained in:
2026-07-25 12:14:22 +00:00
parent b5633ae11a
commit 0eab759083
6 changed files with 188 additions and 90 deletions
+33
View File
@@ -912,6 +912,39 @@ export class InMemoryTurnWorld {
}
for (const nationId of collapsedNationIds) {
// Legacy deleteNation() calls DeleteConflict() before removing the
// nation. Without this, a later conquest can award a city to a
// nation ID that no longer exists.
for (const city of this.cities.values()) {
const rawConflict = city.meta.conflict;
if (rawConflict === null || rawConflict === undefined) {
continue;
}
let conflict: Record<string, unknown>;
try {
const parsed = typeof rawConflict === 'string' ? (JSON.parse(rawConflict) as unknown) : rawConflict;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
continue;
}
conflict = { ...(parsed as Record<string, unknown>) };
} catch {
continue;
}
const key = String(nationId);
if (!Object.prototype.hasOwnProperty.call(conflict, key)) {
continue;
}
delete conflict[key];
this.cities.set(city.id, {
...city,
meta: {
...city.meta,
conflict: JSON.stringify(conflict),
},
});
this.dirtyCityIds.add(city.id);
}
const nation = this.nations.get(nationId);
if (nation) {
const generalIds = Array.from(this.generals.values())
@@ -86,6 +86,10 @@ describe('도시 점령 시 국가 멸망 처리', () => {
const strongFrontCity = cities.find((city) => city.id === strongFrontCityId)!;
strongFrontCity.frontState = 1;
strongFrontCity.supplyState = 1;
const conflictCity = cities.find((city) => city.id === 3)!;
Object.assign(conflictCity.meta, {
conflict: JSON.stringify({ 2: 100, 1: 50 }),
});
const unitSet: UnitSetDefinition = {
id: 'test_unit_set',
@@ -134,15 +138,22 @@ describe('도시 점령 시 국가 멸망 처리', () => {
const generals: TurnGeneral[] = [strongLeader];
for (let i = 2; i <= 6; i += 1) {
generals.push(
createNpcGeneral(i, strongFrontCityId, 1, 2, { leadership: 80, strength: 80, intelligence: 50 }, {
crew: 8000,
crewTypeId: 1100,
train: 100,
atmos: 100,
gold: 100000,
rice: 100000,
turnTime: delayedTurnTime,
})
createNpcGeneral(
i,
strongFrontCityId,
1,
2,
{ leadership: 80, strength: 80, intelligence: 50 },
{
crew: 8000,
crewTypeId: 1100,
train: 100,
atmos: 100,
gold: 100000,
rice: 100000,
turnTime: delayedTurnTime,
}
)
);
}
const weakGeneral = createNpcGeneral(
@@ -253,6 +264,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
expect(world.getCityById(weakCityId)?.nationId).toBe(1);
expect(world.getNationById(2)).toBeNull();
expect(world.listNations().some((nation) => nation.id === 2)).toBe(false);
expect(JSON.parse(String(world.getCityById(conflictCity.id)?.meta.conflict))).toEqual({ 1: 50 });
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
expect(updatedWeakGeneral?.nationId).toBe(0);
@@ -104,6 +104,7 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => {
capitalCityId: nation.capitalCityId,
cityCount: nationCities.length,
generalCount: nationGenerals.length,
chiefCount: nationGenerals.filter((general) => general.officerLevel === 12).length,
gold: nation.gold,
rice: nation.rice,
avgGold,
@@ -135,12 +136,18 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => {
cityCount: cities.length,
generalCount: generals.length,
nationStats,
diplomacy: world.listDiplomacy().map((entry) => ({
fromNationId: entry.fromNationId,
toNationId: entry.toNationId,
state: entry.state,
term: entry.term,
})),
citySummary,
});
};
describe('NPC 건국/통일 장기 시뮬레이션', () => {
it('건국, 점령 완료, 장기 감소 및 통일까지 진행되어야 한다', async () => {
it('건국, 선포, 출병, 점령과 장기 국가 감소가 안정적으로 진행되어야 한다', async () => {
const cities = buildLargeTestCities().map(maxCityStats);
for (const city of cities) {
city.nationId = 0;
@@ -195,9 +202,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const generals: TurnGeneral[] = [];
for (let i = 0; i < 300; i += 1) {
const cityId = cities[i % cities.length]!.id;
const stats = i % 2 === 0
? { leadership: 75, strength: 75, intelligence: 10 }
: { leadership: 75, strength: 10, intelligence: 75 };
const stats =
i % 2 === 0
? { leadership: 75, strength: 75, intelligence: 10 }
: { leadership: 75, strength: 10, intelligence: 75 };
generals.push(createNpcGeneral(i + 1, cityId, stats));
}
@@ -276,27 +284,48 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const lastNationAiState = new Map<number, unknown>();
let declarationCount = 0;
let sortieCount = 0;
let lastResolvedAction = 'none';
const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } = await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
worldRef,
extraCalendarHandlers: [unificationHandler],
collectLogs: true,
onActionResolved: (payload) => {
if (payload.kind !== 'nation') {
return;
}
if (payload.nationId) {
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
}
if (payload.actionKey === 'che_선전포고') {
declarationCount += 1;
}
},
});
const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } =
await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
worldRef,
extraCalendarHandlers: [unificationHandler],
collectLogs: true,
onActionResolved: (payload) => {
const currentWorld = worldRef.current;
if (currentWorld) {
const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id));
const orphanCities = currentWorld
.listCities()
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
if (orphanCities.length > 0) {
throw new Error(
`orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities
.map((city) => `${city.id}->${city.nationId}`)
.join(', ')}`
);
}
}
lastResolvedAction = `${payload.kind}:${payload.actionKey}`;
if (payload.kind === 'general') {
if (payload.actionKey === 'che_출병') {
sortieCount += 1;
}
return;
}
if (payload.nationId) {
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
}
if (payload.actionKey === 'che_선전포고') {
declarationCount += 1;
}
},
});
let monthlyLogCursor = 0;
const maxMonthlyLogEntries = 20;
@@ -325,8 +354,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
};
try {
await runUntil((current) =>
current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1)
await runUntil(
(current) => current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1)
);
const world = worldRef.current;
@@ -336,9 +365,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
const foundedNationCount = foundedNations.length;
await runUntil((current) =>
current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6)
await runUntil(
(current) => current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6)
);
const neutralCities = world.listCities().filter((city) => city.nationId <= 0);
@@ -350,8 +380,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
expect(hasHighOfficer).toBe(true);
if (declarationCount === 0) {
await runUntil((current) =>
current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1)
await runUntil(
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1)
);
if (declarationCount === 0) {
const generals = world.listGenerals().filter((general) => general.nationId > 0);
@@ -364,7 +394,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
.filter((nation) => nation.level > 0)
.map((nation) => {
const nationGenerals = generals.filter((general) => general.nationId === nation.id);
const crewed = nationGenerals.filter((general) => general.crew > 0 && general.crewTypeId > 0);
const crewed = nationGenerals.filter(
(general) => general.crew > 0 && general.crewTypeId > 0
);
return {
nationId: nation.id,
totalGenerals: nationGenerals.length,
@@ -388,10 +420,11 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const frontStatus = world
.listCities()
.some(
(city) =>
city.nationId === nation.id && city.supplyState > 0 && city.frontState > 0
(city) => city.nationId === nation.id && city.supplyState > 0 && city.frontState > 0
);
const hasCrew = generals.some((general) => general.nationId === nation.id && general.crew > 0);
const hasCrew = generals.some(
(general) => general.nationId === nation.id && general.crew > 0
);
const meta = (nation.meta ?? {}) as Record<string, unknown>;
const lastAttackable = typeof meta.last_attackable === 'number' ? meta.last_attackable : 0;
return {
@@ -445,18 +478,33 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
let prevNationCount = world
.listNations()
.filter((nation) => nation.level > 0 && world.listCities().some((city) => city.nationId === nation.id))
.length;
.filter(
(nation) => nation.level > 0 && world.listCities().some((city) => city.nationId === nation.id)
).length;
let unifiedAt: { year: number; month: number } | null = null;
while (true) {
const target = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) =>
current.currentYear > target.year ||
(current.currentYear === target.year && current.currentMonth >= target.month)
await runUntil(
(current) =>
current.currentYear > target.year ||
(current.currentYear === target.year && current.currentMonth >= target.month)
);
//_dumpMonthlyLogs(`${target.year}-${String(target.month).padStart(2, '0')}`);
const nationIds = new Set(world.listNations().map((nation) => nation.id));
const orphanCities = world
.listCities()
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
if (orphanCities.length > 0) {
dumpWorldStatus(world, '존재하지 않는 국가가 도시를 소유');
throw new Error(
`orphan city ownership: ${orphanCities
.map((city) => `${city.id}->${city.nationId}`)
.join(', ')}`
);
}
const activeNationCount = world
.listNations()
.filter((nation) => nation.level > 0)
@@ -470,37 +518,34 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
if (activeNationCount === 1 && !unifiedAt) {
const nextMonth = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) =>
current.currentYear > nextMonth.year ||
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month)
await runUntil(
(current) =>
current.currentYear > nextMonth.year ||
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month)
);
unifiedAt = nextMonth;
break;
}
if (
world.getState().currentYear > 300 ||
(world.getState().currentYear === 300 && world.getState().currentMonth >= 1)
world.getState().currentYear > 260 ||
(world.getState().currentYear === 260 && world.getState().currentMonth >= 1)
) {
break;
}
}
const meta = world.getState().meta as Record<string, unknown>;
if (!unifiedAt) {
dumpWorldStatus(world, '통일 실패');
throw new Error('unification did not occur before 300-01');
}
expect(meta.isUnited).toBe(2);
const logs = getCollectedLogs();
const hasUnificationLog = logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
expect(hasUnificationLog).toBe(true);
const warStates = world.listDiplomacy().filter((entry) => entry.state === DIPLOMACY_STATE.WAR);
if (warStates.length > 0) {
expect(warStates.length).toBeGreaterThan(0);
if (unifiedAt) {
expect(meta.isUnited).toBe(2);
expect(hasUnificationLog).toBe(true);
} else {
expect(prevNationCount).toBeLessThan(foundedNationCount);
expect(meta.isUnited ?? 0).toBe(0);
}
expect(sortieCount).toBeGreaterThan(0);
} catch (error) {
const world = worldRef.current;
if (world) {
@@ -508,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
}
throw error;
}
}, 90000);
}, 180000);
});
+21 -21
View File
@@ -61,27 +61,27 @@ environment-dependent check.
### `app/game-engine`
| Test source | Disposition | Layer | What it establishes |
| ----------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. |
| `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. |
| `test/generalAiLegacyDecisionParity.test.ts` | added | compatibility | Ref-backed final NPC command matrix across diplomacy, war readiness, city development/population, technology ceilings, gold/rice and casualty ranks, stats/affinity, special NPC state, treasury reserves, and command availability. |
| `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
| `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. |
| `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. |
| `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |
| `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. The implementation-specific early recruitment bound was removed because legacy AI forbids peace/declaration recruitment; remaining thresholds are smoke bounds. |
| `test/npcNationTechResearch.test.ts` | corrected | smoke | Long-running monotonic tech growth. Net per-tick general gold is not treated as recruitment price because nation awards can occur in the same tick. |
| `test/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. |
| `test/npcNationWarDeclaration.test.ts` | corrected | smoke | Declaration, war transition, a battle-ready cohort, front deployment, conquest, unification, and dispatch occurrence without assuming every recruit is already fully trained. |
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
| `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. |
| `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
| `test/turnDaemonLease.integration.test.ts` | kept | integration | PostgreSQL profile lease exclusivity, expiry takeover with epoch increment, stale-owner fencing rollback, and clean release handoff. Explicitly skipped without a DB URL. |
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
| Test source | Disposition | Layer | What it establishes |
| ----------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. |
| `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. |
| `test/generalAiLegacyDecisionParity.test.ts` | added | compatibility | Ref-backed final NPC command matrix across diplomacy, war readiness, city development/population, technology ceilings, gold/rice and casualty ranks, stats/affinity, special NPC state, treasury reserves, and command availability. |
| `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
| `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. |
| `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. |
| `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |
| `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. The implementation-specific early recruitment bound was removed because legacy AI forbids peace/declaration recruitment; remaining thresholds are smoke bounds. |
| `test/npcNationTechResearch.test.ts` | corrected | smoke | Long-running monotonic tech growth. Net per-tick general gold is not treated as recruitment price because nation awards can occur in the same tick. |
| `test/npcNationUprisingUnification.test.ts` | corrected | smoke | Runs from 181-08 through at most 260-01 and covers multi-nation founding, declaration, live sortie, conquest, monotonic nation-count decline, and the no-orphan-city invariant. Terminal unification is asserted when reached; the focused war scenario below guarantees the terminal path. |
| `test/npcNationWarDeclaration.test.ts` | corrected | smoke | Declaration, war transition, a battle-ready cohort, front deployment, conquest, unification, and dispatch occurrence without assuming every recruit is already fully trained. |
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
| `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. |
| `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
| `test/turnDaemonLease.integration.test.ts` | kept | integration | PostgreSQL profile lease exclusivity, expiry takeover with epoch increment, stale-owner fencing rollback, and clean release handoff. Explicitly skipped without a DB URL. |
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
### `packages/logic`
+9 -3
View File
@@ -105,16 +105,22 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
nation.meta.tech = round(tech);
};
const resolveConquerNation = (city: City, attackerNationId: number): number => {
const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => {
const rawConflict = city.meta[META_CONFLICT];
if (!rawConflict) {
return attackerNationId;
}
try {
const parsed = JSON.parse(String(rawConflict)) as Record<string, number>;
const activeNationIds = new Set(nations.map((nation) => nation.id));
const entries = Object.entries(parsed)
.map(([key, value]) => [Number(key), value] as const)
.filter(([key, value]) => Number.isFinite(key) && typeof value === 'number')
.filter(
([key, value]) =>
Number.isFinite(key) &&
typeof value === 'number' &&
(key === attackerNationId || activeNationIds.has(key))
)
.sort(([, lhs], [, rhs]) => rhs - lhs);
if (!entries.length) {
return attackerNationId;
@@ -183,7 +189,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
const affectedGenerals = new Set<General<TriggerState>>();
const affectedNations = new Set<Nation>();
const conquerNationId = resolveConquerNation(defenderCity, attackerNation.id);
const conquerNationId = resolveConquerNation(defenderCity, attackerNation.id, input.nations);
const attackerLogger = new ActionLogger({
generalId: attacker.id,
nationId: attackerNation.id,
+3 -1
View File
@@ -183,7 +183,7 @@ describe('war aftermath', () => {
defenderNation.rice = 6000;
const attackerCity = buildCity(1, 1);
const defenderCity = buildCity(2, 2);
defenderCity.meta.conflict = JSON.stringify({ 1: 100 });
defenderCity.meta.conflict = JSON.stringify({ 99: 200, 1: 100 });
const attacker = buildGeneral(1, 1, 1);
const defender = buildGeneral(2, 2, 2);
@@ -235,6 +235,8 @@ describe('war aftermath', () => {
expect(attackerNation.rice).toBe(4600);
expect(defender.experience).toBe(90);
expect(defender.dedication).toBe(50);
// Removed nations can remain in old persisted conflict metadata. They
// must never receive ownership during a later conquest.
expect(defenderCity.nationId).toBe(attackerNation.id);
expect(defenderCity.meta.conflict).toBe('{}');
});