feat: preserve legacy nation turn execution
This commit is contained in:
@@ -11,18 +11,16 @@ export const do불가침제의 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
const meta = asRecord(ai.nation.meta);
|
||||
const recvAssist = Array.isArray(meta.recv_assist) ? meta.recv_assist : [];
|
||||
const recvAssist = Array.isArray(meta.recv_assist) ? meta.recv_assist : Object.values(asRecord(meta.recv_assist));
|
||||
const respAssist = asRecord(meta.resp_assist);
|
||||
const respAssistTry = asRecord(meta.resp_assist_try);
|
||||
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
|
||||
|
||||
const candidateList: Record<number, number> = {};
|
||||
for (const entry of recvAssist) {
|
||||
if (!Array.isArray(entry) || entry.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const destNationId = Number(entry[0]);
|
||||
const amount = Number(entry[1]);
|
||||
const entryRecord = asRecord(entry);
|
||||
const destNationId = Number(Array.isArray(entry) ? entry[0] : entryRecord['0']);
|
||||
const amount = Number(Array.isArray(entry) ? entry[1] : entryRecord['1']);
|
||||
if (!Number.isFinite(destNationId) || !Number.isFinite(amount)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
type TurnEngineTroopUpdateInput,
|
||||
type TurnEngineWorldStateUpdateInput,
|
||||
} from '@sammo-ts/infra';
|
||||
import { finalizeLogEntry, LogCategory, LogScope, type LogEntryDraft } from '@sammo-ts/logic';
|
||||
import {
|
||||
finalizeLogEntry,
|
||||
LogCategory,
|
||||
LogScope,
|
||||
sendMessage,
|
||||
type LogEntryDraft,
|
||||
type MessageRecordDraft,
|
||||
} from '@sammo-ts/logic';
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
|
||||
@@ -323,6 +330,7 @@ export const createDatabaseTurnHooks = async (
|
||||
deletedNationSnapshots,
|
||||
diplomacy,
|
||||
logs,
|
||||
messages,
|
||||
createdGenerals,
|
||||
createdNations,
|
||||
createdTroops,
|
||||
@@ -566,6 +574,33 @@ export const createDatabaseTurnHooks = async (
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const message of messages) {
|
||||
await sendMessage(
|
||||
{
|
||||
insertMessage: async (draft: MessageRecordDraft) => {
|
||||
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${draft.validUntil},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error('Failed to persist turn message.');
|
||||
}
|
||||
return id;
|
||||
},
|
||||
},
|
||||
message
|
||||
);
|
||||
}
|
||||
if (options?.reservedTurns && reservedTurnChanges) {
|
||||
await options.reservedTurns.persistChanges(prisma, reservedTurnChanges);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { City, LogEntryDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic';
|
||||
import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic';
|
||||
import { getNextTurnAt } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
||||
@@ -25,6 +25,7 @@ export interface GeneralTurnResult {
|
||||
nation?: Nation | null;
|
||||
nextTurnAt?: Date;
|
||||
logs?: LogEntryDraft[];
|
||||
messages?: MessageDraft[];
|
||||
patches?: {
|
||||
generals: Array<{ id: number; patch: Partial<TurnGeneral> }>;
|
||||
cities: Array<{ id: number; patch: Partial<City> }>;
|
||||
@@ -79,6 +80,7 @@ export interface TurnWorldChanges {
|
||||
deletedNationSnapshots: Array<{ nation: Nation; generalIds: number[]; removedAt: Date }>;
|
||||
diplomacy: TurnDiplomacy[];
|
||||
logs: LogEntryDraft[];
|
||||
messages: MessageDraft[];
|
||||
createdGenerals: TurnGeneral[];
|
||||
createdNations: Nation[];
|
||||
createdTroops: Troop[];
|
||||
@@ -247,6 +249,7 @@ export class InMemoryTurnWorld {
|
||||
removedAt: Date;
|
||||
}> = [];
|
||||
private readonly logs: LogEntryDraft[] = [];
|
||||
private readonly messages: MessageDraft[] = [];
|
||||
private readonly scenarioConfig: ScenarioConfig;
|
||||
private checkpoint?: TurnCheckpoint;
|
||||
private state: TurnWorldState;
|
||||
@@ -587,6 +590,9 @@ export class InMemoryTurnWorld {
|
||||
if (result.logs && result.logs.length > 0) {
|
||||
this.logs.push(...result.logs);
|
||||
}
|
||||
if (result.messages && result.messages.length > 0) {
|
||||
this.messages.push(...result.messages);
|
||||
}
|
||||
if (result.patches) {
|
||||
for (const patch of result.patches.generals) {
|
||||
const target = this.generals.get(patch.id);
|
||||
@@ -744,6 +750,7 @@ export class InMemoryTurnWorld {
|
||||
const deletedNations = Array.from(this.deletedNationIds);
|
||||
const deletedNationSnapshots = this.deletedNationSnapshots.slice();
|
||||
const logs = this.logs.slice();
|
||||
const messages = this.messages.slice();
|
||||
|
||||
return {
|
||||
generals,
|
||||
@@ -756,6 +763,7 @@ export class InMemoryTurnWorld {
|
||||
deletedNationSnapshots,
|
||||
diplomacy,
|
||||
logs,
|
||||
messages,
|
||||
createdGenerals,
|
||||
createdNations,
|
||||
createdTroops,
|
||||
@@ -782,6 +790,7 @@ export class InMemoryTurnWorld {
|
||||
for (const id of changes.deletedNations) this.deletedNationIds.delete(id);
|
||||
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
|
||||
this.logs.splice(0, changes.logs.length);
|
||||
this.messages.splice(0, changes.messages.length);
|
||||
}
|
||||
|
||||
consumeDirtyState(): TurnWorldChanges {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
|
||||
|
||||
const decrementLimit = (value: unknown): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.floor(value) - 1);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.max(0, Math.floor(parsed) - 1);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// ref preUpdateMonthly(): 전략 제한과 외교 제한은 매 월턴마다 1씩 감소한다.
|
||||
export const createNationTurnMonthlyHandler = (options: {
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
}): TurnCalendarHandler => ({
|
||||
onMonthChanged: () => {
|
||||
const world = options.getWorld();
|
||||
if (!world) {
|
||||
return;
|
||||
}
|
||||
for (const nation of world.listNations()) {
|
||||
const meta = asRecord(nation.meta);
|
||||
world.updateNation(nation.id, {
|
||||
meta: {
|
||||
...nation.meta,
|
||||
strategic_cmd_limit: decrementLimit(meta.strategic_cmd_limit),
|
||||
surlimit: decrementLimit(meta.surlimit),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
GeneralActionDefinition,
|
||||
LogEntryDraft,
|
||||
MapDefinition,
|
||||
MessageDraft,
|
||||
Nation,
|
||||
ScenarioConfig,
|
||||
ScenarioMeta,
|
||||
@@ -100,6 +101,40 @@ const serializeSeed = (...values: Array<string | number>): string =>
|
||||
|
||||
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
||||
|
||||
type NationLastTurn = {
|
||||
command: string;
|
||||
arg?: Record<string, unknown>;
|
||||
term?: number;
|
||||
seq?: number;
|
||||
};
|
||||
|
||||
const nationLastTurnKey = (officerLevel: number): string => `turn_last_${officerLevel}`;
|
||||
|
||||
const normalizeLastTurn = (value: unknown): NationLastTurn => {
|
||||
const raw = asRecord(value);
|
||||
return {
|
||||
command: typeof raw.command === 'string' ? raw.command : '휴식',
|
||||
...(asRecord(raw.arg) && Object.keys(asRecord(raw.arg)).length > 0 ? { arg: asRecord(raw.arg) } : undefined),
|
||||
...(typeof raw.term === 'number' && Number.isFinite(raw.term) ? { term: Math.floor(raw.term) } : undefined),
|
||||
...(typeof raw.seq === 'number' && Number.isFinite(raw.seq) ? { seq: Math.floor(raw.seq) } : undefined),
|
||||
};
|
||||
};
|
||||
|
||||
const sameArgs = (left: Record<string, unknown> | undefined, right: Record<string, unknown>): boolean =>
|
||||
JSON.stringify(left ?? {}) === JSON.stringify(right);
|
||||
|
||||
const readNextAvailableTurn = (nation: Nation, actionName: string): number | null => {
|
||||
const raw = asRecord(nation.meta)[`next_execute_${actionName}`];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
return Math.floor(raw);
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
|
||||
const value = meta[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -214,7 +249,6 @@ const buildUniqueLotteryRunner = (options: {
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
type WorldView = {
|
||||
getGeneralById(id: number): TurnGeneral | null;
|
||||
getCityById(id: number): City | null;
|
||||
@@ -573,6 +607,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
...(options.unitSet ? { unitSet: options.unitSet } : {}),
|
||||
};
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const messages: MessageDraft[] = [];
|
||||
const patches = {
|
||||
generals: [] as Array<{ id: number; patch: Partial<TurnGeneral> }>,
|
||||
cities: [] as Array<{ id: number; patch: Partial<City> }>,
|
||||
@@ -592,6 +627,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
let currentNation = context.nation ?? null;
|
||||
|
||||
const runAction = (
|
||||
kind: 'nation' | 'general',
|
||||
definitionMap: Map<string, GeneralActionDefinition>,
|
||||
fallbackDefinition: GeneralActionDefinition,
|
||||
command: ReservedTurnEntry,
|
||||
@@ -644,6 +680,19 @@ export const createReservedTurnHandler = async (options: {
|
||||
const meta = result.kind === 'deny' ? { constraintName: result.constraintName } : undefined;
|
||||
logs.push(createActionLog(reason, meta));
|
||||
}
|
||||
if (kind === 'nation' && !usedFallback && currentNation) {
|
||||
const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth);
|
||||
const nextAvailableTurn = readNextAvailableTurn(currentNation, definition.name);
|
||||
if (nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn) {
|
||||
const remainTurn = nextAvailableTurn - currentYearMonth;
|
||||
definition = fallbackDefinition;
|
||||
actionArgs = definition.parseArgs({}) ?? {};
|
||||
actionKey = definition.key;
|
||||
usedFallback = true;
|
||||
blockedReason = `${remainTurn}턴 더 기다려야 합니다`;
|
||||
logs.push(createActionLog(blockedReason));
|
||||
}
|
||||
}
|
||||
|
||||
const seedBase = buildSeedBase(context.world);
|
||||
const buildRng = (key: string) => {
|
||||
@@ -694,6 +743,8 @@ export const createReservedTurnHandler = async (options: {
|
||||
definition = fallbackDefinition;
|
||||
actionArgs = definition.parseArgs({}) ?? {};
|
||||
actionKey = definition.key;
|
||||
usedFallback = true;
|
||||
blockedReason = '예약된 명령을 실행하지 못했습니다.';
|
||||
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
|
||||
baseContext = {
|
||||
general: currentGeneral,
|
||||
@@ -704,6 +755,52 @@ export const createReservedTurnHandler = async (options: {
|
||||
specificContext = baseContext;
|
||||
}
|
||||
const actionContext = specificContext ?? baseContext;
|
||||
const executionDefinition = definition as unknown as {
|
||||
getPreReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
||||
getPostReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
||||
getStackSequence?: (context: ActionContextBase, args: unknown) => number | null;
|
||||
};
|
||||
const preReqTurn =
|
||||
kind === 'nation' && !usedFallback
|
||||
? Math.max(0, Math.floor(executionDefinition.getPreReqTurn?.(actionContext, actionArgs) ?? 0))
|
||||
: 0;
|
||||
const postReqTurn =
|
||||
kind === 'nation' && !usedFallback
|
||||
? Math.max(0, Math.floor(executionDefinition.getPostReqTurn?.(actionContext, actionArgs) ?? 0))
|
||||
: 0;
|
||||
|
||||
if (kind === 'nation' && !usedFallback && currentNation && preReqTurn > 0) {
|
||||
const metaKey = nationLastTurnKey(currentGeneral.officerLevel);
|
||||
const lastTurn = normalizeLastTurn(asRecord(currentNation.meta)[metaKey]);
|
||||
const stackSequence = executionDefinition.getStackSequence?.(actionContext, actionArgs) ?? null;
|
||||
const sequenceChanged =
|
||||
stackSequence !== null && (lastTurn.seq === undefined || lastTurn.seq < stackSequence);
|
||||
const continuing =
|
||||
lastTurn.command === definition.name &&
|
||||
sameArgs(lastTurn.arg, actionArgsRecord) &&
|
||||
!sequenceChanged;
|
||||
const nextTerm = continuing ? (lastTurn.term ?? 0) + 1 : 1;
|
||||
|
||||
if (!continuing || (lastTurn.term ?? 0) < preReqTurn) {
|
||||
const nextLastTurn: NationLastTurn = {
|
||||
command: definition.name,
|
||||
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
||||
term: nextTerm,
|
||||
...(stackSequence !== null ? { seq: stackSequence } : undefined),
|
||||
};
|
||||
const nextNation: Nation = {
|
||||
...currentNation,
|
||||
meta: {
|
||||
...currentNation.meta,
|
||||
[metaKey]: nextLastTurn,
|
||||
} as Nation['meta'],
|
||||
};
|
||||
currentNation = nextNation;
|
||||
worldOverlay?.syncNation(nextNation);
|
||||
logs.push(createActionLog(`${definition.name} 수행중... (${nextTerm}/${preReqTurn + 1})`));
|
||||
return { actionKey, usedFallback, blockedReason };
|
||||
}
|
||||
}
|
||||
|
||||
const resolution = resolveGeneralAction(
|
||||
definition,
|
||||
@@ -718,12 +815,39 @@ export const createReservedTurnHandler = async (options: {
|
||||
currentGeneral = resolution.general as TurnGeneral;
|
||||
currentCity = resolution.city ?? currentCity;
|
||||
currentNation = resolution.nation ?? currentNation;
|
||||
if (kind === 'nation' && !usedFallback && definition.countsAsInheritanceActiveAction) {
|
||||
const meta = { ...currentGeneral.meta };
|
||||
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
|
||||
meta.inherit_active_action = active + 1;
|
||||
currentGeneral = { ...currentGeneral, meta };
|
||||
}
|
||||
|
||||
if (!currentNation && resolution.created?.nations) {
|
||||
currentNation =
|
||||
(resolution.created.nations as Nation[]).find((n) => n.id === currentGeneral.nationId) ??
|
||||
currentNation;
|
||||
}
|
||||
if (kind === 'nation' && !usedFallback && currentNation) {
|
||||
const metaKey = nationLastTurnKey(currentGeneral.officerLevel);
|
||||
const nextMeta: Record<string, unknown> = {
|
||||
...currentNation.meta,
|
||||
[metaKey]: {
|
||||
command: definition.name,
|
||||
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
||||
term: 0,
|
||||
} satisfies NationLastTurn,
|
||||
};
|
||||
if (postReqTurn > 0) {
|
||||
nextMeta[`next_execute_${definition.name}`] =
|
||||
joinYearMonth(context.world.currentYear, context.world.currentMonth) +
|
||||
postReqTurn -
|
||||
preReqTurn;
|
||||
}
|
||||
currentNation = {
|
||||
...currentNation,
|
||||
meta: nextMeta as Nation['meta'],
|
||||
};
|
||||
}
|
||||
|
||||
logs.push(...resolution.logs);
|
||||
if (worldOverlay) {
|
||||
@@ -738,15 +862,16 @@ export const createReservedTurnHandler = async (options: {
|
||||
|
||||
if (resolution.effects.length > 0) {
|
||||
for (const effect of resolution.effects) {
|
||||
if (effect.type !== 'diplomacy:patch') {
|
||||
continue;
|
||||
if (effect.type === 'message:add') {
|
||||
messages.push(effect.draft);
|
||||
} else if (effect.type === 'diplomacy:patch') {
|
||||
diplomacyPatches.push({
|
||||
srcNationId: effect.srcNationId,
|
||||
destNationId: effect.destNationId,
|
||||
patch: effect.patch,
|
||||
});
|
||||
worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch);
|
||||
}
|
||||
diplomacyPatches.push({
|
||||
srcNationId: effect.srcNationId,
|
||||
destNationId: effect.destNationId,
|
||||
patch: effect.patch,
|
||||
});
|
||||
worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,7 +951,12 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
}
|
||||
|
||||
return { nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined, actionKey, usedFallback, blockedReason };
|
||||
return {
|
||||
nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined,
|
||||
actionKey,
|
||||
usedFallback,
|
||||
blockedReason,
|
||||
};
|
||||
};
|
||||
|
||||
if (currentNation && currentGeneral.officerLevel >= 5) {
|
||||
@@ -860,7 +990,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
nationAiState = ai.getDebugState();
|
||||
}
|
||||
const nationResult = runAction(nationDefinitions, nationFallback, nationCommand, false);
|
||||
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
|
||||
options.onActionResolved?.({
|
||||
kind: 'nation',
|
||||
generalId: currentGeneral.id,
|
||||
@@ -900,7 +1030,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
generalAiState = ai.getDebugState();
|
||||
}
|
||||
const generalResult = runAction(generalDefinitions, generalFallback, generalCommand, true);
|
||||
const generalResult = runAction('general', generalDefinitions, generalFallback, generalCommand, true);
|
||||
options.onActionResolved?.({
|
||||
kind: 'general',
|
||||
generalId: currentGeneral.id,
|
||||
@@ -935,6 +1065,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
nation: currentNation,
|
||||
nextTurnAt,
|
||||
logs,
|
||||
...(messages.length > 0 ? { messages } : undefined),
|
||||
patches,
|
||||
...(diplomacyPatches.length > 0 ? { diplomacyPatches } : undefined),
|
||||
created:
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js';
|
||||
import { createGatewayProfileGate } from './gatewayProfileGate.js';
|
||||
import { composeCalendarHandlers } from './calendarHandlers.js';
|
||||
import { createIncomeHandler } from './incomeHandler.js';
|
||||
import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js';
|
||||
import { createFrontStateHandler } from './frontStateHandler.js';
|
||||
import { createReservedTurnHandler } from './reservedTurnHandler.js';
|
||||
import { createReservedTurnStore } from './reservedTurnStore.js';
|
||||
@@ -124,6 +125,9 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
scenarioConfig: snapshot.scenarioConfig,
|
||||
nationTraits: nationTraitMap,
|
||||
});
|
||||
const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({
|
||||
getWorld: () => worldRef,
|
||||
});
|
||||
const frontStateHandler = createFrontStateHandler({
|
||||
getWorld: () => worldRef,
|
||||
map: snapshot.map ?? null,
|
||||
@@ -141,6 +145,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
});
|
||||
const calendarHandler = composeCalendarHandlers(
|
||||
options.calendarHandler ?? unification?.handler,
|
||||
nationTurnMonthlyHandler,
|
||||
incomeHandler,
|
||||
frontStateHandler,
|
||||
tournamentAutoStartHandler,
|
||||
|
||||
Reference in New Issue
Block a user