diff --git a/packages/logic/src/war/aftermath.ts b/packages/logic/src/war/aftermath.ts index fb0bc71..1e1e772 100644 --- a/packages/logic/src/war/aftermath.ts +++ b/packages/logic/src/war/aftermath.ts @@ -13,7 +13,16 @@ import type { WarAftermathTechContext, WarDiplomacyDelta, } 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 MAX_EXP_LEVEL = 255; @@ -124,15 +133,10 @@ const resolveConquerNation = (city: City, attackerNationId: number, nations: Nat return attackerNationId; } 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); + const entries = sortConflictEntries(conflict, readConflictOrder(city.meta)).filter( + ([key, value]) => + Number.isFinite(key) && typeof value === 'number' && (key === attackerNationId || activeNationIds.has(key)) + ); if (!entries.length) { return attackerNationId; } @@ -396,6 +400,7 @@ const resolveConquerCity = ( defenderCity.security = round(defenderCity.security * 0.7); defenderCity.nationId = conquerNationId; defenderCity.conflict = {}; + defenderCity.meta.conflict_order = []; if (defenderCity.level > 3) { defenderCity.defence = config.defaultCityWall; diff --git a/packages/logic/src/war/units/city.ts b/packages/logic/src/war/units/city.ts index 791dd27..b4af7c3 100644 --- a/packages/logic/src/war/units/city.ts +++ b/packages/logic/src/war/units/city.ts @@ -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 } from '../utils.js'; +import { clampMin, parseConflict, readConflictOrder, round, sortConflict, sortConflictEntries } from '../utils.js'; import { WarUnit } from './base.js'; // 도시 성벽 전투 유닛(legacy WarUnitCity 포팅). @@ -144,6 +144,12 @@ export class WarUnitCity extends WarUnit { let dead = Math.max(1, this.dead); 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) { dead *= 1.05; @@ -157,10 +163,13 @@ export class WarUnitCity extends WarUnit { conflict[nationId] += dead; } else { conflict[nationId] = dead; + conflictOrder.push(nationId); 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; return isNew; diff --git a/packages/logic/src/war/utils.ts b/packages/logic/src/war/utils.ts index 56a3345..23f8fbe 100644 --- a/packages/logic/src/war/utils.ts +++ b/packages/logic/src/war/utils.ts @@ -77,6 +77,31 @@ export const parseConflict = (raw: TriggerValue | undefined): Record): 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, + preferredOrder: number[] = [] +): Array => { + 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 | null): string => { if (!conflict) { return '{}'; @@ -94,14 +119,12 @@ export const stringifyConflict = (conflict: Record | null): stri return JSON.stringify(ordered); }; -export const sortConflict = (conflict: Record): Record => { +export const sortConflict = ( + conflict: Record, + preferredOrder: number[] = [] +): Record => { const ordered: Record = {}; - const entries = Object.entries(conflict) - .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) { + for (const [key, value] of sortConflictEntries(conflict, preferredOrder)) { ordered[key] = value; } diff --git a/packages/logic/test/warAftermath.test.ts b/packages/logic/test/warAftermath.test.ts index 0c299d0..5336f3b 100644 --- a/packages/logic/test/warAftermath.test.ts +++ b/packages/logic/test/warAftermath.test.ts @@ -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); + }); }); diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index 9d183ac..a77be2e 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -301,6 +301,7 @@ describe('war triggers', () => { cityUnit.setOppose(firstAttacker); expect(cityUnit.addConflict()).toBe(false); expect(city.conflict).toEqual({ 1: 1.05 }); + expect(city.meta.conflict_order).toEqual([1]); expect(city.meta.conflict).toBeUndefined(); const secondNation = { ...buildNation(), id: 2 }; @@ -319,6 +320,7 @@ describe('war triggers', () => { cityUnit.setOppose(secondAttacker); expect(cityUnit.addConflict()).toBe(true); expect(city.conflict).toEqual({ 1: 1.05, 2: 1 }); + expect(city.meta.conflict_order).toEqual([1, 2]); }); }); diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 1cdd71a..9e2ef2e 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -384,6 +384,7 @@ const buildWorldInput = ( } } 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 ? new Set(request.setup.randomFoundingCandidateCityIds) : null; @@ -435,6 +436,19 @@ const buildWorldInput = ( nations, cities: map.cities.map((definition) => { 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 { id: definition.id, name: readString(row, 'name', definition.name), @@ -461,12 +475,15 @@ const buildWorldInput = ( defenceMax: readNumber(row, 'defenceMax', definition.max.defence), wall: readNumber(row, 'wall', definition.initial.wall), wallMax: readNumber(row, 'wallMax', definition.max.wall), - conflict: asNumberRecord(row.conflict), + conflict, meta: { trust: readNumber(row, 'trust', map.defaults?.trust ?? 50), trade: readNumber(row, 'trade', map.defaults?.trade ?? 100), term: readNumber(row, 'term'), officer_set: readNumber(row, 'officerSet'), + ...(conflictEntries.length > 0 + ? { conflict_order: conflictEntries.map(([nationId]) => nationId) } + : {}), }, }; }), diff --git a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts index e3efbb2..e1f59a8 100644 --- a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts +++ b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts @@ -85,6 +85,7 @@ integration('core ↔ legacy command-boundary differential', () => { ['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 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', async (_label, fixturePath) => { @@ -146,6 +147,13 @@ integration('core ↔ legacy command-boundary differential', () => { ); 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({ requestedAction: request.action,