diff --git a/app/game-engine/test/monthlyDiplomacyHandler.test.ts b/app/game-engine/test/monthlyDiplomacyHandler.test.ts index 4f6ef91c..2ccb30e5 100644 --- a/app/game-engine/test/monthlyDiplomacyHandler.test.ts +++ b/app/game-engine/test/monthlyDiplomacyHandler.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { LogCategory, LogFormat, LogScope, type Nation } from '@sammo-ts/logic'; +import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope, type Nation } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { createMonthlyDiplomacyHandler } from '../src/turn/monthlyNationStatsHandler.js'; @@ -27,10 +27,12 @@ const diplomacy: TurnDiplomacy[] = [ { fromNationId: 3, toNationId: 4, state: 0, term: 1, dead: 0, meta: {} }, { fromNationId: 4, toNationId: 3, state: 0, term: 1, dead: 0, meta: {} }, { fromNationId: 2, toNationId: 4, state: 7, term: 1, dead: 999, meta: {} }, + { fromNationId: 5, toNationId: 6, state: 0, term: 1, dead: 0, meta: {} }, + { fromNationId: 6, toNationId: 5, state: 0, term: 3, dead: 0, meta: {} }, ]; describe('monthly diplomacy post-update', () => { - it('matches legacy casualty terms, state transitions, and global log order', async () => { + it('shares war terms across both directions and emits each transition log once', async () => { const state: TurnWorldState = { id: 1, currentYear: 193, @@ -58,6 +60,8 @@ describe('monthly diplomacy post-update', () => { buildNation(2, '을국', 1), buildNation(3, '병국', 1), buildNation(4, '정국', 1), + buildNation(5, '무국', 1), + buildNation(6, '기국', 1), ], troops: [], }; @@ -77,10 +81,12 @@ describe('monthly diplomacy post-update', () => { { fromNationId: 1, toNationId: 2, state: 0, term: 6, dead: 0, meta: {} }, { fromNationId: 2, toNationId: 1, state: 0, term: 6, dead: 0, meta: {} }, { fromNationId: 1, toNationId: 3, state: 0, term: 5, dead: 50, meta: {} }, - { fromNationId: 3, toNationId: 1, state: 0, term: 4, dead: 50, meta: {} }, + { fromNationId: 3, toNationId: 1, state: 0, term: 5, dead: 50, meta: {} }, { fromNationId: 3, toNationId: 4, state: 2, term: 0, dead: 0, meta: {} }, { fromNationId: 4, toNationId: 3, state: 2, term: 0, dead: 0, meta: {} }, { fromNationId: 2, toNationId: 4, state: 2, term: 0, dead: 0, meta: {} }, + { fromNationId: 5, toNationId: 6, state: 0, term: 2, dead: 0, meta: {} }, + { fromNationId: 6, toNationId: 5, state: 0, term: 2, dead: 0, meta: {} }, ]); expect(world.consumeDirtyState().logs).toEqual([ { @@ -96,5 +102,44 @@ describe('monthly diplomacy post-update', () => { format: LogFormat.YEAR_MONTH, }, ]); + + await world.advanceMonth(new Date('0193-03-01T00:00:00.000Z')); + expect( + world + .listDiplomacy() + .filter( + (entry) => + (entry.fromNationId === 5 && entry.toNationId === 6) || + (entry.fromNationId === 6 && entry.toNationId === 5) + ) + .map(({ state, term }) => ({ state, term })) + ).toEqual([ + { state: DIPLOMACY_STATE.WAR, term: 1 }, + { state: DIPLOMACY_STATE.WAR, term: 1 }, + ]); + expect(world.consumeDirtyState().logs).toEqual([]); + + await world.advanceMonth(new Date('0193-04-01T00:00:00.000Z')); + expect( + world + .listDiplomacy() + .filter( + (entry) => + (entry.fromNationId === 5 && entry.toNationId === 6) || + (entry.fromNationId === 6 && entry.toNationId === 5) + ) + .map(({ state, term }) => ({ state, term })) + ).toEqual([ + { state: DIPLOMACY_STATE.TRADE, term: 0 }, + { state: DIPLOMACY_STATE.TRADE, term: 0 }, + ]); + expect(world.consumeDirtyState().logs).toEqual([ + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '【종전】무국기국종전합니다.', + format: LogFormat.YEAR_MONTH, + }, + ]); }); }); diff --git a/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts b/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts index e3ecd0ba..c34dfd36 100644 --- a/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts @@ -13,7 +13,7 @@ const nationIds = [992_101, 992_102, 992_103, 992_104]; const scenarioCode = 'monthly-diplomacy-persistence'; const startLog = '●193년 2월:【개전】갑국을국전쟁을 시작합니다.'; -const stopLog = '●193년 2월:【종전】병국정국종전합니다.'; +const stopLog = '●193년 4월:【종전】병국정국종전합니다.'; const buildNation = (id: number, name: string, generalCount: number): Nation => ({ id, @@ -35,7 +35,7 @@ const diplomacy: TurnDiplomacy[] = [ { fromNationId: nationIds[0]!, toNationId: nationIds[2]!, state: 0, term: 5, dead: 250, meta: {} }, { fromNationId: nationIds[2]!, toNationId: nationIds[0]!, state: 0, term: 5, dead: 50, meta: {} }, { fromNationId: nationIds[2]!, toNationId: nationIds[3]!, state: 0, term: 1, dead: 0, meta: {} }, - { fromNationId: nationIds[3]!, toNationId: nationIds[2]!, state: 0, term: 1, dead: 0, meta: {} }, + { fromNationId: nationIds[3]!, toNationId: nationIds[2]!, state: 0, term: 3, dead: 0, meta: {} }, { fromNationId: nationIds[1]!, toNationId: nationIds[3]!, state: 7, term: 1, dead: 999, meta: {} }, ]; @@ -70,7 +70,7 @@ integration('monthly diplomacy persistence', () => { await closeDb?.(); }); - it('commits diplomacy transitions and exact global history text together', async () => { + it('commits a shared war countdown and the exact transition history together', async () => { const nations = [ buildNation(nationIds[0]!, '갑국', 2), buildNation(nationIds[1]!, '을국', 1), @@ -156,6 +156,22 @@ integration('monthly diplomacy persistence', () => { durationMs: 0, partial: false, }); + await world.advanceMonth(new Date('0193-03-01T00:00:00.000Z')); + await hooks.hooks.flushChanges?.({ + lastTurnTime: '0193-03-01T00:00:00.000Z', + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + await world.advanceMonth(new Date('0193-04-01T00:00:00.000Z')); + await hooks.hooks.flushChanges?.({ + lastTurnTime: '0193-04-01T00:00:00.000Z', + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); const rows = await db.diplomacy.findMany({ where: { @@ -178,11 +194,11 @@ integration('monthly diplomacy persistence', () => { dead: (row.meta as { dead?: number }).dead ?? 0, })) ).toEqual([ - { fromNationId: nationIds[0], toNationId: nationIds[1], state: 0, term: 6, dead: 0 }, - { fromNationId: nationIds[0], toNationId: nationIds[2], state: 0, term: 5, dead: 50 }, - { fromNationId: nationIds[1], toNationId: nationIds[0], state: 0, term: 6, dead: 0 }, + { fromNationId: nationIds[0], toNationId: nationIds[1], state: 0, term: 4, dead: 0 }, + { fromNationId: nationIds[0], toNationId: nationIds[2], state: 0, term: 3, dead: 50 }, + { fromNationId: nationIds[1], toNationId: nationIds[0], state: 0, term: 4, dead: 0 }, { fromNationId: nationIds[1], toNationId: nationIds[3], state: 2, term: 0, dead: 0 }, - { fromNationId: nationIds[2], toNationId: nationIds[0], state: 0, term: 4, dead: 50 }, + { fromNationId: nationIds[2], toNationId: nationIds[0], state: 0, term: 3, dead: 50 }, { fromNationId: nationIds[2], toNationId: nationIds[3], state: 2, term: 0, dead: 0 }, { fromNationId: nationIds[3], toNationId: nationIds[2], state: 2, term: 0, dead: 0 }, ]); @@ -194,8 +210,12 @@ integration('monthly diplomacy persistence', () => { }) ).toEqual([ { scope: 'SYSTEM', category: 'HISTORY', year: 193, month: 2, text: startLog }, - { scope: 'SYSTEM', category: 'HISTORY', year: 193, month: 2, text: stopLog }, + { scope: 'SYSTEM', category: 'HISTORY', year: 193, month: 4, text: stopLog }, ]); + expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({ + currentYear: 193, + currentMonth: 4, + }); } finally { await hooks.close(); } diff --git a/packages/logic/src/diplomacy/index.ts b/packages/logic/src/diplomacy/index.ts index 126efa09..668f91d6 100644 --- a/packages/logic/src/diplomacy/index.ts +++ b/packages/logic/src/diplomacy/index.ts @@ -92,10 +92,12 @@ export const processDiplomacyMonth = ( entry.term = clamp(entry.term + termIncrease, 0, MAX_WAR_TERM); } - // 전쟁 종료 판정: 양방 term이 1 이하이면 통상으로 전환. + // 전쟁은 양방향 diplomacy row로 저장되지만 기간은 하나의 관계가 소유한다. + // Ref처럼 방향별 사상자 연장을 먼저 계산하되, 더 긴 기간을 양쪽에 적용하여 + // 한쪽만 0개월 전쟁으로 남는 비대칭 상태를 다음 월까지 노출하지 않는다. const processedPairs = new Set(); for (const entry of next) { - if (entry.state !== DIPLOMACY_STATE.WAR || entry.term > 1) { + if (entry.state !== DIPLOMACY_STATE.WAR) { continue; } const pairKey = buildDiplomacyKey( @@ -106,12 +108,20 @@ export const processDiplomacyMonth = ( continue; } const opposite = byKey.get(buildDiplomacyKey(entry.toNationId, entry.fromNationId)); - if (opposite && opposite.state === DIPLOMACY_STATE.WAR && opposite.term <= 1) { + if (!opposite || opposite.state !== DIPLOMACY_STATE.WAR) { + continue; + } + + const sharedTerm = Math.max(entry.term, opposite.term); + entry.term = sharedTerm; + opposite.term = sharedTerm; + processedPairs.add(pairKey); + + if (sharedTerm <= 1) { entry.state = DIPLOMACY_STATE.TRADE; entry.term = 0; opposite.state = DIPLOMACY_STATE.TRADE; opposite.term = 0; - processedPairs.add(pairKey); } } diff --git a/packages/logic/test/diplomacy.test.ts b/packages/logic/test/diplomacy.test.ts index 0655ec52..a3745d3c 100644 --- a/packages/logic/test/diplomacy.test.ts +++ b/packages/logic/test/diplomacy.test.ts @@ -61,6 +61,32 @@ describe('diplomacy month processing', () => { } }); + it('keeps an asymmetric war on one shared countdown and ends it once', () => { + let entries = [buildEntry(1, 2, DIPLOMACY_STATE.WAR, 1), buildEntry(2, 1, DIPLOMACY_STATE.WAR, 3)]; + const generalCounts = new Map([ + [1, 1], + [2, 1], + ]); + + entries = processDiplomacyMonth(entries, generalCounts); + expect(entries.map(({ state, term }) => ({ state, term }))).toEqual([ + { state: DIPLOMACY_STATE.WAR, term: 2 }, + { state: DIPLOMACY_STATE.WAR, term: 2 }, + ]); + + entries = processDiplomacyMonth(entries, generalCounts); + expect(entries.map(({ state, term }) => ({ state, term }))).toEqual([ + { state: DIPLOMACY_STATE.WAR, term: 1 }, + { state: DIPLOMACY_STATE.WAR, term: 1 }, + ]); + + entries = processDiplomacyMonth(entries, generalCounts); + expect(entries.map(({ state, term }) => ({ state, term }))).toEqual([ + { state: DIPLOMACY_STATE.TRADE, term: 0 }, + { state: DIPLOMACY_STATE.TRADE, term: 0 }, + ]); + }); + it('extends war term based on accumulated casualties', () => { const entries = [buildEntry(1, 2, DIPLOMACY_STATE.WAR, 3, 400), buildEntry(2, 1, DIPLOMACY_STATE.WAR, 3, 0)]; const result = processDiplomacyMonth( @@ -75,7 +101,7 @@ describe('diplomacy month processing', () => { const reverse = result.find((entry) => entry.fromNationId === 2 && entry.toNationId === 1); expect(forward?.term).toBe(4); expect(forward?.dead).toBe(0); - expect(reverse?.term).toBe(2); + expect(reverse?.term).toBe(4); }); it('expires non-aggression pact into trade', () => {