fix war command differential parity

This commit is contained in:
2026-07-26 22:26:09 +00:00
parent 1011822463
commit 789b9c8c8f
9 changed files with 69 additions and 52 deletions
+12 -5
View File
@@ -1035,7 +1035,7 @@ export class InMemoryTurnWorld {
// 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;
const rawConflict = city.conflict;
if (rawConflict === null || rawConflict === undefined) {
continue;
}
@@ -1056,10 +1056,7 @@ export class InMemoryTurnWorld {
delete conflict[key];
this.cities.set(city.id, {
...city,
meta: {
...city.meta,
conflict: JSON.stringify(conflict),
},
conflict: conflict as City['conflict'],
});
this.dirtyCityIds.add(city.id);
}
@@ -1073,10 +1070,20 @@ export class InMemoryTurnWorld {
if (general.nationId !== nationId) {
continue;
}
const belong = typeof general.meta.belong === 'number' ? general.meta.belong : 0;
const maxBelong = typeof general.meta.max_belong === 'number' ? general.meta.max_belong : 0;
const updated = applyGeneralPatch(general, {
nationId: 0,
officerLevel: 0,
troopId: 0,
meta: {
...general.meta,
belong: 0,
officer_city: 0,
officerCity: 0,
permission: 'normal',
...(general.npcState < 2 ? { max_belong: Math.max(belong, maxBelong) } : {}),
},
});
this.generals.set(general.id, normalizeGeneralTurnTime(updated, this.state.lastTurnTime));
this.dirtyGeneralIds.add(general.id);
@@ -129,8 +129,9 @@ const applyLegacyGeneralProgression = (
const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양';
if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) {
const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0);
const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel);
meta.explevel = expLevel;
if (expLevel !== previousExpLevel) {
if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) {
const josaRo = JosaUtil.pick(String(expLevel), '로');
logs.push({
scope: LogScope.GENERAL,
@@ -1089,6 +1090,9 @@ export const createReservedTurnHandler = async (options: {
const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {});
const generalBeforeExecution = currentGeneral;
const cityNationIdsBeforeExecution = new Map(
(worldView?.listCities() ?? []).map((city) => [city.id, city.nationId] as const)
);
const resolution = resolveGeneralAction(
definition,
actionContext,
@@ -1279,9 +1283,14 @@ export const createReservedTurnHandler = async (options: {
}
}
const hasNationChange = (resolution.patches?.cities ?? []).some((patch) =>
Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId')
);
const hasNationChange = (resolution.patches?.cities ?? []).some((patch) => {
if (!Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId')) {
return false;
}
const nextNationId = patch.patch.nationId;
const previousNationId = cityNationIdsBeforeExecution.get(patch.id);
return previousNationId === undefined || nextNationId !== previousNationId;
});
const refreshesFrontForDiplomacyState =
actionKey === 'che_이호경식' &&
resolution.effects.some(
@@ -87,9 +87,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
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 }),
});
conflictCity.conflict = { 2: 100, 1: 50 };
const unitSet: UnitSetDefinition = {
id: 'test_unit_set',
@@ -170,6 +168,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
gold: 0,
rice: 0,
turnTime: delayedTurnTime,
meta: { killturn: 800, belong: 10 },
}
);
generals.push(weakGeneral);
@@ -264,10 +263,11 @@ 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 });
expect(world.getCityById(conflictCity.id)?.conflict).toEqual({ 1: 50 });
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
expect(updatedWeakGeneral?.nationId).toBe(0);
expect(updatedWeakGeneral?.officerLevel).toBe(0);
expect(updatedWeakGeneral?.meta.belong).toBe(0);
});
});
+16 -22
View File
@@ -13,10 +13,9 @@ import type {
WarAftermathTechContext,
WarDiplomacyDelta,
} from './types.js';
import { clamp, clampMin, getMetaNumber, round, simpleSerialize } from './utils.js';
import { clamp, clampMin, getMetaNumber, parseConflict, round, simpleSerialize } from './utils.js';
const META_DEAD = 'dead';
const META_CONFLICT = 'conflict';
const MAX_EXP_LEVEL = 255;
const MAX_DEDICATION_LEVEL = 30;
@@ -120,29 +119,24 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
};
const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => {
const rawConflict = city.meta[META_CONFLICT];
if (!rawConflict) {
const conflict = parseConflict(city.conflict);
if (!conflict) {
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' &&
(key === attackerNationId || activeNationIds.has(key))
)
.sort(([, lhs], [, rhs]) => rhs - lhs);
if (!entries.length) {
return attackerNationId;
}
return entries[0]![0];
} catch {
const activeNationIds = new Set(nations.map((nation) => nation.id));
const entries = Object.entries(conflict)
.map(([key, value]) => [Number(key), value] as const)
.filter(
([key, value]) =>
Number.isFinite(key) &&
typeof value === 'number' &&
(key === attackerNationId || activeNationIds.has(key))
)
.sort(([, lhs], [, rhs]) => rhs - lhs);
if (!entries.length) {
return attackerNationId;
}
return entries[0]![0];
};
const getCityPosition = (city: City): { x: number; y: number } | null => {
@@ -383,7 +377,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
defenderCity.commerce = round(defenderCity.commerce * 0.7);
defenderCity.security = round(defenderCity.security * 0.7);
defenderCity.nationId = conquerNationId;
defenderCity.meta[META_CONFLICT] = '{}';
defenderCity.conflict = {};
if (defenderCity.level > 3) {
defenderCity.defence = config.defaultCityWall;
+3 -3
View File
@@ -4,7 +4,7 @@ import type { City, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
import type { WarEngineConfig } from '../types.js';
import type { WarCrewType } from '../crewType.js';
import { clampMin, round, parseConflict, sortConflict, stringifyConflict } from '../utils.js';
import { clampMin, round, parseConflict, sortConflict } from '../utils.js';
import { WarUnit } from './base.js';
// 도시 성벽 전투 유닛(legacy WarUnitCity 포팅).
@@ -133,7 +133,7 @@ export class WarUnitCity extends WarUnit {
}
public addConflict(): boolean {
const raw = this.city.meta.conflict;
const raw = this.city.conflict;
const conflict = parseConflict(raw) ?? {};
const oppose = this.getOppose();
@@ -161,7 +161,7 @@ export class WarUnitCity extends WarUnit {
}
const sorted = sortConflict(conflict);
this.city.meta.conflict = stringifyConflict(sorted);
this.city.conflict = sorted;
return isNew;
}
+14 -11
View File
@@ -55,23 +55,26 @@ export const parseConflict = (raw: TriggerValue | undefined): Record<number, num
if (raw === undefined || raw === null) {
return null;
}
let parsed: unknown = raw;
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw) as Record<string, number>;
const result: Record<number, number> = {};
for (const [key, value] of Object.entries(parsed)) {
const id = Number(key);
if (!Number.isFinite(id) || typeof value !== 'number') {
continue;
}
result[id] = value;
}
return Object.keys(result).length ? result : null;
parsed = JSON.parse(raw) as unknown;
} catch {
return null;
}
}
return null;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return null;
}
const result: Record<number, number> = {};
for (const [key, value] of Object.entries(parsed)) {
const id = Number(key);
if (!Number.isFinite(id) || typeof value !== 'number') {
continue;
}
result[id] = value;
}
return Object.keys(result).length ? result : null;
};
export const stringifyConflict = (conflict: Record<number, number> | null): string => {
+3 -3
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({ 99: 200, 1: 100 });
defenderCity.conflict = { 99: 200, 1: 100 };
const attacker = buildGeneral(1, 1, 1);
attacker.officerLevel = 12;
const defender = buildGeneral(2, 2, 2);
@@ -236,10 +236,10 @@ 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
// Removed nations can remain in old persisted conflict data. They
// must never receive ownership during a later conquest.
expect(defenderCity.nationId).toBe(attackerNation.id);
expect(defenderCity.meta.conflict).toBe('{}');
expect(defenderCity.conflict).toEqual({});
expect(outcome.logs.map((log) => log.text)).toEqual(
expect.arrayContaining([
'<D><b>Nation2</b></>를 정복',
+3
View File
@@ -300,6 +300,8 @@ describe('war triggers', () => {
);
cityUnit.setOppose(firstAttacker);
expect(cityUnit.addConflict()).toBe(false);
expect(city.conflict).toEqual({ 1: 1.05 });
expect(city.meta.conflict).toBeUndefined();
const secondNation = { ...buildNation(), id: 2 };
const secondGeneral = { ...buildGeneral(80), id: 2, nationId: 2 };
@@ -316,6 +318,7 @@ describe('war triggers', () => {
);
cityUnit.setOppose(secondAttacker);
expect(cityUnit.addConflict()).toBe(true);
expect(city.conflict).toEqual({ 1: 1.05, 2: 1 });
});
});
@@ -76,6 +76,7 @@ integration('core ↔ legacy command-boundary differential', () => {
it.each([
['nation declaration', 'fixtures/turn-differential/nation-declaration.json'],
['live sortie conquest', 'fixtures/turn-differential/live-sortie-conquest.json'],
['live sortie against a defending general', 'fixtures/turn-differential/live-sortie-defender.json'],
])(
'%s matches command RNG and canonical state delta',
async (_label, fixturePath) => {