feat: implement diplomacy system with state management and processing logic
This commit is contained in:
@@ -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({
|
await prisma.diplomacy.createMany({
|
||||||
data: seed.diplomacy.map((row) => ({
|
data: diplomacyRows.map((row) => ({
|
||||||
srcNationId: row.fromNationId,
|
srcNationId: row.srcNationId,
|
||||||
destNationId: row.toNationId,
|
destNationId: row.destNationId,
|
||||||
stateCode: row.state,
|
stateCode: row.state,
|
||||||
term: row.durationMonths,
|
term: row.term,
|
||||||
meta: asJson({}),
|
meta: asJson({}),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
type InputJsonValue,
|
type InputJsonValue,
|
||||||
type TurnEngineCityUpdateInput,
|
type TurnEngineCityUpdateInput,
|
||||||
type TurnEngineDatabaseClient,
|
type TurnEngineDatabaseClient,
|
||||||
|
type TurnEngineDiplomacyCreateManyInput,
|
||||||
|
type TurnEngineDiplomacyUpdateInput,
|
||||||
type TurnEngineGeneralCreateManyInput,
|
type TurnEngineGeneralCreateManyInput,
|
||||||
type TurnEngineGeneralUpdateInput,
|
type TurnEngineGeneralUpdateInput,
|
||||||
type TurnEngineLogEntryCreateManyInput,
|
type TurnEngineLogEntryCreateManyInput,
|
||||||
@@ -16,6 +18,7 @@ import { finalizeLogEntry, type LogEntryDraft } from '@sammo-ts/logic';
|
|||||||
import type { TurnDaemonHooks } from '../lifecycle/types.js';
|
import type { TurnDaemonHooks } from '../lifecycle/types.js';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
||||||
|
import { buildDiplomacyMeta } from './diplomacy.js';
|
||||||
|
|
||||||
export interface DatabaseTurnHooks {
|
export interface DatabaseTurnHooks {
|
||||||
hooks: TurnDaemonHooks;
|
hooks: TurnDaemonHooks;
|
||||||
@@ -174,6 +177,28 @@ const buildTroopCreate = (
|
|||||||
name: troop.name,
|
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 = (
|
const buildLogCreateData = (
|
||||||
entry: LogEntryDraft,
|
entry: LogEntryDraft,
|
||||||
context: { year: number; month: number; at: Date }
|
context: { year: number; month: number; at: Date }
|
||||||
@@ -220,9 +245,11 @@ export const createDatabaseTurnHooks = async (
|
|||||||
cities,
|
cities,
|
||||||
nations,
|
nations,
|
||||||
troops,
|
troops,
|
||||||
|
diplomacy,
|
||||||
logs,
|
logs,
|
||||||
createdGenerals,
|
createdGenerals,
|
||||||
createdTroops,
|
createdTroops,
|
||||||
|
createdDiplomacy,
|
||||||
} = world.consumeDirtyState();
|
} = world.consumeDirtyState();
|
||||||
|
|
||||||
const worldStateUpdate: TurnEngineWorldStateUpdateInput = {
|
const worldStateUpdate: TurnEngineWorldStateUpdateInput = {
|
||||||
@@ -242,6 +269,11 @@ export const createDatabaseTurnHooks = async (
|
|||||||
const createdTroopIds = new Set(
|
const createdTroopIds = new Set(
|
||||||
createdTroops.map((troop) => troop.id)
|
createdTroops.map((troop) => troop.id)
|
||||||
);
|
);
|
||||||
|
const createdDiplomacyKeys = new Set(
|
||||||
|
createdDiplomacy.map(
|
||||||
|
(entry) => `${entry.fromNationId}:${entry.toNationId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (createdGenerals.length > 0) {
|
if (createdGenerals.length > 0) {
|
||||||
await prisma.general.createMany({
|
await prisma.general.createMany({
|
||||||
@@ -253,6 +285,11 @@ export const createDatabaseTurnHooks = async (
|
|||||||
data: createdTroops.map(buildTroopCreate),
|
data: createdTroops.map(buildTroopCreate),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (createdDiplomacy.length > 0) {
|
||||||
|
await prisma.diplomacy.createMany({
|
||||||
|
data: createdDiplomacy.map(buildDiplomacyCreate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...generals
|
...generals
|
||||||
@@ -283,6 +320,22 @@ export const createDatabaseTurnHooks = async (
|
|||||||
data: buildTroopUpdate(troop),
|
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) {
|
if (logs.length > 0) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -8,7 +8,19 @@ import type {
|
|||||||
import { getNextTurnAt } from '@sammo-ts/logic';
|
import { getNextTurnAt } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
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 {
|
export interface GeneralTurnContext {
|
||||||
general: TurnGeneral;
|
general: TurnGeneral;
|
||||||
@@ -163,12 +175,15 @@ export class InMemoryTurnWorld {
|
|||||||
private readonly cities = new Map<number, City>();
|
private readonly cities = new Map<number, City>();
|
||||||
private readonly nations = new Map<number, Nation>();
|
private readonly nations = new Map<number, Nation>();
|
||||||
private readonly troops = new Map<number, Troop>();
|
private readonly troops = new Map<number, Troop>();
|
||||||
|
private readonly diplomacy = new Map<string, TurnDiplomacy>();
|
||||||
private readonly dirtyGeneralIds = new Set<number>();
|
private readonly dirtyGeneralIds = new Set<number>();
|
||||||
private readonly dirtyCityIds = new Set<number>();
|
private readonly dirtyCityIds = new Set<number>();
|
||||||
private readonly dirtyNationIds = new Set<number>();
|
private readonly dirtyNationIds = new Set<number>();
|
||||||
private readonly dirtyTroopIds = new Set<number>();
|
private readonly dirtyTroopIds = new Set<number>();
|
||||||
|
private readonly dirtyDiplomacyKeys = new Set<string>();
|
||||||
private readonly createdGeneralIds = new Set<number>();
|
private readonly createdGeneralIds = new Set<number>();
|
||||||
private readonly createdTroopIds = new Set<number>();
|
private readonly createdTroopIds = new Set<number>();
|
||||||
|
private readonly createdDiplomacyKeys = new Set<string>();
|
||||||
private readonly logs: LogEntryDraft[] = [];
|
private readonly logs: LogEntryDraft[] = [];
|
||||||
private checkpoint?: TurnCheckpoint;
|
private checkpoint?: TurnCheckpoint;
|
||||||
private state: TurnWorldState;
|
private state: TurnWorldState;
|
||||||
@@ -199,6 +214,17 @@ export class InMemoryTurnWorld {
|
|||||||
for (const troop of snapshot.troops) {
|
for (const troop of snapshot.troops) {
|
||||||
this.troops.set(troop.id, { ...troop });
|
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 {
|
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 {
|
setLastTurnTime(turnTime: Date): void {
|
||||||
const meta = {
|
const meta = {
|
||||||
...this.state.meta,
|
...this.state.meta,
|
||||||
@@ -417,6 +484,7 @@ export class InMemoryTurnWorld {
|
|||||||
currentMonth: nextMonth,
|
currentMonth: nextMonth,
|
||||||
turnTime,
|
turnTime,
|
||||||
};
|
};
|
||||||
|
this.advanceDiplomacyMonth();
|
||||||
this.calendarHandler?.onMonthChanged?.(context);
|
this.calendarHandler?.onMonthChanged?.(context);
|
||||||
if (nextYear !== previousYear) {
|
if (nextYear !== previousYear) {
|
||||||
this.calendarHandler?.onYearChanged?.(context);
|
this.calendarHandler?.onYearChanged?.(context);
|
||||||
@@ -428,9 +496,11 @@ export class InMemoryTurnWorld {
|
|||||||
cities: City[];
|
cities: City[];
|
||||||
nations: Nation[];
|
nations: Nation[];
|
||||||
troops: Troop[];
|
troops: Troop[];
|
||||||
|
diplomacy: TurnDiplomacy[];
|
||||||
logs: LogEntryDraft[];
|
logs: LogEntryDraft[];
|
||||||
createdGenerals: TurnGeneral[];
|
createdGenerals: TurnGeneral[];
|
||||||
createdTroops: Troop[];
|
createdTroops: Troop[];
|
||||||
|
createdDiplomacy: TurnDiplomacy[];
|
||||||
} {
|
} {
|
||||||
const generals = Array.from(this.dirtyGeneralIds)
|
const generals = Array.from(this.dirtyGeneralIds)
|
||||||
.map((id) => this.generals.get(id))
|
.map((id) => this.generals.get(id))
|
||||||
@@ -447,26 +517,93 @@ export class InMemoryTurnWorld {
|
|||||||
const troops = Array.from(this.dirtyTroopIds)
|
const troops = Array.from(this.dirtyTroopIds)
|
||||||
.map((id) => this.troops.get(id))
|
.map((id) => this.troops.get(id))
|
||||||
.filter((troop): troop is Troop => Boolean(troop));
|
.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)
|
const createdTroops = Array.from(this.createdTroopIds)
|
||||||
.map((id) => this.troops.get(id))
|
.map((id) => this.troops.get(id))
|
||||||
.filter((troop): troop is Troop => Boolean(troop));
|
.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);
|
const logs = this.logs.splice(0, this.logs.length);
|
||||||
|
|
||||||
this.dirtyGeneralIds.clear();
|
this.dirtyGeneralIds.clear();
|
||||||
this.dirtyCityIds.clear();
|
this.dirtyCityIds.clear();
|
||||||
this.dirtyNationIds.clear();
|
this.dirtyNationIds.clear();
|
||||||
this.dirtyTroopIds.clear();
|
this.dirtyTroopIds.clear();
|
||||||
|
this.dirtyDiplomacyKeys.clear();
|
||||||
this.createdGeneralIds.clear();
|
this.createdGeneralIds.clear();
|
||||||
this.createdTroopIds.clear();
|
this.createdTroopIds.clear();
|
||||||
|
this.createdDiplomacyKeys.clear();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generals,
|
generals,
|
||||||
cities,
|
cities,
|
||||||
nations,
|
nations,
|
||||||
troops,
|
troops,
|
||||||
|
diplomacy,
|
||||||
logs,
|
logs,
|
||||||
createdGenerals,
|
createdGenerals,
|
||||||
createdTroops,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
City,
|
City,
|
||||||
GeneralActionDefinition,
|
GeneralActionDefinition,
|
||||||
GeneralActionResolveContext,
|
|
||||||
LogEntryDraft,
|
LogEntryDraft,
|
||||||
MapDefinition,
|
MapDefinition,
|
||||||
Nation,
|
Nation,
|
||||||
ScenarioConfig,
|
ScenarioConfig,
|
||||||
ScenarioDiplomacy,
|
|
||||||
ScenarioMeta,
|
ScenarioMeta,
|
||||||
Troop,
|
Troop,
|
||||||
TurnCommandProfile,
|
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 buildSeedBase = (world: TurnWorldState): string => {
|
||||||
const meta = asRecord(world.meta);
|
const meta = asRecord(world.meta);
|
||||||
const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id;
|
const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id;
|
||||||
@@ -120,7 +107,6 @@ class DeterministicRandom {
|
|||||||
class WorldStateView implements StateView {
|
class WorldStateView implements StateView {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly world: InMemoryTurnWorld | null,
|
private readonly world: InMemoryTurnWorld | null,
|
||||||
private readonly diplomacy: Map<string, number>,
|
|
||||||
private readonly env: Record<string, unknown>,
|
private readonly env: Record<string, unknown>,
|
||||||
private readonly args: Record<string, unknown>,
|
private readonly args: Record<string, unknown>,
|
||||||
private readonly overrides?: {
|
private readonly overrides?: {
|
||||||
@@ -161,9 +147,10 @@ class WorldStateView implements StateView {
|
|||||||
case 'destNation':
|
case 'destNation':
|
||||||
return this.world.getNationById(req.id);
|
return this.world.getNationById(req.id);
|
||||||
case 'diplomacy':
|
case 'diplomacy':
|
||||||
return this.diplomacy.get(
|
return this.world.getDiplomacyEntry(
|
||||||
`${req.srcNationId}:${req.destNationId}`
|
req.srcNationId,
|
||||||
) ?? null;
|
req.destNationId
|
||||||
|
);
|
||||||
case 'arg':
|
case 'arg':
|
||||||
return this.args[req.key] ?? null;
|
return this.args[req.key] ?? null;
|
||||||
case 'env':
|
case 'env':
|
||||||
@@ -213,7 +200,6 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
reservedTurns: InMemoryReservedTurnStore;
|
reservedTurns: InMemoryReservedTurnStore;
|
||||||
scenarioConfig: ScenarioConfig;
|
scenarioConfig: ScenarioConfig;
|
||||||
scenarioMeta?: ScenarioMeta;
|
scenarioMeta?: ScenarioMeta;
|
||||||
diplomacy: ScenarioDiplomacy[];
|
|
||||||
map?: MapDefinition;
|
map?: MapDefinition;
|
||||||
unitSet?: UnitSetDefinition;
|
unitSet?: UnitSetDefinition;
|
||||||
getWorld: () => InMemoryTurnWorld | null;
|
getWorld: () => InMemoryTurnWorld | null;
|
||||||
@@ -230,7 +216,6 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
});
|
});
|
||||||
const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!;
|
const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!;
|
||||||
const nationFallback = nationDefinitions.get(DEFAULT_ACTION)!;
|
const nationFallback = nationDefinitions.get(DEFAULT_ACTION)!;
|
||||||
const diplomacyMap = buildDiplomacyMap(options.diplomacy);
|
|
||||||
|
|
||||||
let nextGeneralId: number | null = null;
|
let nextGeneralId: number | null = null;
|
||||||
const createGeneralId = (): number => {
|
const createGeneralId = (): number => {
|
||||||
@@ -299,7 +284,6 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
);
|
);
|
||||||
const view = new WorldStateView(
|
const view = new WorldStateView(
|
||||||
worldRef,
|
worldRef,
|
||||||
diplomacyMap,
|
|
||||||
constraintEnv,
|
constraintEnv,
|
||||||
actionArgs as Record<string, unknown>,
|
actionArgs as Record<string, unknown>,
|
||||||
{
|
{
|
||||||
@@ -382,6 +366,19 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
currentNation = resolution.nation ?? currentNation;
|
currentNation = resolution.nation ?? currentNation;
|
||||||
logs.push(...resolution.logs);
|
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) {
|
if (resolution.patches) {
|
||||||
patches.generals.push(
|
patches.generals.push(
|
||||||
...(resolution.patches.generals as Array<{
|
...(resolution.patches.generals as Array<{
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ export const createTurnDaemonRuntime = async (
|
|||||||
reservedTurns: reservedTurnStoreHandle!.store,
|
reservedTurns: reservedTurnStoreHandle!.store,
|
||||||
scenarioConfig: snapshot.scenarioConfig,
|
scenarioConfig: snapshot.scenarioConfig,
|
||||||
scenarioMeta: snapshot.scenarioMeta,
|
scenarioMeta: snapshot.scenarioMeta,
|
||||||
diplomacy: snapshot.diplomacy,
|
|
||||||
map: snapshot.map,
|
map: snapshot.map,
|
||||||
unitSet: snapshot.unitSet,
|
unitSet: snapshot.unitSet,
|
||||||
getWorld: () => worldRef,
|
getWorld: () => worldRef,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type {
|
|||||||
MapDefinition,
|
MapDefinition,
|
||||||
Nation,
|
Nation,
|
||||||
ScenarioConfig,
|
ScenarioConfig,
|
||||||
ScenarioDiplomacy,
|
|
||||||
ScenarioMeta,
|
ScenarioMeta,
|
||||||
Troop,
|
Troop,
|
||||||
UnitSetDefinition,
|
UnitSetDefinition,
|
||||||
@@ -25,16 +24,25 @@ export interface TurnGeneral extends General {
|
|||||||
recentWarTime?: Date | null;
|
recentWarTime?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TurnDiplomacy {
|
||||||
|
fromNationId: number;
|
||||||
|
toNationId: number;
|
||||||
|
state: number;
|
||||||
|
term: number;
|
||||||
|
dead: number;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnWorldSnapshot
|
export interface TurnWorldSnapshot
|
||||||
extends Omit<
|
extends Omit<
|
||||||
WorldSnapshot,
|
WorldSnapshot,
|
||||||
'generals' | 'cities' | 'nations' | 'troops'
|
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
|
||||||
> {
|
> {
|
||||||
scenarioConfig: ScenarioConfig;
|
scenarioConfig: ScenarioConfig;
|
||||||
scenarioMeta?: ScenarioMeta;
|
scenarioMeta?: ScenarioMeta;
|
||||||
map: MapDefinition;
|
map: MapDefinition;
|
||||||
unitSet?: UnitSetDefinition;
|
unitSet?: UnitSetDefinition;
|
||||||
diplomacy: ScenarioDiplomacy[];
|
diplomacy: TurnDiplomacy[];
|
||||||
events: unknown[];
|
events: unknown[];
|
||||||
initialEvents: unknown[];
|
initialEvents: unknown[];
|
||||||
generals: TurnGeneral[];
|
generals: TurnGeneral[];
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
City,
|
City,
|
||||||
Nation,
|
Nation,
|
||||||
ScenarioConfig,
|
ScenarioConfig,
|
||||||
ScenarioDiplomacy,
|
|
||||||
ScenarioMeta,
|
ScenarioMeta,
|
||||||
Troop,
|
Troop,
|
||||||
TriggerValue,
|
TriggerValue,
|
||||||
@@ -24,7 +23,8 @@ import type { MapLoaderOptions } from '../scenario/mapLoader.js';
|
|||||||
import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
|
import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
|
||||||
import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
|
import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
|
||||||
import { loadUnitSetDefinitionByName } 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 {
|
interface TurnWorldLoaderOptions {
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
@@ -223,12 +223,17 @@ const mapNationRow = (row: TurnEngineNationRow): Nation => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDiplomacyRow = (row: TurnEngineDiplomacyRow): ScenarioDiplomacy => ({
|
const mapDiplomacyRow = (row: TurnEngineDiplomacyRow): TurnDiplomacy => {
|
||||||
fromNationId: row.srcNationId,
|
const { meta, dead } = readDiplomacyMeta(asRecord(row.meta));
|
||||||
toNationId: row.destNationId,
|
return {
|
||||||
state: row.stateCode,
|
fromNationId: row.srcNationId,
|
||||||
durationMonths: row.term,
|
toNationId: row.destNationId,
|
||||||
});
|
state: row.stateCode,
|
||||||
|
term: row.term,
|
||||||
|
dead,
|
||||||
|
meta,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const mapTroopRow = (row: TurnEngineTroopRow): Troop => ({
|
const mapTroopRow = (row: TurnEngineTroopRow): Troop => ({
|
||||||
id: row.troopLeaderId,
|
id: row.troopLeaderId,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,7 @@ type ScenarioSeederPrismaClient = {
|
|||||||
count(): Promise<number>;
|
count(): Promise<number>;
|
||||||
findFirst(args: {
|
findFirst(args: {
|
||||||
where: { srcNationId: number; destNationId: number };
|
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(nationCount).toBe(seed.nations.length);
|
||||||
expect(cityCount).toBe(seed.cities.length);
|
expect(cityCount).toBe(seed.cities.length);
|
||||||
expect(generalCount).toBe(seed.generals.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);
|
expect(generalCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
if (seed.diplomacy.length > 0) {
|
if (seed.diplomacy.length > 0) {
|
||||||
@@ -82,6 +84,18 @@ describeDb('scenario database seed', () => {
|
|||||||
expect(row).not.toBeNull();
|
expect(row).not.toBeNull();
|
||||||
if (row) {
|
if (row) {
|
||||||
expect(row.stateCode).toBe(sample.state);
|
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 {
|
} finally {
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export interface TurnEngineDiplomacyRow {
|
|||||||
destNationId: number;
|
destNationId: number;
|
||||||
stateCode: number;
|
stateCode: number;
|
||||||
term: number;
|
term: number;
|
||||||
|
meta: JsonValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnEngineTroopRow {
|
export interface TurnEngineTroopRow {
|
||||||
@@ -307,6 +308,12 @@ export interface TurnEngineDiplomacyCreateManyInput {
|
|||||||
meta: InputJsonValue;
|
meta: InputJsonValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TurnEngineDiplomacyUpdateInput {
|
||||||
|
stateCode: number;
|
||||||
|
term: number;
|
||||||
|
meta: InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnEngineEventCreateManyInput {
|
export interface TurnEngineEventCreateManyInput {
|
||||||
targetCode: string;
|
targetCode: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
@@ -379,6 +386,10 @@ export interface TurnEngineDatabaseClient {
|
|||||||
createMany(args: {
|
createMany(args: {
|
||||||
data: TurnEngineDiplomacyCreateManyInput[];
|
data: TurnEngineDiplomacyCreateManyInput[];
|
||||||
}): Promise<unknown>;
|
}): Promise<unknown>;
|
||||||
|
update(args: {
|
||||||
|
where: { srcNationId: number; destNationId: number };
|
||||||
|
data: TurnEngineDiplomacyUpdateInput;
|
||||||
|
}): Promise<unknown>;
|
||||||
deleteMany(args?: unknown): Promise<unknown>;
|
deleteMany(args?: unknown): Promise<unknown>;
|
||||||
};
|
};
|
||||||
troop: {
|
troop: {
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ export interface NationPatchEffect {
|
|||||||
targetId?: NationId;
|
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 {
|
export interface LogEffect {
|
||||||
type: 'log';
|
type: 'log';
|
||||||
entry: LogEntryDraft;
|
entry: LogEntryDraft;
|
||||||
@@ -93,6 +106,7 @@ export type GeneralActionEffect<
|
|||||||
| GeneralAddEffect<TriggerState>
|
| GeneralAddEffect<TriggerState>
|
||||||
| CityPatchEffect
|
| CityPatchEffect
|
||||||
| NationPatchEffect
|
| NationPatchEffect
|
||||||
|
| DiplomacyPatchEffect
|
||||||
| LogEffect
|
| LogEffect
|
||||||
| NextTurnOverrideEffect;
|
| NextTurnOverrideEffect;
|
||||||
|
|
||||||
@@ -176,6 +190,17 @@ export const createNationPatchEffect = (
|
|||||||
...(targetId !== undefined ? { targetId } : {}),
|
...(targetId !== undefined ? { targetId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createDiplomacyPatchEffect = (
|
||||||
|
srcNationId: NationId,
|
||||||
|
destNationId: NationId,
|
||||||
|
patch: DiplomacyPatchEffect['patch']
|
||||||
|
): DiplomacyPatchEffect => ({
|
||||||
|
type: 'diplomacy:patch',
|
||||||
|
srcNationId,
|
||||||
|
destNationId,
|
||||||
|
patch,
|
||||||
|
});
|
||||||
|
|
||||||
export const createLogEffect = (
|
export const createLogEffect = (
|
||||||
message: string,
|
message: string,
|
||||||
options: Partial<Omit<LogEntryDraft, 'text'>> = {}
|
options: Partial<Omit<LogEntryDraft, 'text'>> = {}
|
||||||
@@ -224,6 +249,7 @@ export const resolveGeneralAction = <
|
|||||||
nations: [],
|
nations: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pendingEffects: GeneralActionEffect[] = [];
|
||||||
const [nextWorld, worldPatches] = produceWithPatches(
|
const [nextWorld, worldPatches] = produceWithPatches(
|
||||||
{
|
{
|
||||||
general: context.general,
|
general: context.general,
|
||||||
@@ -296,6 +322,9 @@ export const resolveGeneralAction = <
|
|||||||
case 'general:add':
|
case 'general:add':
|
||||||
createdGenerals.push(effect.general as General);
|
createdGenerals.push(effect.general as General);
|
||||||
break;
|
break;
|
||||||
|
case 'diplomacy:patch':
|
||||||
|
pendingEffects.push(effect);
|
||||||
|
break;
|
||||||
case 'general:patch':
|
case 'general:patch':
|
||||||
case 'city:patch':
|
case 'city:patch':
|
||||||
case 'nation:patch':
|
case 'nation:patch':
|
||||||
@@ -359,7 +388,7 @@ export const resolveGeneralAction = <
|
|||||||
nation: nextWorld.nation as Nation | null,
|
nation: nextWorld.nation as Nation | null,
|
||||||
nextTurnAt,
|
nextTurnAt,
|
||||||
logs,
|
logs,
|
||||||
effects: [], // 이제 effects는 직접 사용되지 않음 (이미 반영됨)
|
effects: pendingEffects,
|
||||||
};
|
};
|
||||||
if (nextWorld.city) {
|
if (nextWorld.city) {
|
||||||
resolution.city = nextWorld.city as 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 type { Constraint, ConstraintContext } from '../../../constraints/types.js';
|
||||||
import {
|
import {
|
||||||
beChief,
|
beChief,
|
||||||
|
disallowDiplomacyBetweenStatus,
|
||||||
existsDestNation,
|
existsDestNation,
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
occupiedCity,
|
occupiedCity,
|
||||||
@@ -12,7 +13,7 @@ import type {
|
|||||||
GeneralActionOutcome,
|
GeneralActionOutcome,
|
||||||
GeneralActionResolveContext,
|
GeneralActionResolveContext,
|
||||||
} from '../../engine.js';
|
} from '../../engine.js';
|
||||||
import { createLogEffect } from '../../engine.js';
|
import { createDiplomacyPatchEffect, createLogEffect } from '../../engine.js';
|
||||||
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
|
||||||
import type { TurnCommandEnv } from '../commandEnv.js';
|
import type { TurnCommandEnv } from '../commandEnv.js';
|
||||||
import type { NationTurnCommandSpec } from './index.js';
|
import type { NationTurnCommandSpec } from './index.js';
|
||||||
@@ -22,6 +23,9 @@ export interface DeclareWarArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '선전포고';
|
const ACTION_NAME = '선전포고';
|
||||||
|
// legacy 규칙: 선전포고 상태는 24턴 유지.
|
||||||
|
const DIPLOMACY_DECLARE = 1;
|
||||||
|
const DECLARE_TERM = 24;
|
||||||
|
|
||||||
const parseNationId = (raw: unknown): number | null => {
|
const parseNationId = (raw: unknown): number | null => {
|
||||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||||
@@ -52,18 +56,45 @@ export class ActionDefinition<
|
|||||||
suppliedCity(),
|
suppliedCity(),
|
||||||
beChief(),
|
beChief(),
|
||||||
existsDestNation(),
|
existsDestNation(),
|
||||||
|
disallowDiplomacyBetweenStatus({
|
||||||
|
0: '아국과 이미 교전중입니다.',
|
||||||
|
1: '아국과 이미 선포중입니다.',
|
||||||
|
7: '불가침국입니다.',
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
resolve(
|
||||||
_context: GeneralActionResolveContext<TriggerState>,
|
context: GeneralActionResolveContext<TriggerState>,
|
||||||
args: DeclareWarArgs
|
args: DeclareWarArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): 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 {
|
return {
|
||||||
effects: [
|
effects: [
|
||||||
|
createDiplomacyPatchEffect(nationId, args.destNationId, {
|
||||||
|
state: DIPLOMACY_DECLARE,
|
||||||
|
term: DECLARE_TERM,
|
||||||
|
}),
|
||||||
|
createDiplomacyPatchEffect(args.destNationId, nationId, {
|
||||||
|
state: DIPLOMACY_DECLARE,
|
||||||
|
term: DECLARE_TERM,
|
||||||
|
}),
|
||||||
createLogEffect(
|
createLogEffect(
|
||||||
`${ACTION_NAME}을 준비했습니다. (국가 ${args.destNationId})`,
|
`${ACTION_NAME}을 실행했습니다. (국가 ${args.destNationId})`,
|
||||||
{
|
{
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
|
|||||||
Reference in New Issue
Block a user