fix war command differential parity
This commit is contained in:
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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></>를 정복',
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user