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. Without this, a later conquest can award a city to a
// nation ID that no longer exists. // nation ID that no longer exists.
for (const city of this.cities.values()) { for (const city of this.cities.values()) {
const rawConflict = city.meta.conflict; const rawConflict = city.conflict;
if (rawConflict === null || rawConflict === undefined) { if (rawConflict === null || rawConflict === undefined) {
continue; continue;
} }
@@ -1056,10 +1056,7 @@ export class InMemoryTurnWorld {
delete conflict[key]; delete conflict[key];
this.cities.set(city.id, { this.cities.set(city.id, {
...city, ...city,
meta: { conflict: conflict as City['conflict'],
...city.meta,
conflict: JSON.stringify(conflict),
},
}); });
this.dirtyCityIds.add(city.id); this.dirtyCityIds.add(city.id);
} }
@@ -1073,10 +1070,20 @@ export class InMemoryTurnWorld {
if (general.nationId !== nationId) { if (general.nationId !== nationId) {
continue; 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, { const updated = applyGeneralPatch(general, {
nationId: 0, nationId: 0,
officerLevel: 0, officerLevel: 0,
troopId: 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.generals.set(general.id, normalizeGeneralTurnTime(updated, this.state.lastTurnTime));
this.dirtyGeneralIds.add(general.id); this.dirtyGeneralIds.add(general.id);
@@ -129,8 +129,9 @@ const applyLegacyGeneralProgression = (
const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양'; const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양';
if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) { if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) {
const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0); const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0);
const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel);
meta.explevel = expLevel; meta.explevel = expLevel;
if (expLevel !== previousExpLevel) { if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) {
const josaRo = JosaUtil.pick(String(expLevel), '로'); const josaRo = JosaUtil.pick(String(expLevel), '로');
logs.push({ logs.push({
scope: LogScope.GENERAL, scope: LogScope.GENERAL,
@@ -1089,6 +1090,9 @@ export const createReservedTurnHandler = async (options: {
const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {}); const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {});
const generalBeforeExecution = currentGeneral; const generalBeforeExecution = currentGeneral;
const cityNationIdsBeforeExecution = new Map(
(worldView?.listCities() ?? []).map((city) => [city.id, city.nationId] as const)
);
const resolution = resolveGeneralAction( const resolution = resolveGeneralAction(
definition, definition,
actionContext, actionContext,
@@ -1279,9 +1283,14 @@ export const createReservedTurnHandler = async (options: {
} }
} }
const hasNationChange = (resolution.patches?.cities ?? []).some((patch) => const hasNationChange = (resolution.patches?.cities ?? []).some((patch) => {
Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId') 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 = const refreshesFrontForDiplomacyState =
actionKey === 'che_이호경식' && actionKey === 'che_이호경식' &&
resolution.effects.some( resolution.effects.some(
@@ -87,9 +87,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
strongFrontCity.frontState = 1; strongFrontCity.frontState = 1;
strongFrontCity.supplyState = 1; strongFrontCity.supplyState = 1;
const conflictCity = cities.find((city) => city.id === 3)!; const conflictCity = cities.find((city) => city.id === 3)!;
Object.assign(conflictCity.meta, { conflictCity.conflict = { 2: 100, 1: 50 };
conflict: JSON.stringify({ 2: 100, 1: 50 }),
});
const unitSet: UnitSetDefinition = { const unitSet: UnitSetDefinition = {
id: 'test_unit_set', id: 'test_unit_set',
@@ -170,6 +168,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
gold: 0, gold: 0,
rice: 0, rice: 0,
turnTime: delayedTurnTime, turnTime: delayedTurnTime,
meta: { killturn: 800, belong: 10 },
} }
); );
generals.push(weakGeneral); generals.push(weakGeneral);
@@ -264,10 +263,11 @@ describe('도시 점령 시 국가 멸망 처리', () => {
expect(world.getCityById(weakCityId)?.nationId).toBe(1); expect(world.getCityById(weakCityId)?.nationId).toBe(1);
expect(world.getNationById(2)).toBeNull(); expect(world.getNationById(2)).toBeNull();
expect(world.listNations().some((nation) => nation.id === 2)).toBe(false); 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); const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
expect(updatedWeakGeneral?.nationId).toBe(0); expect(updatedWeakGeneral?.nationId).toBe(0);
expect(updatedWeakGeneral?.officerLevel).toBe(0); expect(updatedWeakGeneral?.officerLevel).toBe(0);
expect(updatedWeakGeneral?.meta.belong).toBe(0);
}); });
}); });
+16 -22
View File
@@ -13,10 +13,9 @@ import type {
WarAftermathTechContext, WarAftermathTechContext,
WarDiplomacyDelta, WarDiplomacyDelta,
} from './types.js'; } 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_DEAD = 'dead';
const META_CONFLICT = 'conflict';
const MAX_EXP_LEVEL = 255; const MAX_EXP_LEVEL = 255;
const MAX_DEDICATION_LEVEL = 30; const MAX_DEDICATION_LEVEL = 30;
@@ -120,29 +119,24 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
}; };
const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => { const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => {
const rawConflict = city.meta[META_CONFLICT]; const conflict = parseConflict(city.conflict);
if (!rawConflict) { if (!conflict) {
return attackerNationId; return attackerNationId;
} }
try { const activeNationIds = new Set(nations.map((nation) => nation.id));
const parsed = JSON.parse(String(rawConflict)) as Record<string, number>; const entries = Object.entries(conflict)
const activeNationIds = new Set(nations.map((nation) => nation.id)); .map(([key, value]) => [Number(key), value] as const)
const entries = Object.entries(parsed) .filter(
.map(([key, value]) => [Number(key), value] as const) ([key, value]) =>
.filter( Number.isFinite(key) &&
([key, value]) => typeof value === 'number' &&
Number.isFinite(key) && (key === attackerNationId || activeNationIds.has(key))
typeof value === 'number' && )
(key === attackerNationId || activeNationIds.has(key)) .sort(([, lhs], [, rhs]) => rhs - lhs);
) if (!entries.length) {
.sort(([, lhs], [, rhs]) => rhs - lhs);
if (!entries.length) {
return attackerNationId;
}
return entries[0]![0];
} catch {
return attackerNationId; return attackerNationId;
} }
return entries[0]![0];
}; };
const getCityPosition = (city: City): { x: number; y: number } | null => { 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.commerce = round(defenderCity.commerce * 0.7);
defenderCity.security = round(defenderCity.security * 0.7); defenderCity.security = round(defenderCity.security * 0.7);
defenderCity.nationId = conquerNationId; defenderCity.nationId = conquerNationId;
defenderCity.meta[META_CONFLICT] = '{}'; defenderCity.conflict = {};
if (defenderCity.level > 3) { if (defenderCity.level > 3) {
defenderCity.defence = config.defaultCityWall; 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 { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
import type { WarEngineConfig } from '../types.js'; import type { WarEngineConfig } from '../types.js';
import type { WarCrewType } from '../crewType.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'; import { WarUnit } from './base.js';
// 도시 성벽 전투 유닛(legacy WarUnitCity 포팅). // 도시 성벽 전투 유닛(legacy WarUnitCity 포팅).
@@ -133,7 +133,7 @@ export class WarUnitCity extends WarUnit {
} }
public addConflict(): boolean { public addConflict(): boolean {
const raw = this.city.meta.conflict; const raw = this.city.conflict;
const conflict = parseConflict(raw) ?? {}; const conflict = parseConflict(raw) ?? {};
const oppose = this.getOppose(); const oppose = this.getOppose();
@@ -161,7 +161,7 @@ export class WarUnitCity extends WarUnit {
} }
const sorted = sortConflict(conflict); const sorted = sortConflict(conflict);
this.city.meta.conflict = stringifyConflict(sorted); this.city.conflict = sorted;
return isNew; return isNew;
} }
+14 -11
View File
@@ -55,23 +55,26 @@ export const parseConflict = (raw: TriggerValue | undefined): Record<number, num
if (raw === undefined || raw === null) { if (raw === undefined || raw === null) {
return null; return null;
} }
let parsed: unknown = raw;
if (typeof raw === 'string') { if (typeof raw === 'string') {
try { try {
const parsed = JSON.parse(raw) as Record<string, number>; parsed = JSON.parse(raw) as unknown;
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;
} catch { } catch {
return null; 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 => { export const stringifyConflict = (conflict: Record<number, number> | null): string => {
+3 -3
View File
@@ -183,7 +183,7 @@ describe('war aftermath', () => {
defenderNation.rice = 6000; defenderNation.rice = 6000;
const attackerCity = buildCity(1, 1); const attackerCity = buildCity(1, 1);
const defenderCity = buildCity(2, 2); 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); const attacker = buildGeneral(1, 1, 1);
attacker.officerLevel = 12; attacker.officerLevel = 12;
const defender = buildGeneral(2, 2, 2); const defender = buildGeneral(2, 2, 2);
@@ -236,10 +236,10 @@ describe('war aftermath', () => {
expect(attackerNation.rice).toBe(4600); expect(attackerNation.rice).toBe(4600);
expect(defender.experience).toBe(90); expect(defender.experience).toBe(90);
expect(defender.dedication).toBe(50); 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. // must never receive ownership during a later conquest.
expect(defenderCity.nationId).toBe(attackerNation.id); expect(defenderCity.nationId).toBe(attackerNation.id);
expect(defenderCity.meta.conflict).toBe('{}'); expect(defenderCity.conflict).toEqual({});
expect(outcome.logs.map((log) => log.text)).toEqual( expect(outcome.logs.map((log) => log.text)).toEqual(
expect.arrayContaining([ expect.arrayContaining([
'<D><b>Nation2</b></>를 정복', '<D><b>Nation2</b></>를 정복',
+3
View File
@@ -300,6 +300,8 @@ describe('war triggers', () => {
); );
cityUnit.setOppose(firstAttacker); cityUnit.setOppose(firstAttacker);
expect(cityUnit.addConflict()).toBe(false); expect(cityUnit.addConflict()).toBe(false);
expect(city.conflict).toEqual({ 1: 1.05 });
expect(city.meta.conflict).toBeUndefined();
const secondNation = { ...buildNation(), id: 2 }; const secondNation = { ...buildNation(), id: 2 };
const secondGeneral = { ...buildGeneral(80), id: 2, nationId: 2 }; const secondGeneral = { ...buildGeneral(80), id: 2, nationId: 2 };
@@ -316,6 +318,7 @@ describe('war triggers', () => {
); );
cityUnit.setOppose(secondAttacker); cityUnit.setOppose(secondAttacker);
expect(cityUnit.addConflict()).toBe(true); 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([ 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'],
['live sortie against a defending general', 'fixtures/turn-differential/live-sortie-defender.json'],
])( ])(
'%s matches command RNG and canonical state delta', '%s matches command RNG and canonical state delta',
async (_label, fixturePath) => { async (_label, fixturePath) => {