From cb86a47f945354985f8a85ce2e2d73f377959685 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 2 Jan 2026 17:48:44 +0000 Subject: [PATCH] feat: implement diplomacy system with state management and processing logic --- .../src/scenario/scenarioSeeder.ts | 43 ++++- app/game-engine/src/turn/databaseHooks.ts | 53 ++++++ app/game-engine/src/turn/diplomacy.ts | 162 ++++++++++++++++++ app/game-engine/src/turn/inMemoryWorld.ts | 139 ++++++++++++++- .../src/turn/reservedTurnHandler.ts | 37 ++-- app/game-engine/src/turn/turnDaemon.ts | 1 - app/game-engine/src/turn/types.ts | 14 +- app/game-engine/src/turn/worldLoader.ts | 21 ++- app/game-engine/test/diplomacy.test.ts | 82 +++++++++ app/game-engine/test/scenarioSeeder.test.ts | 18 +- packages/infra/src/turnEngineDb.ts | 11 ++ packages/logic/src/actions/engine.ts | 31 +++- .../src/actions/turn/nation/che_선전포고.ts | 39 ++++- 13 files changed, 606 insertions(+), 45 deletions(-) create mode 100644 app/game-engine/src/turn/diplomacy.ts create mode 100644 app/game-engine/test/diplomacy.test.ts diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 3c8de40..f42ac61 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -260,13 +260,46 @@ export const seedScenarioToDatabase = async ( }); } - if (seed.diplomacy.length > 0) { + const diplomacyMap = new Map< + string, + { srcNationId: number; destNationId: number; state: number; term: number } + >(); + const nationIds = seed.nations.map((nation) => nation.id); + for (const srcNationId of nationIds) { + for (const destNationId of nationIds) { + if (srcNationId === destNationId) { + continue; + } + diplomacyMap.set(`${srcNationId}:${destNationId}`, { + srcNationId, + destNationId, + state: 2, + term: 0, + }); + } + } + for (const row of seed.diplomacy) { + diplomacyMap.set(`${row.fromNationId}:${row.toNationId}`, { + srcNationId: row.fromNationId, + destNationId: row.toNationId, + state: row.state, + term: row.durationMonths, + }); + diplomacyMap.set(`${row.toNationId}:${row.fromNationId}`, { + srcNationId: row.toNationId, + destNationId: row.fromNationId, + state: row.state, + term: row.durationMonths, + }); + } + const diplomacyRows = Array.from(diplomacyMap.values()); + if (diplomacyRows.length > 0) { await prisma.diplomacy.createMany({ - data: seed.diplomacy.map((row) => ({ - srcNationId: row.fromNationId, - destNationId: row.toNationId, + data: diplomacyRows.map((row) => ({ + srcNationId: row.srcNationId, + destNationId: row.destNationId, stateCode: row.state, - term: row.durationMonths, + term: row.term, meta: asJson({}), })), }); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index fca58cb..620300e 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -3,6 +3,8 @@ import { type InputJsonValue, type TurnEngineCityUpdateInput, type TurnEngineDatabaseClient, + type TurnEngineDiplomacyCreateManyInput, + type TurnEngineDiplomacyUpdateInput, type TurnEngineGeneralCreateManyInput, type TurnEngineGeneralUpdateInput, type TurnEngineLogEntryCreateManyInput, @@ -16,6 +18,7 @@ import { finalizeLogEntry, type LogEntryDraft } from '@sammo-ts/logic'; import type { TurnDaemonHooks } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; +import { buildDiplomacyMeta } from './diplomacy.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -174,6 +177,28 @@ const buildTroopCreate = ( name: troop.name, }); +const buildDiplomacyCreate = ( + entry: ReturnType< + InMemoryTurnWorld['consumeDirtyState'] + >['diplomacy'][number] +): TurnEngineDiplomacyCreateManyInput => ({ + srcNationId: entry.fromNationId, + destNationId: entry.toNationId, + stateCode: entry.state, + term: entry.term, + meta: asJson(buildDiplomacyMeta(entry)), +}); + +const buildDiplomacyUpdate = ( + entry: ReturnType< + InMemoryTurnWorld['consumeDirtyState'] + >['diplomacy'][number] +): TurnEngineDiplomacyUpdateInput => ({ + stateCode: entry.state, + term: entry.term, + meta: asJson(buildDiplomacyMeta(entry)), +}); + const buildLogCreateData = ( entry: LogEntryDraft, context: { year: number; month: number; at: Date } @@ -220,9 +245,11 @@ export const createDatabaseTurnHooks = async ( cities, nations, troops, + diplomacy, logs, createdGenerals, createdTroops, + createdDiplomacy, } = world.consumeDirtyState(); const worldStateUpdate: TurnEngineWorldStateUpdateInput = { @@ -242,6 +269,11 @@ export const createDatabaseTurnHooks = async ( const createdTroopIds = new Set( createdTroops.map((troop) => troop.id) ); + const createdDiplomacyKeys = new Set( + createdDiplomacy.map( + (entry) => `${entry.fromNationId}:${entry.toNationId}` + ) + ); if (createdGenerals.length > 0) { await prisma.general.createMany({ @@ -253,6 +285,11 @@ export const createDatabaseTurnHooks = async ( data: createdTroops.map(buildTroopCreate), }); } + if (createdDiplomacy.length > 0) { + await prisma.diplomacy.createMany({ + data: createdDiplomacy.map(buildDiplomacyCreate), + }); + } await Promise.all([ ...generals @@ -283,6 +320,22 @@ export const createDatabaseTurnHooks = async ( data: buildTroopUpdate(troop), }) ), + ...diplomacy + .filter( + (entry) => + !createdDiplomacyKeys.has( + `${entry.fromNationId}:${entry.toNationId}` + ) + ) + .map((entry) => + prisma.diplomacy.update({ + where: { + srcNationId: entry.fromNationId, + destNationId: entry.toNationId, + }, + data: buildDiplomacyUpdate(entry), + }) + ), ]); if (logs.length > 0) { diff --git a/app/game-engine/src/turn/diplomacy.ts b/app/game-engine/src/turn/diplomacy.ts new file mode 100644 index 0000000..50b044d --- /dev/null +++ b/app/game-engine/src/turn/diplomacy.ts @@ -0,0 +1,162 @@ +import type { TurnDiplomacy } from './types.js'; + +// 외교 상태 코드 (legacy 기준). +export const DIPLOMACY_STATE = { + WAR: 0, + DECLARATION: 1, + TRADE: 2, + NON_AGGRESSION: 7, +} as const; + +export const DEFAULT_DECLARE_WAR_TERM = 24; +export const DEFAULT_WAR_TERM = 6; + +// TODO: 불가침 제의/수락 등 외교 커맨드 전환 규칙을 이 모듈에 추가 예정. +const MAX_WAR_TERM = 13; + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +export const buildDiplomacyKey = (srcNationId: number, destNationId: number): string => + `${srcNationId}:${destNationId}`; + +export const buildDefaultDiplomacy = ( + srcNationId: number, + destNationId: number +): TurnDiplomacy => ({ + fromNationId: srcNationId, + toNationId: destNationId, + state: DIPLOMACY_STATE.TRADE, + term: 0, + dead: 0, + meta: {}, +}); + +export interface DiplomacyPatch { + state?: number; + term?: number; + dead?: number; + deadDelta?: number; + meta?: Record; +} + +export const applyDiplomacyPatch = ( + entry: TurnDiplomacy, + patch: DiplomacyPatch +): TurnDiplomacy => { + const nextDead = + typeof patch.dead === 'number' + ? patch.dead + : typeof patch.deadDelta === 'number' + ? entry.dead + patch.deadDelta + : entry.dead; + return { + ...entry, + state: patch.state ?? entry.state, + term: patch.term ?? entry.term, + dead: clamp(nextDead, 0, Number.MAX_SAFE_INTEGER), + meta: patch.meta ? { ...entry.meta, ...patch.meta } : entry.meta, + }; +}; + +export const readDiplomacyMeta = ( + meta: Record +): { meta: Record; dead: number } => { + const rawDead = meta.dead; + const dead = typeof rawDead === 'number' ? rawDead : 0; + const cleaned = { ...meta }; + delete cleaned.dead; + return { meta: cleaned, dead }; +}; + +export const buildDiplomacyMeta = ( + entry: TurnDiplomacy +): Record => ({ + ...entry.meta, + dead: entry.dead, +}); + +export const processDiplomacyMonth = ( + diplomacy: TurnDiplomacy[], + generalCounts: Map +): TurnDiplomacy[] => { + const next = diplomacy.map((entry) => ({ + ...entry, + meta: { ...entry.meta }, + })); + const byKey = new Map( + next.map((entry) => [ + buildDiplomacyKey(entry.fromNationId, entry.toNationId), + entry, + ]) + ); + + // 전쟁 기간 갱신: 사상자에 따라 term 증가, 잔여 사상자 유지. + for (const entry of next) { + if (entry.state !== DIPLOMACY_STATE.WAR || entry.dead <= 0) { + continue; + } + const genCount = Math.max(1, generalCounts.get(entry.fromNationId) ?? 1); + const termIncrease = Math.floor(entry.dead / 100 / genCount); + if (termIncrease <= 0) { + continue; + } + entry.dead -= termIncrease * 100 * genCount; + entry.term = clamp(entry.term + termIncrease, 0, MAX_WAR_TERM); + } + + // 전쟁 종료 판정: 양방 term이 1 이하이면 통상으로 전환. + const processedPairs = new Set(); + for (const entry of next) { + if (entry.state !== DIPLOMACY_STATE.WAR || entry.term > 1) { + continue; + } + const pairKey = buildDiplomacyKey( + Math.min(entry.fromNationId, entry.toNationId), + Math.max(entry.fromNationId, entry.toNationId) + ); + if (processedPairs.has(pairKey)) { + continue; + } + const opposite = byKey.get( + buildDiplomacyKey(entry.toNationId, entry.fromNationId) + ); + if ( + opposite && + opposite.state === DIPLOMACY_STATE.WAR && + opposite.term <= 1 + ) { + entry.state = DIPLOMACY_STATE.TRADE; + entry.term = 0; + opposite.state = DIPLOMACY_STATE.TRADE; + opposite.term = 0; + processedPairs.add(pairKey); + } + } + + // 사상자 초기화 및 term 감소. + for (const entry of next) { + if (entry.state !== DIPLOMACY_STATE.WAR) { + entry.dead = 0; + } + entry.term = Math.max(0, entry.term - 1); + } + + // 불가침/선전포고 만료 처리. + for (const entry of next) { + if ( + entry.state === DIPLOMACY_STATE.NON_AGGRESSION && + entry.term === 0 + ) { + entry.state = DIPLOMACY_STATE.TRADE; + } else if ( + entry.state === DIPLOMACY_STATE.DECLARATION && + entry.term === 0 + ) { + entry.state = DIPLOMACY_STATE.WAR; + entry.term = DEFAULT_WAR_TERM; + } + } + + return next; +}; diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 2e4da93..d81cc18 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -8,7 +8,19 @@ import type { import { getNextTurnAt } from '@sammo-ts/logic'; import type { TurnCheckpoint } from '../lifecycle/types.js'; -import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js'; +import type { + TurnDiplomacy, + TurnGeneral, + TurnWorldSnapshot, + TurnWorldState, +} from './types.js'; +import { + applyDiplomacyPatch as applyDiplomacyPatchToEntry, + buildDefaultDiplomacy, + buildDiplomacyKey, + processDiplomacyMonth, + type DiplomacyPatch, +} from './diplomacy.js'; export interface GeneralTurnContext { general: TurnGeneral; @@ -163,12 +175,15 @@ export class InMemoryTurnWorld { private readonly cities = new Map(); private readonly nations = new Map(); private readonly troops = new Map(); + private readonly diplomacy = new Map(); private readonly dirtyGeneralIds = new Set(); private readonly dirtyCityIds = new Set(); private readonly dirtyNationIds = new Set(); private readonly dirtyTroopIds = new Set(); + private readonly dirtyDiplomacyKeys = new Set(); private readonly createdGeneralIds = new Set(); private readonly createdTroopIds = new Set(); + private readonly createdDiplomacyKeys = new Set(); private readonly logs: LogEntryDraft[] = []; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -199,6 +214,17 @@ export class InMemoryTurnWorld { for (const troop of snapshot.troops) { this.troops.set(troop.id, { ...troop }); } + for (const entry of snapshot.diplomacy) { + const key = buildDiplomacyKey( + entry.fromNationId, + entry.toNationId + ); + this.diplomacy.set(key, { + ...entry, + meta: { ...entry.meta }, + }); + } + this.ensureDiplomacyMatrix(); } getState(): TurnWorldState { @@ -243,6 +269,47 @@ export class InMemoryTurnWorld { })); } + getDiplomacyEntry( + srcNationId: number, + destNationId: number + ): TurnDiplomacy | null { + const entry = this.diplomacy.get( + buildDiplomacyKey(srcNationId, destNationId) + ); + if (!entry) { + return null; + } + return { + ...entry, + meta: { ...entry.meta }, + }; + } + + listDiplomacy(): TurnDiplomacy[] { + return Array.from(this.diplomacy.values()).map((entry) => ({ + ...entry, + meta: { ...entry.meta }, + })); + } + + applyDiplomacyPatch(input: { + srcNationId: number; + destNationId: number; + patch: DiplomacyPatch; + }): void { + const key = buildDiplomacyKey(input.srcNationId, input.destNationId); + const existed = this.diplomacy.has(key); + const base = + this.diplomacy.get(key) ?? + buildDefaultDiplomacy(input.srcNationId, input.destNationId); + const next = applyDiplomacyPatchToEntry(base, input.patch); + this.diplomacy.set(key, next); + this.dirtyDiplomacyKeys.add(key); + if (!existed) { + this.createdDiplomacyKeys.add(key); + } + } + setLastTurnTime(turnTime: Date): void { const meta = { ...this.state.meta, @@ -417,6 +484,7 @@ export class InMemoryTurnWorld { currentMonth: nextMonth, turnTime, }; + this.advanceDiplomacyMonth(); this.calendarHandler?.onMonthChanged?.(context); if (nextYear !== previousYear) { this.calendarHandler?.onYearChanged?.(context); @@ -428,9 +496,11 @@ export class InMemoryTurnWorld { cities: City[]; nations: Nation[]; troops: Troop[]; + diplomacy: TurnDiplomacy[]; logs: LogEntryDraft[]; createdGenerals: TurnGeneral[]; createdTroops: Troop[]; + createdDiplomacy: TurnDiplomacy[]; } { const generals = Array.from(this.dirtyGeneralIds) .map((id) => this.generals.get(id)) @@ -447,26 +517,93 @@ export class InMemoryTurnWorld { const troops = Array.from(this.dirtyTroopIds) .map((id) => this.troops.get(id)) .filter((troop): troop is Troop => Boolean(troop)); + const diplomacy = Array.from(this.dirtyDiplomacyKeys) + .map((key) => this.diplomacy.get(key)) + .filter((entry): entry is TurnDiplomacy => Boolean(entry)); const createdTroops = Array.from(this.createdTroopIds) .map((id) => this.troops.get(id)) .filter((troop): troop is Troop => Boolean(troop)); + const createdDiplomacy = Array.from(this.createdDiplomacyKeys) + .map((key) => this.diplomacy.get(key)) + .filter((entry): entry is TurnDiplomacy => Boolean(entry)); const logs = this.logs.splice(0, this.logs.length); this.dirtyGeneralIds.clear(); this.dirtyCityIds.clear(); this.dirtyNationIds.clear(); this.dirtyTroopIds.clear(); + this.dirtyDiplomacyKeys.clear(); this.createdGeneralIds.clear(); this.createdTroopIds.clear(); + this.createdDiplomacyKeys.clear(); return { generals, cities, nations, troops, + diplomacy, logs, createdGenerals, createdTroops, + createdDiplomacy, }; } + + private ensureDiplomacyMatrix(): void { + const nationIds = Array.from(this.nations.keys()); + for (const srcNationId of nationIds) { + for (const destNationId of nationIds) { + if (srcNationId === destNationId) { + continue; + } + const key = buildDiplomacyKey(srcNationId, destNationId); + if (this.diplomacy.has(key)) { + continue; + } + const entry = buildDefaultDiplomacy(srcNationId, destNationId); + this.diplomacy.set(key, entry); + this.dirtyDiplomacyKeys.add(key); + this.createdDiplomacyKeys.add(key); + } + } + } + + private advanceDiplomacyMonth(): void { + if (this.diplomacy.size === 0) { + return; + } + const generalCounts = new Map(); + for (const general of this.generals.values()) { + const nationId = general.nationId; + if (nationId <= 0) { + continue; + } + generalCounts.set(nationId, (generalCounts.get(nationId) ?? 0) + 1); + } + + const updated = processDiplomacyMonth( + this.listDiplomacy(), + generalCounts + ); + for (const entry of updated) { + const key = buildDiplomacyKey( + entry.fromNationId, + entry.toNationId + ); + const prev = this.diplomacy.get(key); + if ( + !prev || + prev.state !== entry.state || + prev.term !== entry.term || + prev.dead !== entry.dead + ) { + this.diplomacy.set(key, entry); + this.dirtyDiplomacyKeys.add(key); + if (!prev) { + this.createdDiplomacyKeys.add(key); + } + } + } + } } diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index bf83f89..30ec3c6 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1,12 +1,10 @@ import type { City, GeneralActionDefinition, - GeneralActionResolveContext, LogEntryDraft, MapDefinition, Nation, ScenarioConfig, - ScenarioDiplomacy, ScenarioMeta, Troop, TurnCommandProfile, @@ -65,17 +63,6 @@ const resolveConstraintEnv = ( }; }; -const buildDiplomacyMap = ( - diplomacy: ScenarioDiplomacy[] -): Map => { - const map = new Map(); - for (const row of diplomacy) { - map.set(`${row.fromNationId}:${row.toNationId}`, row.state); - } - return map; -}; - - const buildSeedBase = (world: TurnWorldState): string => { const meta = asRecord(world.meta); const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id; @@ -120,7 +107,6 @@ class DeterministicRandom { class WorldStateView implements StateView { constructor( private readonly world: InMemoryTurnWorld | null, - private readonly diplomacy: Map, private readonly env: Record, private readonly args: Record, private readonly overrides?: { @@ -161,9 +147,10 @@ class WorldStateView implements StateView { case 'destNation': return this.world.getNationById(req.id); case 'diplomacy': - return this.diplomacy.get( - `${req.srcNationId}:${req.destNationId}` - ) ?? null; + return this.world.getDiplomacyEntry( + req.srcNationId, + req.destNationId + ); case 'arg': return this.args[req.key] ?? null; case 'env': @@ -213,7 +200,6 @@ export const createReservedTurnHandler = async (options: { reservedTurns: InMemoryReservedTurnStore; scenarioConfig: ScenarioConfig; scenarioMeta?: ScenarioMeta; - diplomacy: ScenarioDiplomacy[]; map?: MapDefinition; unitSet?: UnitSetDefinition; getWorld: () => InMemoryTurnWorld | null; @@ -230,7 +216,6 @@ export const createReservedTurnHandler = async (options: { }); const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!; const nationFallback = nationDefinitions.get(DEFAULT_ACTION)!; - const diplomacyMap = buildDiplomacyMap(options.diplomacy); let nextGeneralId: number | null = null; const createGeneralId = (): number => { @@ -299,7 +284,6 @@ export const createReservedTurnHandler = async (options: { ); const view = new WorldStateView( worldRef, - diplomacyMap, constraintEnv, actionArgs as Record, { @@ -382,6 +366,19 @@ export const createReservedTurnHandler = async (options: { currentNation = resolution.nation ?? currentNation; logs.push(...resolution.logs); + if (worldRef && resolution.effects.length > 0) { + for (const effect of resolution.effects) { + if (effect.type !== 'diplomacy:patch') { + continue; + } + worldRef.applyDiplomacyPatch({ + srcNationId: effect.srcNationId, + destNationId: effect.destNationId, + patch: effect.patch, + }); + } + } + if (resolution.patches) { patches.generals.push( ...(resolution.patches.generals as Array<{ diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 57a34b1..f62268b 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -100,7 +100,6 @@ export const createTurnDaemonRuntime = async ( reservedTurns: reservedTurnStoreHandle!.store, scenarioConfig: snapshot.scenarioConfig, scenarioMeta: snapshot.scenarioMeta, - diplomacy: snapshot.diplomacy, map: snapshot.map, unitSet: snapshot.unitSet, getWorld: () => worldRef, diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 3ff1e43..58daf8a 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -4,7 +4,6 @@ import type { MapDefinition, Nation, ScenarioConfig, - ScenarioDiplomacy, ScenarioMeta, Troop, UnitSetDefinition, @@ -25,16 +24,25 @@ export interface TurnGeneral extends General { recentWarTime?: Date | null; } +export interface TurnDiplomacy { + fromNationId: number; + toNationId: number; + state: number; + term: number; + dead: number; + meta: Record; +} + export interface TurnWorldSnapshot extends Omit< WorldSnapshot, - 'generals' | 'cities' | 'nations' | 'troops' + 'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy' > { scenarioConfig: ScenarioConfig; scenarioMeta?: ScenarioMeta; map: MapDefinition; unitSet?: UnitSetDefinition; - diplomacy: ScenarioDiplomacy[]; + diplomacy: TurnDiplomacy[]; events: unknown[]; initialEvents: unknown[]; generals: TurnGeneral[]; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 3d1a4ac..dd415b4 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -12,7 +12,6 @@ import type { City, Nation, ScenarioConfig, - ScenarioDiplomacy, ScenarioMeta, Troop, TriggerValue, @@ -24,7 +23,8 @@ import type { MapLoaderOptions } from '../scenario/mapLoader.js'; import { loadMapDefinitionByName } from '../scenario/mapLoader.js'; import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js'; import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js'; -import type { TurnGeneral, TurnWorldLoadResult } from './types.js'; +import type { TurnDiplomacy, TurnGeneral, TurnWorldLoadResult } from './types.js'; +import { readDiplomacyMeta } from './diplomacy.js'; interface TurnWorldLoaderOptions { databaseUrl: string; @@ -223,12 +223,17 @@ const mapNationRow = (row: TurnEngineNationRow): Nation => ({ }, }); -const mapDiplomacyRow = (row: TurnEngineDiplomacyRow): ScenarioDiplomacy => ({ - fromNationId: row.srcNationId, - toNationId: row.destNationId, - state: row.stateCode, - durationMonths: row.term, -}); +const mapDiplomacyRow = (row: TurnEngineDiplomacyRow): TurnDiplomacy => { + const { meta, dead } = readDiplomacyMeta(asRecord(row.meta)); + return { + fromNationId: row.srcNationId, + toNationId: row.destNationId, + state: row.stateCode, + term: row.term, + dead, + meta, + }; +}; const mapTroopRow = (row: TurnEngineTroopRow): Troop => ({ id: row.troopLeaderId, diff --git a/app/game-engine/test/diplomacy.test.ts b/app/game-engine/test/diplomacy.test.ts new file mode 100644 index 0000000..6486090 --- /dev/null +++ b/app/game-engine/test/diplomacy.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_WAR_TERM, + DIPLOMACY_STATE, + processDiplomacyMonth, +} from '../src/turn/diplomacy.js'; +import type { TurnDiplomacy } from '../src/turn/types.js'; + +const buildEntry = ( + fromNationId: number, + toNationId: number, + state: number, + term: number, + dead = 0 +): TurnDiplomacy => ({ + fromNationId, + toNationId, + state, + term, + dead, + meta: {}, +}); + +describe('diplomacy month processing', () => { + it('promotes declaration to war after term expires', () => { + const entries = [ + buildEntry(1, 2, DIPLOMACY_STATE.DECLARATION, 1), + buildEntry(2, 1, DIPLOMACY_STATE.DECLARATION, 1), + ]; + const result = processDiplomacyMonth(entries, new Map([[1, 1], [2, 1]])); + + for (const entry of result) { + expect(entry.state).toBe(DIPLOMACY_STATE.WAR); + expect(entry.term).toBe(DEFAULT_WAR_TERM); + } + }); + + it('ends war when both terms reach zero without casualties', () => { + const entries = [ + buildEntry(1, 2, DIPLOMACY_STATE.WAR, 1), + buildEntry(2, 1, DIPLOMACY_STATE.WAR, 1), + ]; + const result = processDiplomacyMonth(entries, new Map([[1, 1], [2, 1]])); + + for (const entry of result) { + expect(entry.state).toBe(DIPLOMACY_STATE.TRADE); + expect(entry.term).toBe(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(entries, new Map([[1, 2], [2, 2]])); + + const forward = result.find( + (entry) => entry.fromNationId === 1 && entry.toNationId === 2 + ); + 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); + }); + + it('expires non-aggression pact into trade', () => { + const entries = [ + buildEntry(1, 2, DIPLOMACY_STATE.NON_AGGRESSION, 1), + buildEntry(2, 1, DIPLOMACY_STATE.NON_AGGRESSION, 1), + ]; + const result = processDiplomacyMonth(entries, new Map([[1, 1], [2, 1]])); + + for (const entry of result) { + expect(entry.state).toBe(DIPLOMACY_STATE.TRADE); + expect(entry.term).toBe(0); + } + }); +}); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 7b267ab..5a5c187 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -21,7 +21,7 @@ type ScenarioSeederPrismaClient = { count(): Promise; findFirst(args: { where: { srcNationId: number; destNationId: number }; - }): Promise<{ stateCode: string } | null>; + }): Promise<{ stateCode: number; term: number } | null>; }; }; @@ -68,7 +68,9 @@ describeDb('scenario database seed', () => { expect(nationCount).toBe(seed.nations.length); expect(cityCount).toBe(seed.cities.length); expect(generalCount).toBe(seed.generals.length); - expect(diplomacyCount).toBe(seed.diplomacy.length); + expect(diplomacyCount).toBe( + seed.nations.length * Math.max(0, seed.nations.length - 1) + ); expect(generalCount).toBeGreaterThan(0); if (seed.diplomacy.length > 0) { @@ -82,6 +84,18 @@ describeDb('scenario database seed', () => { expect(row).not.toBeNull(); if (row) { expect(row.stateCode).toBe(sample.state); + expect(row.term).toBe(sample.durationMonths); + } + const reverse = await prisma.diplomacy.findFirst({ + where: { + srcNationId: sample.toNationId, + destNationId: sample.fromNationId, + }, + }); + expect(reverse).not.toBeNull(); + if (reverse) { + expect(reverse.stateCode).toBe(sample.state); + expect(reverse.term).toBe(sample.durationMonths); } } } finally { diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index 454d197..0a21fa3 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -95,6 +95,7 @@ export interface TurnEngineDiplomacyRow { destNationId: number; stateCode: number; term: number; + meta: JsonValue; } export interface TurnEngineTroopRow { @@ -307,6 +308,12 @@ export interface TurnEngineDiplomacyCreateManyInput { meta: InputJsonValue; } +export interface TurnEngineDiplomacyUpdateInput { + stateCode: number; + term: number; + meta: InputJsonValue; +} + export interface TurnEngineEventCreateManyInput { targetCode: string; priority: number; @@ -379,6 +386,10 @@ export interface TurnEngineDatabaseClient { createMany(args: { data: TurnEngineDiplomacyCreateManyInput[]; }): Promise; + update(args: { + where: { srcNationId: number; destNationId: number }; + data: TurnEngineDiplomacyUpdateInput; + }): Promise; deleteMany(args?: unknown): Promise; }; troop: { diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index f4f4df3..102dce2 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -76,6 +76,19 @@ export interface NationPatchEffect { targetId?: NationId; } +export interface DiplomacyPatchEffect { + type: 'diplomacy:patch'; + srcNationId: NationId; + destNationId: NationId; + patch: { + state?: number; + term?: number; + dead?: number; + deadDelta?: number; + meta?: Record; + }; +} + export interface LogEffect { type: 'log'; entry: LogEntryDraft; @@ -93,6 +106,7 @@ export type GeneralActionEffect< | GeneralAddEffect | CityPatchEffect | NationPatchEffect + | DiplomacyPatchEffect | LogEffect | NextTurnOverrideEffect; @@ -176,6 +190,17 @@ export const createNationPatchEffect = ( ...(targetId !== undefined ? { targetId } : {}), }); +export const createDiplomacyPatchEffect = ( + srcNationId: NationId, + destNationId: NationId, + patch: DiplomacyPatchEffect['patch'] +): DiplomacyPatchEffect => ({ + type: 'diplomacy:patch', + srcNationId, + destNationId, + patch, +}); + export const createLogEffect = ( message: string, options: Partial> = {} @@ -224,6 +249,7 @@ export const resolveGeneralAction = < nations: [], }; + const pendingEffects: GeneralActionEffect[] = []; const [nextWorld, worldPatches] = produceWithPatches( { general: context.general, @@ -296,6 +322,9 @@ export const resolveGeneralAction = < case 'general:add': createdGenerals.push(effect.general as General); break; + case 'diplomacy:patch': + pendingEffects.push(effect); + break; case 'general:patch': case 'city:patch': case 'nation:patch': @@ -359,7 +388,7 @@ export const resolveGeneralAction = < nation: nextWorld.nation as Nation | null, nextTurnAt, logs, - effects: [], // 이제 effects는 직접 사용되지 않음 (이미 반영됨) + effects: pendingEffects, }; if (nextWorld.city) { resolution.city = nextWorld.city as City; diff --git a/packages/logic/src/actions/turn/nation/che_선전포고.ts b/packages/logic/src/actions/turn/nation/che_선전포고.ts index 00ec674..c755f1d 100644 --- a/packages/logic/src/actions/turn/nation/che_선전포고.ts +++ b/packages/logic/src/actions/turn/nation/che_선전포고.ts @@ -2,6 +2,7 @@ import type { GeneralTriggerState } from '../../../domain/entities.js'; import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; import { beChief, + disallowDiplomacyBetweenStatus, existsDestNation, notBeNeutral, occupiedCity, @@ -12,7 +13,7 @@ import type { GeneralActionOutcome, GeneralActionResolveContext, } from '../../engine.js'; -import { createLogEffect } from '../../engine.js'; +import { createDiplomacyPatchEffect, createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; import type { TurnCommandEnv } from '../commandEnv.js'; import type { NationTurnCommandSpec } from './index.js'; @@ -22,6 +23,9 @@ export interface DeclareWarArgs { } const ACTION_NAME = '선전포고'; +// legacy 규칙: 선전포고 상태는 24턴 유지. +const DIPLOMACY_DECLARE = 1; +const DECLARE_TERM = 24; const parseNationId = (raw: unknown): number | null => { if (typeof raw !== 'number' || !Number.isFinite(raw)) { @@ -52,18 +56,45 @@ export class ActionDefinition< suppliedCity(), beChief(), existsDestNation(), + disallowDiplomacyBetweenStatus({ + 0: '아국과 이미 교전중입니다.', + 1: '아국과 이미 선포중입니다.', + 7: '불가침국입니다.', + }), ]; } resolve( - _context: GeneralActionResolveContext, + context: GeneralActionResolveContext, args: DeclareWarArgs ): GeneralActionOutcome { - void _context; + const nationId = context.nation?.id; + if (nationId === undefined || nationId <= 0) { + return { + effects: [ + createLogEffect( + `${ACTION_NAME}을 준비했지만 국가 정보가 없습니다.`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } + ), + ], + }; + } return { effects: [ + createDiplomacyPatchEffect(nationId, args.destNationId, { + state: DIPLOMACY_DECLARE, + term: DECLARE_TERM, + }), + createDiplomacyPatchEffect(args.destNationId, nationId, { + state: DIPLOMACY_DECLARE, + term: DECLARE_TERM, + }), createLogEffect( - `${ACTION_NAME}을 준비했습니다. (국가 ${args.destNationId})`, + `${ACTION_NAME}을 실행했습니다. (국가 ${args.destNationId})`, { scope: LogScope.GENERAL, category: LogCategory.ACTION,