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
@@ -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);
});