feat: implement diplomacy system with state management and processing logic

This commit is contained in:
2026-01-02 17:48:44 +00:00
parent f137c5be65
commit cb86a47f94
13 changed files with 606 additions and 45 deletions
+38 -5
View File
@@ -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({}),
})),
});
+53
View File
@@ -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) {
+162
View File
@@ -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<string, unknown>;
}
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<string, unknown>
): { meta: Record<string, unknown>; 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<string, unknown> => ({
...entry.meta,
dead: entry.dead,
});
export const processDiplomacyMonth = (
diplomacy: TurnDiplomacy[],
generalCounts: Map<number, number>
): TurnDiplomacy[] => {
const next = diplomacy.map((entry) => ({
...entry,
meta: { ...entry.meta },
}));
const byKey = new Map<string, TurnDiplomacy>(
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<string>();
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;
};
+138 -1
View File
@@ -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<number, City>();
private readonly nations = new Map<number, Nation>();
private readonly troops = new Map<number, Troop>();
private readonly diplomacy = new Map<string, TurnDiplomacy>();
private readonly dirtyGeneralIds = new Set<number>();
private readonly dirtyCityIds = new Set<number>();
private readonly dirtyNationIds = new Set<number>();
private readonly dirtyTroopIds = new Set<number>();
private readonly dirtyDiplomacyKeys = new Set<string>();
private readonly createdGeneralIds = new Set<number>();
private readonly createdTroopIds = new Set<number>();
private readonly createdDiplomacyKeys = new Set<string>();
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<number, number>();
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);
}
}
}
}
}
+17 -20
View File
@@ -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<string, number> => {
const map = new Map<string, number>();
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<string, number>,
private readonly env: Record<string, unknown>,
private readonly args: Record<string, unknown>,
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<string, unknown>,
{
@@ -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<{
-1
View File
@@ -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,
+11 -3
View File
@@ -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<string, unknown>;
}
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[];
+13 -8
View File
@@ -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,
+82
View File
@@ -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);
}
});
});
+16 -2
View File
@@ -21,7 +21,7 @@ type ScenarioSeederPrismaClient = {
count(): Promise<number>;
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 {
+11
View File
@@ -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<unknown>;
update(args: {
where: { srcNationId: number; destNationId: number };
data: TurnEngineDiplomacyUpdateInput;
}): Promise<unknown>;
deleteMany(args?: unknown): Promise<unknown>;
};
troop: {
+30 -1
View File
@@ -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<string, unknown>;
};
}
export interface LogEffect {
type: 'log';
entry: LogEntryDraft;
@@ -93,6 +106,7 @@ export type GeneralActionEffect<
| GeneralAddEffect<TriggerState>
| 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<Omit<LogEntryDraft, 'text'>> = {}
@@ -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;
@@ -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<TriggerState>,
context: GeneralActionResolveContext<TriggerState>,
args: DeclareWarArgs
): GeneralActionOutcome<TriggerState> {
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,