fix tied conflict conquest order
This commit is contained in:
@@ -13,7 +13,16 @@ import type {
|
|||||||
WarAftermathTechContext,
|
WarAftermathTechContext,
|
||||||
WarDiplomacyDelta,
|
WarDiplomacyDelta,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { clamp, clampMin, getMetaNumber, parseConflict, round, simpleSerialize } from './utils.js';
|
import {
|
||||||
|
clamp,
|
||||||
|
clampMin,
|
||||||
|
getMetaNumber,
|
||||||
|
parseConflict,
|
||||||
|
readConflictOrder,
|
||||||
|
round,
|
||||||
|
simpleSerialize,
|
||||||
|
sortConflictEntries,
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
const META_DEAD = 'dead';
|
const META_DEAD = 'dead';
|
||||||
const MAX_EXP_LEVEL = 255;
|
const MAX_EXP_LEVEL = 255;
|
||||||
@@ -124,15 +133,10 @@ const resolveConquerNation = (city: City, attackerNationId: number, nations: Nat
|
|||||||
return attackerNationId;
|
return attackerNationId;
|
||||||
}
|
}
|
||||||
const activeNationIds = new Set(nations.map((nation) => nation.id));
|
const activeNationIds = new Set(nations.map((nation) => nation.id));
|
||||||
const entries = Object.entries(conflict)
|
const entries = sortConflictEntries(conflict, readConflictOrder(city.meta)).filter(
|
||||||
.map(([key, value]) => [Number(key), value] as const)
|
([key, value]) =>
|
||||||
.filter(
|
Number.isFinite(key) && typeof value === 'number' && (key === attackerNationId || activeNationIds.has(key))
|
||||||
([key, value]) =>
|
);
|
||||||
Number.isFinite(key) &&
|
|
||||||
typeof value === 'number' &&
|
|
||||||
(key === attackerNationId || activeNationIds.has(key))
|
|
||||||
)
|
|
||||||
.sort(([, lhs], [, rhs]) => rhs - lhs);
|
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
return attackerNationId;
|
return attackerNationId;
|
||||||
}
|
}
|
||||||
@@ -396,6 +400,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
defenderCity.security = round(defenderCity.security * 0.7);
|
defenderCity.security = round(defenderCity.security * 0.7);
|
||||||
defenderCity.nationId = conquerNationId;
|
defenderCity.nationId = conquerNationId;
|
||||||
defenderCity.conflict = {};
|
defenderCity.conflict = {};
|
||||||
|
defenderCity.meta.conflict_order = [];
|
||||||
|
|
||||||
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 } from '../utils.js';
|
import { clampMin, parseConflict, readConflictOrder, round, sortConflict, sortConflictEntries } from '../utils.js';
|
||||||
import { WarUnit } from './base.js';
|
import { WarUnit } from './base.js';
|
||||||
|
|
||||||
// 도시 성벽 전투 유닛(legacy WarUnitCity 포팅).
|
// 도시 성벽 전투 유닛(legacy WarUnitCity 포팅).
|
||||||
@@ -144,6 +144,12 @@ export class WarUnitCity extends WarUnit {
|
|||||||
|
|
||||||
let dead = Math.max(1, this.dead);
|
let dead = Math.max(1, this.dead);
|
||||||
let isNew = false;
|
let isNew = false;
|
||||||
|
const conflictOrder = readConflictOrder(this.city.meta).filter((id) => conflict[id] !== undefined);
|
||||||
|
for (const id of Object.keys(conflict).map(Number)) {
|
||||||
|
if (!conflictOrder.includes(id)) {
|
||||||
|
conflictOrder.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(conflict).length === 0 || this.getHP() === 0) {
|
if (Object.keys(conflict).length === 0 || this.getHP() === 0) {
|
||||||
dead *= 1.05;
|
dead *= 1.05;
|
||||||
@@ -157,10 +163,13 @@ export class WarUnitCity extends WarUnit {
|
|||||||
conflict[nationId] += dead;
|
conflict[nationId] += dead;
|
||||||
} else {
|
} else {
|
||||||
conflict[nationId] = dead;
|
conflict[nationId] = dead;
|
||||||
|
conflictOrder.push(nationId);
|
||||||
isNew = true;
|
isNew = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = sortConflict(conflict);
|
const sortedEntries = sortConflictEntries(conflict, conflictOrder);
|
||||||
|
this.city.meta.conflict_order = sortedEntries.map(([id]) => id);
|
||||||
|
const sorted = sortConflict(conflict, conflictOrder);
|
||||||
this.city.conflict = sorted;
|
this.city.conflict = sorted;
|
||||||
|
|
||||||
return isNew;
|
return isNew;
|
||||||
|
|||||||
@@ -77,6 +77,31 @@ export const parseConflict = (raw: TriggerValue | undefined): Record<number, num
|
|||||||
return Object.keys(result).length ? result : null;
|
return Object.keys(result).length ? result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const readConflictOrder = (meta: Record<string, TriggerValue>): number[] => {
|
||||||
|
const raw = meta.conflict_order;
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return raw.filter((value): value is number => typeof value === 'number' && Number.isInteger(value) && value > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortConflictEntries = (
|
||||||
|
conflict: Record<number, number>,
|
||||||
|
preferredOrder: number[] = []
|
||||||
|
): Array<readonly [number, number]> => {
|
||||||
|
const orderIndex = new Map(preferredOrder.map((nationId, index) => [nationId, index]));
|
||||||
|
return Object.entries(conflict)
|
||||||
|
.map(([key, value], index) => [Number(key), value, index] as const)
|
||||||
|
.filter(([key, value]) => Number.isFinite(key) && typeof value === 'number')
|
||||||
|
.sort(
|
||||||
|
([lhsKey, lhsValue, lhsIndex], [rhsKey, rhsValue, rhsIndex]) =>
|
||||||
|
rhsValue - lhsValue ||
|
||||||
|
(orderIndex.get(lhsKey) ?? preferredOrder.length + lhsIndex) -
|
||||||
|
(orderIndex.get(rhsKey) ?? preferredOrder.length + rhsIndex)
|
||||||
|
)
|
||||||
|
.map(([key, value]) => [key, value] as const);
|
||||||
|
};
|
||||||
|
|
||||||
export const stringifyConflict = (conflict: Record<number, number> | null): string => {
|
export const stringifyConflict = (conflict: Record<number, number> | null): string => {
|
||||||
if (!conflict) {
|
if (!conflict) {
|
||||||
return '{}';
|
return '{}';
|
||||||
@@ -94,14 +119,12 @@ export const stringifyConflict = (conflict: Record<number, number> | null): stri
|
|||||||
return JSON.stringify(ordered);
|
return JSON.stringify(ordered);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sortConflict = (conflict: Record<number, number>): Record<number, number> => {
|
export const sortConflict = (
|
||||||
|
conflict: Record<number, number>,
|
||||||
|
preferredOrder: number[] = []
|
||||||
|
): Record<number, number> => {
|
||||||
const ordered: Record<number, number> = {};
|
const ordered: Record<number, number> = {};
|
||||||
const entries = Object.entries(conflict)
|
for (const [key, value] of sortConflictEntries(conflict, preferredOrder)) {
|
||||||
.map(([key, value]) => [Number(key), value] as const)
|
|
||||||
.filter(([key, value]) => Number.isFinite(key) && typeof value === 'number')
|
|
||||||
.sort(([, lhs], [, rhs]) => rhs - lhs);
|
|
||||||
|
|
||||||
for (const [key, value] of entries) {
|
|
||||||
ordered[key] = value;
|
ordered[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,4 +304,48 @@ describe('war aftermath', () => {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves the first contributor when conflict values are tied', () => {
|
||||||
|
const attackerNation = buildNation(1);
|
||||||
|
const defenderNation = buildNation(2);
|
||||||
|
defenderNation.capitalCityId = 5;
|
||||||
|
const laterNation = buildNation(3);
|
||||||
|
const firstNation = buildNation(4);
|
||||||
|
const attackerCity = buildCity(1, 1);
|
||||||
|
const defenderCity = buildCity(2, 2);
|
||||||
|
defenderCity.conflict = { 3: 100, 4: 100 };
|
||||||
|
defenderCity.meta.conflict_order = [4, 3];
|
||||||
|
const defenderCapital = buildCity(5, 2);
|
||||||
|
const attacker = buildGeneral(1, 1, 1);
|
||||||
|
|
||||||
|
const outcome = resolveWarAftermath({
|
||||||
|
battle: {
|
||||||
|
attacker,
|
||||||
|
defenders: [],
|
||||||
|
defenderCity,
|
||||||
|
logs: [],
|
||||||
|
conquered: true,
|
||||||
|
reports: [],
|
||||||
|
},
|
||||||
|
attackerNation,
|
||||||
|
defenderNation,
|
||||||
|
attackerCity,
|
||||||
|
defenderCity,
|
||||||
|
nations: [attackerNation, defenderNation, laterNation, firstNation],
|
||||||
|
cities: [attackerCity, defenderCity, defenderCapital],
|
||||||
|
generals: [attacker],
|
||||||
|
unitSet: buildUnitSet(),
|
||||||
|
config: buildConfig(),
|
||||||
|
time: {
|
||||||
|
year: 200,
|
||||||
|
month: 1,
|
||||||
|
startYear: 180,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcome.conquest?.conquerNationId).toBe(4);
|
||||||
|
expect(defenderCity.nationId).toBe(4);
|
||||||
|
expect(defenderCity.meta.conflict_order).toEqual([]);
|
||||||
|
expect(attacker.cityId).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ 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.conflict).toEqual({ 1: 1.05 });
|
||||||
|
expect(city.meta.conflict_order).toEqual([1]);
|
||||||
expect(city.meta.conflict).toBeUndefined();
|
expect(city.meta.conflict).toBeUndefined();
|
||||||
|
|
||||||
const secondNation = { ...buildNation(), id: 2 };
|
const secondNation = { ...buildNation(), id: 2 };
|
||||||
@@ -319,6 +320,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 });
|
expect(city.conflict).toEqual({ 1: 1.05, 2: 1 });
|
||||||
|
expect(city.meta.conflict_order).toEqual([1, 2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ const buildWorldInput = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const observedCityRows = new Map(referenceBefore.cities.map((row) => [readNumber(row, 'id'), row] as const));
|
const observedCityRows = new Map(referenceBefore.cities.map((row) => [readNumber(row, 'id'), row] as const));
|
||||||
|
const fixtureCityRows = new Map((request.setup?.cities ?? []).map((row) => [readNumber(row, 'id'), row] as const));
|
||||||
const randomFoundingCandidateCityIds = request.setup?.randomFoundingCandidateCityIds
|
const randomFoundingCandidateCityIds = request.setup?.randomFoundingCandidateCityIds
|
||||||
? new Set(request.setup.randomFoundingCandidateCityIds)
|
? new Set(request.setup.randomFoundingCandidateCityIds)
|
||||||
: null;
|
: null;
|
||||||
@@ -435,6 +436,19 @@ const buildWorldInput = (
|
|||||||
nations,
|
nations,
|
||||||
cities: map.cities.map((definition) => {
|
cities: map.cities.map((definition) => {
|
||||||
const row = observedCityRows.get(definition.id) ?? {};
|
const row = observedCityRows.get(definition.id) ?? {};
|
||||||
|
const fixtureRow = fixtureCityRows.get(definition.id) ?? {};
|
||||||
|
const conflictEntries = Array.isArray(fixtureRow.conflictEntries)
|
||||||
|
? fixtureRow.conflictEntries.filter(
|
||||||
|
(entry): entry is [number, number] =>
|
||||||
|
Array.isArray(entry) &&
|
||||||
|
entry.length === 2 &&
|
||||||
|
typeof entry[0] === 'number' &&
|
||||||
|
Number.isInteger(entry[0]) &&
|
||||||
|
typeof entry[1] === 'number'
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const conflict =
|
||||||
|
conflictEntries.length > 0 ? Object.fromEntries(conflictEntries) : asNumberRecord(row.conflict);
|
||||||
return {
|
return {
|
||||||
id: definition.id,
|
id: definition.id,
|
||||||
name: readString(row, 'name', definition.name),
|
name: readString(row, 'name', definition.name),
|
||||||
@@ -461,12 +475,15 @@ const buildWorldInput = (
|
|||||||
defenceMax: readNumber(row, 'defenceMax', definition.max.defence),
|
defenceMax: readNumber(row, 'defenceMax', definition.max.defence),
|
||||||
wall: readNumber(row, 'wall', definition.initial.wall),
|
wall: readNumber(row, 'wall', definition.initial.wall),
|
||||||
wallMax: readNumber(row, 'wallMax', definition.max.wall),
|
wallMax: readNumber(row, 'wallMax', definition.max.wall),
|
||||||
conflict: asNumberRecord(row.conflict),
|
conflict,
|
||||||
meta: {
|
meta: {
|
||||||
trust: readNumber(row, 'trust', map.defaults?.trust ?? 50),
|
trust: readNumber(row, 'trust', map.defaults?.trust ?? 50),
|
||||||
trade: readNumber(row, 'trade', map.defaults?.trade ?? 100),
|
trade: readNumber(row, 'trade', map.defaults?.trade ?? 100),
|
||||||
term: readNumber(row, 'term'),
|
term: readNumber(row, 'term'),
|
||||||
officer_set: readNumber(row, 'officerSet'),
|
officer_set: readNumber(row, 'officerSet'),
|
||||||
|
...(conflictEntries.length > 0
|
||||||
|
? { conflict_order: conflictEntries.map(([nationId]) => nationId) }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ integration('core ↔ legacy command-boundary differential', () => {
|
|||||||
['live sortie noncapital conquest', 'fixtures/turn-differential/live-sortie-noncapital-conquest.json'],
|
['live sortie noncapital conquest', 'fixtures/turn-differential/live-sortie-noncapital-conquest.json'],
|
||||||
['live sortie emergency capital', 'fixtures/turn-differential/live-sortie-emergency-capital.json'],
|
['live sortie emergency capital', 'fixtures/turn-differential/live-sortie-emergency-capital.json'],
|
||||||
['live sortie conflict arbitration', 'fixtures/turn-differential/live-sortie-conflict-arbitration.json'],
|
['live sortie conflict arbitration', 'fixtures/turn-differential/live-sortie-conflict-arbitration.json'],
|
||||||
|
['live sortie tied conflict', 'fixtures/turn-differential/live-sortie-conflict-tie.json'],
|
||||||
])(
|
])(
|
||||||
'%s matches command RNG and canonical state delta',
|
'%s matches command RNG and canonical state delta',
|
||||||
async (_label, fixturePath) => {
|
async (_label, fixturePath) => {
|
||||||
@@ -146,6 +147,13 @@ integration('core ↔ legacy command-boundary differential', () => {
|
|||||||
);
|
);
|
||||||
expect(arbitrationLogs.some((log) => String(log.text).includes('【분쟁협상】'))).toBe(true);
|
expect(arbitrationLogs.some((log) => String(log.text).includes('【분쟁협상】'))).toBe(true);
|
||||||
}
|
}
|
||||||
|
if (fixturePath.endsWith('live-sortie-conflict-tie.json')) {
|
||||||
|
expect(reference.after.cities.find((city) => city.id === 70)).toMatchObject({
|
||||||
|
nationId: 4,
|
||||||
|
conflict: {},
|
||||||
|
});
|
||||||
|
expect(reference.after.generals.find((general) => general.id === 1)?.cityId).toBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
expect(core.execution.outcome).toMatchObject({
|
expect(core.execution.outcome).toMatchObject({
|
||||||
requestedAction: request.action,
|
requestedAction: request.action,
|
||||||
|
|||||||
Reference in New Issue
Block a user