From 91b2980e89e842147656d823b1c7b0efb547dee6 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 3 Sep 2026 02:48:31 +0000 Subject: [PATCH] =?UTF-8?q?=EB=B3=B4=EA=B8=89=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=20=EA=B3=84=EC=95=BD=EC=9D=84=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 출병 점령 그 순간에는 보급이 즉시 끊기지 않고(che_출병/aftermath는 점령 도시와 긴급천도 신수도에만 supply=1을 쓴다), 차단(切断)은 월 경계의 UpdateCitySupply(pre_month)에서 일어난다는 Ref 계승 계약을 테스트로 고정한다. 월 경계 연감 스냅샷은 Ref #230(d17b2518) 이후 UpdateCitySupply 뒤에 찍히므로 끝난 달의 연감 지도에는 이미 고립이 반영된다. 10월중 점령 -> 11월 경계 차단 -> 10월 연감 반영 순서를 검증한다. --- .../test/supplyCutMonthBoundaryOrder.test.ts | 208 +++++++++++++++ .../logic/test/supplyCutAtConquest.test.ts | 246 ++++++++++++++++++ 2 files changed, 454 insertions(+) create mode 100644 app/game-engine/test/supplyCutMonthBoundaryOrder.test.ts create mode 100644 packages/logic/test/supplyCutAtConquest.test.ts diff --git a/app/game-engine/test/supplyCutMonthBoundaryOrder.test.ts b/app/game-engine/test/supplyCutMonthBoundaryOrder.test.ts new file mode 100644 index 00000000..692fb956 --- /dev/null +++ b/app/game-engine/test/supplyCutMonthBoundaryOrder.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest'; + +import type { City, MapDefinition, Nation } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js'; +import { createUpdateCitySupplyHandler } from '../src/turn/monthlyCitySupplyAction.js'; +import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js'; +import { createYearbookHandler } from '../src/turn/yearbookHandler.js'; +import type { PendingYearbookSnapshot, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +// Ref 계승 계약 (devel TurnExecutionHelper.php 실행 순서): +// runEventHandler(PreMonth) -> UpdateCitySupply (보급 재계산/고립) +// preUpdateMonthly() -> LogHistory (방금 끝난 달의 연감 스냅샷) +// turnDate() -> 월 advance +// 즉 월중 출병 점령은 보급을 즉시 끊지 않고(che_출병/aftermath는 점령 도시와 +// 천도 신수도에만 supply=1을 쓴다), 보급切断은 다음 달 시작 경계에서 일어난다. +// 단 경계 스냅샷은 Ref #230(d17b2518) 이후 UpdateCitySupply "뒤"에 찍히므로 +// 끝난 달(10월)의 연감 지도에는 이미 끊긴 보급선이 반영된다. Core의 +// composeCalendarHandlers 순서(monthlyEventHandler -> yearbookHandler)가 이 +// 계약을 보존하는지를 고정한다. + +const map: MapDefinition = { + id: 'supply-cut-order-test', + name: 'supply-cut-order-test', + cities: [ + { + id: 10, + name: '공격기지', + level: 2, + region: 1, + position: { x: 0, y: 0 }, + connections: [20], + max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 }, + initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 }, + }, + { + id: 20, + name: '중간 보급로', + level: 2, + region: 1, + position: { x: 1, y: 0 }, + connections: [10, 21, 22], + max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 }, + initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 }, + }, + { + id: 21, + name: '수도', + level: 2, + region: 1, + position: { x: 2, y: 0 }, + connections: [20], + max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 }, + initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 }, + }, + { + id: 22, + name: '외곽 도시', + level: 2, + region: 1, + position: { x: 3, y: 0 }, + connections: [20], + max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 }, + initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 }, + }, + ], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +const buildCity = (id: number, nationId: number): City => ({ + id, + name: map.cities.find((city) => city.id === id)?.name ?? `도시${id}`, + nationId, + level: 2, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + conflict: {}, + meta: { trust: 50, trade: 100, officer_set: 0, term: 0 }, +}); + +const buildNation = (id: number, capitalCityId: number): Nation => ({ + id, + name: `국가${id}`, + color: '#000000', + capitalCityId, + chiefGeneralId: null, + gold: 1_000, + rice: 1_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: {}, +}); + +type MapCityCompact = [number, number, number, number, number, number]; + +const buildWorld = (): { world: InMemoryTurnWorld; snapshots: () => PendingYearbookSnapshot[] } => { + const state: TurnWorldState = { + id: 1, + currentYear: 191, + currentMonth: 10, + tickSeconds: 600, + lastTurnTime: new Date('0191-10-01T00:00:00.000Z'), + meta: {}, + }; + const actions = new Map(); + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: map.id, unitSet: 'default' }, + }, + map, + generals: [], + cities: [buildCity(10, 2), buildCity(20, 1), buildCity(21, 1), buildCity(22, 1)], + nations: [buildNation(1, 21), buildNation(2, 10)], + troops: [], + diplomacy: [], + events: [ + { + id: 1, + targetCode: 'pre_month', + priority: 9_000, + condition: true, + action: [['UpdateCitySupply']], + meta: {}, + }, + ], + initialEvents: [], + }; + let world: InMemoryTurnWorld | null = null; + // 프로덕션 turnDaemon과 같은 구성 순서: monthly event(pre_month) -> yearbook. + const calendarHandler = composeCalendarHandlers( + createMonthlyEventHandler({ getWorld: () => world, startYear: 180, actions }), + createYearbookHandler({ profileName: 'test', getWorld: () => world }).handler + ); + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler, + }); + actions.set('UpdateCitySupply', createUpdateCitySupplyHandler({ getWorld: () => world!, map })); + return { + world: world!, + snapshots: () => world!.peekDirtyState().pendingYearbookSnapshots, + }; +}; + +describe('10월중 보급로 도시 점령 -> 11월 경계切断 -> 10월 연감 스냅샷 순서', () => { + it('keeps supply alive during October and cuts it exactly at the month boundary', async () => { + const { world } = buildWorld(); + + // che_출병의 aftermath effect가 반영된 10월중 상태: 도시 20이 국가2에 + // 점령되고 Ref처럼 supplyState=1만 쓴다. 나머지 도시는 무변경. + world.updateCity(20, { nationId: 2, supplyState: 1 }); + + // 10월 진행중에는 고립될 외곽 도시도 아직 보급 상태다(라이브 지도 기준). + expect(world.getCityById(22)?.supplyState).toBe(1); + expect(world.getCityById(21)?.supplyState).toBe(1); + + await world.advanceMonth(new Date('0191-11-01T00:00:00.000Z')); + + //切断은 정확히 월 경계에서 일어난다. + expect(world.getCityById(22)?.supplyState).toBe(0); + expect(world.getCityById(21)?.supplyState).toBe(1); + expect(world.getCityById(20)?.supplyState).toBe(1); + expect(world.getCityById(10)?.supplyState).toBe(1); + }); + + it('archives the finished month yearbook map after the boundary supply recalculation (Ref #230 order)', async () => { + const { world, snapshots } = buildWorld(); + world.updateCity(20, { nationId: 2, supplyState: 1 }); + + expect(snapshots()).toEqual([]); + + await world.advanceMonth(new Date('0191-11-01T00:00:00.000Z')); + + const pending = snapshots(); + expect(pending).toHaveLength(1); + const october = pending[0]!; + expect(october.year).toBe(191); + expect(october.month).toBe(10); + + const cityList = (october.map as { cityList: MapCityCompact[] }).cityList; + const supplyFlagById = new Map(cityList.map((entry) => [entry[0], entry[5]])); + // Ref #230 이후 LogHistory는 UpdateCitySupply 뒤에 실행되므로 끝난 10월의 + // 연감 지도는 이미 고립을 반영한다. 이 순서가 Core의 계약이다. + expect(supplyFlagById.get(22)).toBe(0); + expect(supplyFlagById.get(21)).toBe(1); + expect(supplyFlagById.get(20)).toBe(1); + expect(supplyFlagById.get(10)).toBe(1); + }); +}); diff --git a/packages/logic/test/supplyCutAtConquest.test.ts b/packages/logic/test/supplyCutAtConquest.test.ts new file mode 100644 index 00000000..c1cc1a85 --- /dev/null +++ b/packages/logic/test/supplyCutAtConquest.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest'; + +import type { City, General, Nation } from '../src/domain/entities.js'; +import { resolveGeneralAction } from '../src/actions/engine.js'; +import { ActionDefinition } from '../src/actions/turn/general/che_출병.js'; +import type { DispatchResolveContext } from '../src/actions/turn/general/che_출병.js'; +import type { TurnSchedule } from '../src/turn/calendar.js'; +import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js'; +import type { MapDefinition, UnitSetDefinition } from '../src/world/types.js'; + +// Ref 계승 계약: city.supply 재계산은 월 경계의 UpdateCitySupply(pre_month)에서만 +// 일어나고, 출병 점령 순간에는 점령 도시와 긴급천도 신수도에만 supply=1을 쓴다. +// (ref TurnExecutionHelper.php PreMonth→preUpdateMonthly 순서, process_war.php의 +// 'supply' => 1 쓰기 두 곳) 월중에는 다른 도시의 supply가 절대 0으로 바뀌면 안 된다. + +const buildGeneral = (id: number, nationId: number, cityId: number): General => ({ + id, + name: `General${id}`, + nationId, + cityId, + troopId: 0, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + experience: 100, + dedication: 100, + officerLevel: 3, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1000, + rice: 2000, + crew: 1500, + crewTypeId: 100, + train: 80, + atmos: 80, + age: 25, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, +}); + +const buildCity = (id: number, nationId: number): City => ({ + id, + name: `City${id}`, + nationId, + level: 2, + state: 0, + population: 10000, + populationMax: 10000, + agriculture: 1000, + agricultureMax: 1000, + commerce: 1000, + commerceMax: 1000, + security: 1000, + securityMax: 1000, + supplyState: 1, + frontState: 0, + defence: 200, + defenceMax: 400, + wall: 200, + wallMax: 400, + meta: {}, +}); + +const buildNation = (id: number, capitalCityId: number): Nation => ({ + id, + name: `Nation${id}`, + color: '#000000', + capitalCityId, + chiefGeneralId: null, + gold: 5000, + rice: 5000, + power: 0, + level: 1, + typeCode: 'test', + meta: { tech: 1000 }, +}); + +const unitSet: UnitSetDefinition = { + id: 'test', + name: 'test', + defaultCrewTypeId: 100, + crewTypes: [ + { + id: 100, + armType: 1, + name: 'Infantry', + attack: 10, + defence: 10, + speed: 3, + avoid: 5, + magicCoef: 0, + cost: 0, + rice: 1, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + { + id: 999, + armType: 5, + name: 'Castle', + attack: 0, + defence: 0, + speed: 1, + avoid: 0, + magicCoef: 0, + cost: 0, + rice: 10, + requirements: [{ type: 'Impossible' }], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + ], +}; + +const warConfig: WarEngineConfig = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 999, + armTypes: { + footman: 1, + archer: 2, + cavalry: 3, + wizard: 4, + siege: 5, + misc: 6, + castle: 5, + }, +}; + +const aftermathConfig: WarAftermathConfig = { + initialNationGenLimit: 1, + techLevelIncYear: 5, + initialAllowedTechLevel: 1, + maxTechLevel: 12, + defaultCityWall: 1000, + baseGold: 0, + baseRice: 0, + castleCrewTypeId: 999, +}; + +const rng = { + nextFloat1: () => 0.1, + nextBool: (probability: number) => probability >= 0.1, + nextInt: (minInclusive: number, _maxExclusive: number) => minInclusive, +}; + +const schedule: TurnSchedule = { + entries: [{ startMinute: 0, tickMinutes: 60 }], +}; + +// 수도 21 - 중간도시 20 - 외곽도시 22 사슬에서 20만 국가1 영토였고, +// 공격자 국가2의 도시 10이 20과 인접한다. 20을 먹히면 22는 월 경계 이후에 +// 고립되지만, 점령 그 순간에는 아니다. +const mapCities: MapDefinition['cities'] = [ + { id: 10, name: 'AggBase', level: 2, region: 1, position: { x: 0, y: 0 }, connections: [20] }, + { id: 20, name: 'Mid', level: 2, region: 1, position: { x: 1, y: 0 }, connections: [10, 21, 22] }, + { id: 21, name: 'Cap', level: 2, region: 1, position: { x: 2, y: 0 }, connections: [20] }, + { id: 22, name: 'Far', level: 2, region: 1, position: { x: 3, y: 0 }, connections: [20] }, +].map((city) => ({ + ...city, + max: { population: 10000, agriculture: 1000, commerce: 1000, security: 1000, defence: 400, wall: 400 }, + initial: { population: 10000, agriculture: 1000, commerce: 1000, security: 1000, defence: 200, wall: 200 }, +})); + +describe('출병 점령과 보급切断 시점', () => { + it('does not cut any other city supply in the same turn the route city is conquered', () => { + const defenderNation = buildNation(1, 21); + const attackerNation = buildNation(2, 10); + const aggBase = buildCity(10, attackerNation.id); + const midCity = buildCity(20, defenderNation.id); + const capCity = buildCity(21, defenderNation.id); + const farCity = buildCity(22, defenderNation.id); + midCity.defence = 0; + midCity.wall = 0; + const attacker = buildGeneral(1, attackerNation.id, aggBase.id); + attacker.turnTime = new Date('2000-01-01T00:00:00Z'); + + const context: Omit = { + general: attacker, + city: aggBase, + nation: attackerNation, + rng, + destCity: midCity, + destNation: defenderNation, + cities: [aggBase, midCity, capCity, farCity], + nations: [attackerNation, defenderNation], + generals: [attacker], + unitSet, + map: { id: 'supply-chain', name: 'supply-chain', cities: mapCities }, + diplomacy: [ + { fromNationId: attackerNation.id, toNationId: defenderNation.id, state: 0, term: 0 }, + { fromNationId: defenderNation.id, toNationId: attackerNation.id, state: 0, term: 0 }, + ], + time: { year: 191, month: 10, startYear: 180 }, + seedBase: 'supply-cut-seed', + warConfig, + aftermathConfig, + messageTime: new Date('2000-01-01T00:00:00.000Z'), + }; + + const resolution = resolveGeneralAction( + new ActionDefinition(), + context, + { now: new Date('2000-01-01T00:00:00Z'), schedule }, + { destCityId: midCity.id } + ); + + // 점령 자체가 일어나야 테스트 의미가 있다. + expect(resolution.completed).toBe(true); + + const cityPatches = resolution.patches?.cities ?? []; + + // 점령 도시는 소유권 이동 + Ref의 'supply' => 1 재설정만 받는다. + const midPatch = cityPatches.find((patch) => patch.id === midCity.id); + expect(midPatch?.patch.nationId).toBe(attackerNation.id); + expect(midPatch?.patch.supplyState).toBe(1); + + // 월중에는 그 어떤 도시도 supply=0으로 패치되지 않는다. + // (Ref: process_war.php는 점령 도시와 천도 신수도에만 supply=1을 쓴다.) + expect( + cityPatches.filter((patch) => 'supplyState' in patch.patch && patch.patch.supplyState === 0) + ).toEqual([]); + + // 고립될 외곽도시와 수도는 이 명령 처리 중에 손대지 않는다. + expect(farCity.supplyState).toBe(1); + expect(capCity.supplyState).toBe(1); + expect(cityPatches.some((patch) => patch.id === farCity.id && 'supplyState' in patch.patch)).toBe(false); + expect(cityPatches.some((patch) => patch.id === capCity.id && 'supplyState' in patch.patch)).toBe(false); + }); +});