From 85f134fd121a3e1ac19504837437b2b5c242745f Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 06:17:32 +0000 Subject: [PATCH] feat: preserve legacy nation turn execution --- .../src/turn/ai/generalAi/nation/diplomacy.ts | 10 +- app/game-engine/src/turn/databaseHooks.ts | 37 +++- app/game-engine/src/turn/inMemoryWorld.ts | 11 +- .../src/turn/nationTurnMonthlyHandler.ts | 38 ++++ .../src/turn/reservedTurnHandler.ts | 155 ++++++++++++-- app/game-engine/src/turn/turnDaemon.ts | 5 + .../test/helpers/turnTestHarness.ts | 13 +- .../test/nationTurnCompatibility.test.ts | 194 ++++++++++++++++++ package.json | 4 +- packages/logic/src/actions/definition.ts | 5 + packages/logic/src/actions/engine.ts | 13 ++ .../logic/src/actions/turn/nation/che_감축.ts | 35 +++- .../src/actions/turn/nation/che_국기변경.ts | 12 +- .../src/actions/turn/nation/che_국호변경.ts | 6 + .../logic/src/actions/turn/nation/che_급습.ts | 30 ++- .../actions/turn/nation/che_무작위수도이전.ts | 18 +- .../src/actions/turn/nation/che_물자원조.ts | 82 +++++++- .../src/actions/turn/nation/che_백성동원.ts | 30 ++- .../src/actions/turn/nation/che_불가침제의.ts | 69 ++++++- .../actions/turn/nation/che_불가침파기제의.ts | 68 +++++- .../logic/src/actions/turn/nation/che_수몰.ts | 34 ++- .../src/actions/turn/nation/che_의병모집.ts | 13 ++ .../src/actions/turn/nation/che_이호경식.ts | 30 ++- .../src/actions/turn/nation/che_종전제의.ts | 64 +++++- .../logic/src/actions/turn/nation/che_증축.ts | 19 ++ .../logic/src/actions/turn/nation/che_천도.ts | 99 ++++++--- .../src/actions/turn/nation/che_초토화.ts | 5 + .../src/actions/turn/nation/che_피장파장.ts | 93 ++++++++- .../src/actions/turn/nation/che_필사즉생.ts | 34 ++- .../logic/src/actions/turn/nation/che_허보.ts | 34 ++- .../src/actions/turn/nation/eventResearch.ts | 14 +- packages/logic/src/triggers/types.ts | 11 +- .../test/actions/turn/nationMissing.test.ts | 70 ++++++- pnpm-lock.yaml | 70 ++++--- tools/compare-command-constraints.mjs | 55 ++--- tools/compare-command-logs.mjs | 149 +++++++------- 36 files changed, 1346 insertions(+), 283 deletions(-) create mode 100644 app/game-engine/src/turn/nationTurnMonthlyHandler.ts create mode 100644 app/game-engine/test/nationTurnCompatibility.test.ts diff --git a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts index 10cef81..71ccda7 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts @@ -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 = {}; 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; } diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 9245fdc..8c36f7e 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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>` + 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); } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 531cef3..bdf02a3 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -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 }>; cities: Array<{ id: number; patch: Partial }>; @@ -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 { diff --git a/app/game-engine/src/turn/nationTurnMonthlyHandler.ts b/app/game-engine/src/turn/nationTurnMonthlyHandler.ts new file mode 100644 index 0000000..00ebb82 --- /dev/null +++ b/app/game-engine/src/turn/nationTurnMonthlyHandler.ts @@ -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), + }, + }); + } + }, +}); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 0b79097..22eb6b5 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -5,6 +5,7 @@ import type { GeneralActionDefinition, LogEntryDraft, MapDefinition, + MessageDraft, Nation, ScenarioConfig, ScenarioMeta, @@ -100,6 +101,40 @@ const serializeSeed = (...values: Array): string => const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1; +type NationLastTurn = { + command: string; + arg?: Record; + 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 | undefined, right: Record): 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, 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 }>, cities: [] as Array<{ id: number; patch: Partial }>, @@ -592,6 +627,7 @@ export const createReservedTurnHandler = async (options: { let currentNation = context.nation ?? null; const runAction = ( + kind: 'nation' | 'general', definitionMap: Map, 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 = { + ...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: diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 62d4fbb..0b0a9c3 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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, diff --git a/app/game-engine/test/helpers/turnTestHarness.ts b/app/game-engine/test/helpers/turnTestHarness.ts index 65bba48..86142f7 100644 --- a/app/game-engine/test/helpers/turnTestHarness.ts +++ b/app/game-engine/test/helpers/turnTestHarness.ts @@ -12,6 +12,7 @@ import { composeCalendarHandlers } from '../../src/turn/calendarHandlers.js'; import { createIncomeHandler } from '../../src/turn/incomeHandler.js'; import { createNpcTaxHandler } from '../../src/turn/npcTaxHandler.js'; import { createFrontStateHandler } from '../../src/turn/frontStateHandler.js'; +import { createNationTurnMonthlyHandler } from '../../src/turn/nationTurnMonthlyHandler.js'; export const createMockPrisma = (initialGeneralRows: any[] = []) => { let generalRows = [...initialGeneralRows]; @@ -126,8 +127,12 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) => getWorld: () => worldRef.current, map: options.map, }); + const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({ + getWorld: () => worldRef.current, + }); const calendarHandler = composeCalendarHandlers( + nationTurnMonthlyHandler, incomeHandler, npcTaxHandler, frontStateHandler, @@ -194,8 +199,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) => runUntil, getCollectedLogs: () => [...collectedLogs], getCollectedLogsCount: () => collectedLogs.length, - getCollectedLogsRange: (start: number, end?: number) => - collectedLogs.slice(start, end ?? collectedLogs.length), + getCollectedLogsRange: (start: number, end?: number) => collectedLogs.slice(start, end ?? collectedLogs.length), getAndClearCollectedLogs: () => collectedLogs.splice(0, collectedLogs.length), }; }; @@ -238,10 +242,7 @@ const formatCity = (city: ReturnType) => { }; }; -export const createWorldDebugger = ( - getWorld: () => InMemoryTurnWorld | null, - watchTargets: DebugWatchTargets = {} -) => { +export const createWorldDebugger = (getWorld: () => InMemoryTurnWorld | null, watchTargets: DebugWatchTargets = {}) => { const dumpWorldSummary = (label = 'WORLD') => { const world = getWorld(); if (!world) { diff --git a/app/game-engine/test/nationTurnCompatibility.test.ts b/app/game-engine/test/nationTurnCompatibility.test.ts new file mode 100644 index 0000000..183fe78 --- /dev/null +++ b/app/game-engine/test/nationTurnCompatibility.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; +import type { TurnSchedule } from '@sammo-ts/logic'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createNationTurnMonthlyHandler } from '../src/turn/nationTurnMonthlyHandler.js'; +import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; +import { createTurnTestHarness } from './helpers/turnTestHarness.js'; + +const mockDate = new Date('0189-01-01T00:00:00Z'); + +const createChief = (): TurnGeneral => ({ + id: 1, + name: '군주', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 90, strength: 80, intelligence: 70 }, + turnTime: mockDate, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 800 }, + officerLevel: 12, + experience: 0, + dedication: 0, + injury: 0, + gold: 100_000, + rice: 100_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, +}); + +describe('레거시 사령부 턴 실행 호환성', () => { + it('화시병 연구는 선행 11턴을 누적한 뒤 12번째 턴에만 완료한다', async () => { + const cities = buildLargeTestCities(); + for (const city of cities) { + city.nationId = city.id === 1 ? 1 : city.id === 2 ? 2 : 0; + } + const snapshot: TurnWorldSnapshot = { + generals: [createChief()], + cities, + nations: [ + { + id: 1, + name: '테스트국', + color: '#aa0000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 1_000_000, + rice: 1_000_000, + power: 0, + level: 1, + typeCode: 'large_test_map_def', + meta: { can_화시병사용: 0 }, + }, + { + id: 2, + name: '상대국', + color: '#0000aa', + capitalCityId: 2, + chiefGeneralId: null, + gold: 1_000_000, + rice: 1_000_000, + power: 0, + level: 1, + typeCode: 'large_test_map_def', + meta: {}, + }, + ], + troops: [], + diplomacy: [ + { fromNationId: 1, toNationId: 2, state: 0, term: 1200, dead: 0, meta: {} }, + { fromNationId: 2, toNationId: 1, state: 0, term: 1200, dead: 0, meta: {} }, + ], + events: [], + initialEvents: [], + map: LARGE_TEST_MAP, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + openingPartYear: 3, + develCost: 10, + baseGold: 1000, + baseRice: 1000, + maxResourceActionAmount: 10000, + maxTechLevel: 12000, + }, + environment: { mapName: 'large_test_map', unitSet: 'default' }, + }, + scenarioMeta: { startYear: 189 } as never, + unitSet: {} as never, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 189, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: mockDate, + meta: { seed: 1 }, + }; + const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + const resolvedActions: Array<{ requestedAction: string; actionKey: string; blockedReason?: string }> = []; + const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({ + snapshot, + state, + schedule, + map: LARGE_TEST_MAP, + reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 }, + onActionResolved: (event) => { + if (event.kind === 'nation') { + resolvedActions.push(event); + } + }, + }); + const turns = reservedTurnStore.getNationTurns(1, 12); + turns.fill({ action: 'event_화시병연구', args: {} }); + + for (let index = 1; index <= 11; index += 1) { + await runOneTick(); + const nation = world.getNationById(1)!; + expect(nation.meta.can_화시병사용 ?? 0).toBe(0); + expect(nation.meta.turn_last_12).toMatchObject({ + command: '화시병 연구', + term: index, + }); + } + + await runOneTick(); + const nation = world.getNationById(1)!; + expect(nation.meta.can_화시병사용).toBe(1); + expect(nation.meta.turn_last_12).toMatchObject({ + command: '화시병 연구', + term: 0, + }); + expect(world.getGeneralById(1)!.meta.inherit_active_action).toBe(1); + expect(world.getGeneralById(1)!.experience).toBe(60); + expect(world.getGeneralById(1)!.dedication).toBe(60); + + reservedTurnStore.getNationTurns(1, 12)[0] = { + action: 'che_종전제의', + args: { destNationId: 2 }, + }; + await runOneTick(); + expect(resolvedActions.at(-1)?.blockedReason).toBeUndefined(); + expect(resolvedActions.at(-1)).toMatchObject({ + requestedAction: 'che_종전제의', + actionKey: 'che_종전제의', + }); + expect(world.peekDirtyState().messages).toContainEqual( + expect.objectContaining({ + msgType: 'diplomacy', + dest: expect.objectContaining({ nationId: 2 }), + option: { action: 'stopWar', deletable: false }, + }) + ); + }); + + it('월 경계마다 전략·외교 제한을 1씩 감소시키고 0 아래로 내리지 않는다', () => { + const updates: Array<{ id: number; patch: Record }> = []; + const nations = [ + { id: 1, meta: { strategic_cmd_limit: 2, surlimit: '1', keep: true } }, + { id: 2, meta: { strategic_cmd_limit: 0, surlimit: 0 } }, + ]; + const handler = createNationTurnMonthlyHandler({ + getWorld: () => + ({ + listNations: () => nations, + updateNation: (id: number, patch: Record) => updates.push({ id, patch }), + }) as never, + }); + + handler.onMonthChanged?.({} as never); + + expect(updates).toEqual([ + { + id: 1, + patch: { meta: { strategic_cmd_limit: 1, surlimit: 0, keep: true } }, + }, + { + id: 2, + patch: { meta: { strategic_cmd_limit: 0, surlimit: 0 } }, + }, + ]); + }); +}); diff --git a/package.json b/package.json index 7b67546..a5b87ff 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "dev": "turbo dev", "build:server": "pnpm --filter ./tools/build-scripts build:server --", "generate:resource-schemas": "pnpm --filter @sammo-ts/tools-scripts generate:resource-schemas", - "validate:resources": "pnpm --filter @sammo-ts/tools-scripts validate:resources" + "validate:resources": "pnpm --filter @sammo-ts/tools-scripts validate:resources", + "check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -31,6 +32,7 @@ "tsdown": "^0.22.14", "turbo": "^2.10.6", "typescript": "^7.0.2", + "typescript-legacy": "npm:typescript@5.9.3", "typescript-eslint": "^8.65.0", "vue-eslint-parser": "^10.4.1" } diff --git a/packages/logic/src/actions/definition.ts b/packages/logic/src/actions/definition.ts index 1e8d05b..5db420c 100644 --- a/packages/logic/src/actions/definition.ts +++ b/packages/logic/src/actions/definition.ts @@ -14,5 +14,10 @@ export interface GeneralActionDefinition< // 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다. buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[]; buildConstraints(ctx: ConstraintContext, args: Args): Constraint[]; + // NationCommand::addTermStack()/setNextAvailable() 호환 실행 메타데이터. + getPreReqTurn?(context: Context, args: Args): number; + getPostReqTurn?(context: Context, args: Args): number; + getStackSequence?(context: Context, args: Args): number | null; + readonly countsAsInheritanceActiveAction?: boolean; resolve(context: Context, args: Args): GeneralActionOutcome; } diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index 5feda1a..185b726 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -12,6 +12,7 @@ import type { import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; import { getNextTurnAt, type TurnSchedule } from '@sammo-ts/logic/turn/calendar.js'; import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import type { MessageDraft } from '@sammo-ts/logic/messages/message.js'; enablePatches(); @@ -86,6 +87,11 @@ export interface NextTurnOverrideEffect { nextTurnAt: Date; } +export interface MessageAddEffect { + type: 'message:add'; + draft: MessageDraft; +} + export type GeneralActionEffect = | GeneralPatchEffect | GeneralAddEffect @@ -94,6 +100,7 @@ export type GeneralActionEffect { @@ -203,6 +210,11 @@ export const createLogEffect = (message: string, options: Partial ({ + type: 'message:add', + draft, +}); + export const createNextTurnOverrideEffect = (nextTurnAt: Date): NextTurnOverrideEffect => ({ type: 'schedule:override', nextTurnAt, @@ -301,6 +313,7 @@ export const resolveGeneralAction = ({ }, }); -const reqDestCityValue = ( - comp: '>' | '<' | '>=' | '<=', - required: number | 'origin', - reason: string -): Constraint => ({ +const reqDestCityValue = (comp: '>' | '<' | '>=' | '<=', required: number | 'origin', reason: string): Constraint => ({ name: 'reqDestCityValue', requires: (ctx) => - ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }, { kind: 'env', key: 'map' }] : [], + ctx.nationId !== undefined + ? [ + { kind: 'nation', id: ctx.nationId }, + { kind: 'env', key: 'map' }, + ] + : [], test: (ctx: ConstraintContext, view: StateView) => { if (ctx.nationId === undefined) { return { kind: 'deny', reason }; @@ -71,8 +72,7 @@ const reqDestCityValue = ( required === 'origin' ? ((view.get({ kind: 'env', key: 'map' }) as MapDefinition | undefined)?.cities.find( (mapCity) => mapCity.id === nation.capitalCityId - )?.level ?? - 0) + )?.level ?? 0) : required; const level = city.level; const allow = @@ -95,6 +95,7 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = 'che_감축'; public readonly name = ACTION_NAME; + public readonly countsAsInheritanceActiveAction = true; constructor(private readonly env: TurnCommandEnv) {} @@ -122,6 +123,15 @@ export class ActionDefinition< ]; } + getPreReqTurn(): number { + return 5; + } + + getStackSequence(context: ReduceCityResolveContext): number { + const value = context.nation?.meta.capset; + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0; + } + resolve( context: ReduceCityResolveContext, _args: ReduceCityArgs @@ -163,6 +173,10 @@ export class ActionDefinition< { gold: nation.gold + recoverAmount, rice: nation.rice + recoverAmount, + meta: { + ...nation.meta, + capset: (typeof nation.meta.capset === 'number' ? nation.meta.capset : 0) + 1, + }, }, nation.id ), @@ -194,6 +208,11 @@ export class ActionDefinition< format: LogFormat.YEAR_MONTH, } ), + createLogEffect(`${destCityName}${josaUl} ${ACTION_NAME}`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), // General Action Log createLogEffect(`${destCityName}${josaUl} ${ACTION_NAME}했습니다.`, { scope: LogScope.GENERAL, diff --git a/packages/logic/src/actions/turn/nation/che_국기변경.ts b/packages/logic/src/actions/turn/nation/che_국기변경.ts index 10116f5..fd4d05e 100644 --- a/packages/logic/src/actions/turn/nation/che_국기변경.ts +++ b/packages/logic/src/actions/turn/nation/che_국기변경.ts @@ -54,7 +54,11 @@ const NATION_COLORS = [ ]; const ARGS_SCHEMA = z.object({ - colorType: z.number().int().min(0).max(NATION_COLORS.length - 1), + colorType: z + .number() + .int() + .min(0) + .max(NATION_COLORS.length - 1), }); export type ChangeFlagArgs = z.infer; @@ -63,6 +67,7 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = 'che_국기변경'; public readonly name = ACTION_NAME; + public readonly countsAsInheritanceActiveAction = true; parseArgs(raw: unknown): ChangeFlagArgs | null { return parseArgsWithSchema(ARGS_SCHEMA, raw); @@ -141,6 +146,11 @@ export class ActionDefinition< format: LogFormat.YEAR_MONTH, } ), + createLogEffect(`국기를 변경`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), // General Action Log createLogEffect(`국기를 변경하였습니다`, { scope: LogScope.GENERAL, diff --git a/packages/logic/src/actions/turn/nation/che_국호변경.ts b/packages/logic/src/actions/turn/nation/che_국호변경.ts index ac9926f..3c1a540 100644 --- a/packages/logic/src/actions/turn/nation/che_국호변경.ts +++ b/packages/logic/src/actions/turn/nation/che_국호변경.ts @@ -27,6 +27,7 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = 'che_국호변경'; public readonly name = ACTION_NAME; + public readonly countsAsInheritanceActiveAction = true; parseArgs(raw: unknown): ChangeNationNameArgs | null { return parseArgsWithSchema(ARGS_SCHEMA, raw); @@ -101,6 +102,11 @@ export class ActionDefinition< category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, }), + createLogEffect(`국호를 ${newNationName}${josaRo} 변경`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), // General Action Log createLogEffect(`국호를 ${newNationName}${josaRo} 변경합니다.`, { scope: LogScope.GENERAL, diff --git a/packages/logic/src/actions/turn/nation/che_급습.ts b/packages/logic/src/actions/turn/nation/che_급습.ts index ac755ac..efa5adf 100644 --- a/packages/logic/src/actions/turn/nation/che_급습.ts +++ b/packages/logic/src/actions/turn/nation/che_급습.ts @@ -53,10 +53,19 @@ const TERM_REDUCE = 3; export class CommandResolver { private readonly pipeline: GeneralActionPipeline; - constructor(modules: Array | null | undefined>) { + constructor( + modules: Array | null | undefined>, + private readonly initialNationGenLimit = 10 + ) { this.pipeline = new GeneralActionPipeline(modules); } + getPostReqTurn(context: RaidResolveContext): number { + const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit); + const base = Math.round(Math.sqrt(genCount * 16) * 10); + return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base)); + } + getGlobalDelay(context: RaidResolveContext): number { return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY)); } @@ -69,8 +78,12 @@ export class ActionResolver< readonly key = 'che_급습'; private readonly command: CommandResolver; - constructor(modules: Array | null | undefined>) { - this.command = new CommandResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.command = new CommandResolver(modules, initialNationGenLimit); + } + + getPostReqTurn(context: RaidResolveContext): number { + return this.command.getPostReqTurn(context); } resolve(context: RaidResolveContext, _args: RaidArgs): GeneralActionOutcome { @@ -167,8 +180,8 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor(modules: Array | null | undefined>) { - this.resolver = new ActionResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.resolver = new ActionResolver(modules, initialNationGenLimit); } parseArgs(raw: unknown): RaidArgs | null { @@ -191,6 +204,10 @@ export class ActionDefinition< ]; } + getPostReqTurn(context: RaidResolveContext): number { + return this.resolver.getPostReqTurn(context); + } + resolve(context: RaidResolveContext, args: RaidArgs): GeneralActionOutcome { return this.resolver.resolve(context, args); } @@ -235,5 +252,6 @@ export const commandSpec: NationTurnCommandSpec = { reqArg: true, availabilityArgs: { destNationId: 0 }, argsSchema: ARGS_SCHEMA, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit), }; diff --git a/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts b/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts index ead2811..f0743c5 100644 --- a/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts +++ b/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts @@ -1,6 +1,12 @@ import type { GeneralTriggerState, City, General } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; -import { beLord, occupiedCity, suppliedCity, beOpeningPart, reqNationAuxValue } from '@sammo-ts/logic/constraints/presets.js'; +import { + beLord, + occupiedCity, + suppliedCity, + beOpeningPart, + reqNationAuxValue, +} from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionEffect, @@ -39,6 +45,7 @@ export class ActionDefinition< > { public readonly key = 'che_무작위수도이전'; public readonly name = ACTION_NAME; + public readonly countsAsInheritanceActiveAction = true; parseArgs(_raw: unknown): RandomMoveCapitalArgs | null { return {}; @@ -57,6 +64,10 @@ export class ActionDefinition< ]; } + getPreReqTurn(): number { + return 1; + } + resolve( context: RandomMoveCapitalResolveContext, _args: RandomMoveCapitalArgs @@ -142,6 +153,11 @@ export class ActionDefinition< format: LogFormat.YEAR_MONTH, } ), + createLogEffect(`${destCityName}${josaRo} ${ACTION_NAME}`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), // General Action Log createLogEffect(`${destCityName}${josaRo} 국가를 옮겼습니다.`, { scope: LogScope.GENERAL, diff --git a/packages/logic/src/actions/turn/nation/che_물자원조.ts b/packages/logic/src/actions/turn/nation/che_물자원조.ts index 993b211..ab003ae 100644 --- a/packages/logic/src/actions/turn/nation/che_물자원조.ts +++ b/packages/logic/src/actions/turn/nation/che_물자원조.ts @@ -1,4 +1,4 @@ -import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; import { beChief, @@ -27,16 +27,23 @@ import { clamp } from 'es-toolkit'; import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; -const ARGS_SCHEMA = z.object({ - destNationId: z.number(), - amountList: z.tuple([z.number(), z.number()]), -}); +const ARGS_SCHEMA = z + .object({ + destNationId: z.number().int().positive(), + amountList: z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]), + }) + .refine(({ amountList }) => amountList[0] > 0 || amountList[1] > 0, { + message: '지원량은 0보다 커야 합니다.', + path: ['amountList'], + }); export type MaterialAidArgs = z.infer; export interface MaterialAidResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, > extends GeneralActionResolveContext { destNation: Nation; + friendlyChiefs: Array>; + destNationChiefs: Array>; } const ACTION_NAME = '원조'; @@ -75,7 +82,12 @@ export class ActionDefinition< } buildMinConstraints(_ctx: ConstraintContext, _args: MaterialAidArgs): Constraint[] { - return [occupiedCity(), beChief(), suppliedCity(), reqNationValue('surlimit', '외교제한', '==', 0, '외교제한중입니다.')]; + return [ + occupiedCity(), + beChief(), + suppliedCity(), + reqNationValue('surlimit', '외교제한', '==', 0, '외교제한중입니다.'), + ]; } buildConstraints(_ctx: ConstraintContext, args: MaterialAidArgs): Constraint[] { @@ -111,11 +123,24 @@ export class ActionDefinition< const goldText = actualGold.toLocaleString(); const riceText = actualRice.toLocaleString(); + const nationName = nation.name; const josaUlRice = JosaUtil.pick(riceText, '을'); const josaRo = JosaUtil.pick(destNation.name, '로'); - const nationName = nation.name; + const josaRoSrc = JosaUtil.pick(nationName, '로'); const broadcastMessage = `${destNation.name}${josaRo} 금${goldText} 쌀${riceText}을 지원했습니다.`; + const recvAssist = + typeof destNation.meta.recv_assist === 'object' && destNation.meta.recv_assist !== null + ? { ...destNation.meta.recv_assist } + : {}; + const recvKey = `n${nation.id}`; + const priorEntry = + typeof recvAssist[recvKey] === 'object' && recvAssist[recvKey] !== null ? recvAssist[recvKey] : {}; + const priorAmount = Number(priorEntry['1'] ?? 0); + recvAssist[recvKey] = { + 0: nation.id, + 1: (Number.isFinite(priorAmount) ? priorAmount : 0) + actualGold + actualRice, + }; const effects: Array> = [ createNationPatchEffect( @@ -133,6 +158,10 @@ export class ActionDefinition< { gold: destNation.gold + actualGold, rice: destNation.rice + actualRice, + meta: { + ...destNation.meta, + recv_assist: recvAssist, + }, }, destNation.id ), @@ -146,6 +175,14 @@ export class ActionDefinition< } ), // Actor Nation History Log + createLogEffect( + `${destNation.name}${josaRo} 금${goldText} 쌀${riceText}${josaUlRice} 지원`, + { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ), createLogEffect( `${destNation.name}${josaRo} 금${goldText} 쌀${riceText}${josaUlRice} 지원`, { @@ -157,7 +194,7 @@ export class ActionDefinition< ), // Dest Nation History Log createLogEffect( - `${nationName}${JosaUtil.pick(nationName, '부터')} 금${goldText} 쌀${riceText}${josaUlRice} 지원 받음`, + `${nationName}${josaRoSrc}부터 금${goldText} 쌀${riceText}${josaUlRice} 지원 받음`, { scope: LogScope.NATION, nationId: destNation.id, @@ -178,6 +215,30 @@ export class ActionDefinition< }), ]; + for (const chief of context.friendlyChiefs) { + if (chief.id !== general.id) { + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: chief.id, + format: LogFormat.PLAIN, + }) + ); + } + } + const destBroadcastMessage = `${nationName}에서 금${goldText} 쌀${riceText}${josaUlRice} 원조했습니다.`; + for (const chief of context.destNationChiefs) { + effects.push( + createLogEffect(destBroadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: chief.id, + format: LogFormat.PLAIN, + }) + ); + } + general.experience += 5; general.dedication += 5; @@ -194,10 +255,15 @@ export const actionContextBuilder: ActionContextBuilder = (base const destNation = worldRef.getNationById(destNationId); if (!destNation) return null; + const generals = worldRef.listGenerals(); return { ...base, destNation, + friendlyChiefs: generals.filter( + (general) => general.nationId === base.general.nationId && general.officerLevel >= 5 + ), + destNationChiefs: generals.filter((general) => general.nationId === destNationId && general.officerLevel >= 5), }; }; diff --git a/packages/logic/src/actions/turn/nation/che_백성동원.ts b/packages/logic/src/actions/turn/nation/che_백성동원.ts index 04de81b..cb22eb0 100644 --- a/packages/logic/src/actions/turn/nation/che_백성동원.ts +++ b/packages/logic/src/actions/turn/nation/che_백성동원.ts @@ -48,10 +48,19 @@ const DEFENCE_RATE = 0.8; export class CommandResolver { private readonly pipeline: GeneralActionPipeline; - constructor(modules: Array | null | undefined>) { + constructor( + modules: Array | null | undefined>, + private readonly initialNationGenLimit = 10 + ) { this.pipeline = new GeneralActionPipeline(modules); } + getPostReqTurn(context: MobilizePeopleResolveContext): number { + const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit); + const base = Math.round(Math.sqrt(genCount * 4) * 10); + return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base)); + } + getGlobalDelay(context: MobilizePeopleResolveContext): number { return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY)); } @@ -64,8 +73,12 @@ export class ActionResolver< readonly key = 'che_백성동원'; private readonly command: CommandResolver; - constructor(modules: Array | null | undefined>) { - this.command = new CommandResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.command = new CommandResolver(modules, initialNationGenLimit); + } + + getPostReqTurn(context: MobilizePeopleResolveContext): number { + return this.command.getPostReqTurn(context); } resolve( @@ -144,8 +157,8 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor(modules: Array | null | undefined>) { - this.resolver = new ActionResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.resolver = new ActionResolver(modules, initialNationGenLimit); } parseArgs(raw: unknown): MobilizePeopleArgs | null { @@ -162,6 +175,10 @@ export class ActionDefinition< return [occupiedCity(), beChief(), occupiedDestCity(), availableStrategicCommand()]; } + getPostReqTurn(context: MobilizePeopleResolveContext): number { + return this.resolver.getPostReqTurn(context); + } + resolve( context: MobilizePeopleResolveContext, args: MobilizePeopleArgs @@ -198,5 +215,6 @@ export const commandSpec: NationTurnCommandSpec = { reqArg: true, availabilityArgs: { destCityId: 0 }, argsSchema: ARGS_SCHEMA, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit), }; diff --git a/packages/logic/src/actions/turn/nation/che_불가침제의.ts b/packages/logic/src/actions/turn/nation/che_불가침제의.ts index b48cf36..0a0ce08 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침제의.ts @@ -1,4 +1,4 @@ -import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { beChief, @@ -10,7 +10,7 @@ import { import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; -import { createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; @@ -32,6 +32,14 @@ const ARGS_SCHEMA = z.object({ }); export type NonAggressionProposalArgs = z.infer; +interface NonAggressionProposalContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destNation: Nation; + messageValidMinutes: number; + messageTime: Date; +} + const ACTION_NAME = '불가침 제의'; const MIN_TERM_MONTHS = 6; @@ -90,7 +98,11 @@ const reqMinimumTreatyTerm = (minMonths: number): Constraint => ({ // 불가침 제의를 처리하는 국가 커맨드. export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition { +> implements GeneralActionDefinition< + TriggerState, + NonAggressionProposalArgs, + NonAggressionProposalContext +> { public readonly key = 'che_불가침제의'; public readonly name = ACTION_NAME; @@ -117,14 +129,42 @@ export class ActionDefinition< } resolve( - _context: GeneralActionResolveContext, + context: NonAggressionProposalContext, args: NonAggressionProposalArgs ): GeneralActionOutcome { - const destNationName = - (_context as { destNation?: { name?: string } }).destNation?.name ?? `국가${args.destNationId}`; + const { general, nation, destNation } = context; + if (!nation) { + return { effects: [createLogEffect('국가 정보가 없습니다.')] }; + } + const destNationName = destNation.name; const josaRo = JosaUtil.pick(destNationName, '로'); + const josaWa = JosaUtil.pick(nation.name, '와'); + const validUntil = new Date(context.messageTime.getTime() + context.messageValidMinutes * 60_000); return { effects: [ + createMessageEffect({ + msgType: 'diplomacy', + src: { + generalId: general.id, + generalName: general.name, + nationId: nation.id, + nationName: nation.name, + color: nation.color, + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: destNation.id, + nationName: destNation.name, + color: destNation.color, + icon: '', + }, + text: `${nation.name}${josaWa} ${args.year}년 ${args.month}월까지 불가침 제의 서신`, + time: context.messageTime, + validUntil, + option: { action: 'noAggression', year: args.year, month: args.month }, + }), createLogEffect(`${destNationName}${josaRo} 불가침 제의 서신을 보냈습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, @@ -136,11 +176,18 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 날짜 정보를 제공한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => ({ - ...base, - currentYear: options.world.currentYear, - currentMonth: options.world.currentMonth, -}); +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destNation = options.worldRef?.getNationById(options.actionArgs.destNationId); + if (!destNation) { + return null; + } + return { + ...base, + destNation, + messageTime: base.general.turnTime, + messageValidMinutes: Math.max(30, Math.floor((options.world.tickSeconds / 60) * 3)), + }; +}; export const commandSpec: NationTurnCommandSpec = { key: 'che_불가침제의', diff --git a/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts b/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts index c5e4038..9df10dc 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts @@ -1,4 +1,4 @@ -import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { allowDiplomacyBetweenStatus, @@ -10,11 +10,11 @@ import { } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; -import { createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { NationTurnCommandSpec } from './index.js'; import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; @@ -27,13 +27,25 @@ const ARGS_SCHEMA = z.object({ }); export type NonAggressionCancelProposalArgs = z.infer; +interface NonAggressionCancelProposalContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destNation: Nation; + messageValidMinutes: number; + messageTime: Date; +} + const ACTION_NAME = '불가침 파기 제의'; const DIPLOMACY_NON_AGGRESSION = 7; // 불가침 파기 제의를 처리하는 국가 커맨드. export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition { +> implements GeneralActionDefinition< + TriggerState, + NonAggressionCancelProposalArgs, + NonAggressionCancelProposalContext +> { public readonly key = 'che_불가침파기제의'; public readonly name = ACTION_NAME; @@ -57,14 +69,41 @@ export class ActionDefinition< } resolve( - _context: GeneralActionResolveContext, - args: NonAggressionCancelProposalArgs + context: NonAggressionCancelProposalContext, + _args: NonAggressionCancelProposalArgs ): GeneralActionOutcome { - const destNationName = - (_context as { destNation?: { name?: string } }).destNation?.name ?? `국가${args.destNationId}`; + const { general, nation, destNation } = context; + if (!nation) { + return { effects: [createLogEffect('국가 정보가 없습니다.')] }; + } + const destNationName = destNation.name; const josaRo = JosaUtil.pick(destNationName, '로'); + const validUntil = new Date(context.messageTime.getTime() + context.messageValidMinutes * 60_000); return { effects: [ + createMessageEffect({ + msgType: 'diplomacy', + src: { + generalId: general.id, + generalName: general.name, + nationId: nation.id, + nationName: nation.name, + color: nation.color, + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: destNation.id, + nationName: destNation.name, + color: destNation.color, + icon: '', + }, + text: `${nation.name}의 불가침 파기 제의 서신`, + time: context.messageTime, + validUntil, + option: { action: 'cancelNA', deletable: false }, + }), createLogEffect(`${destNationName}${josaRo} 불가침 파기 제의 서신을 보냈습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, @@ -76,7 +115,18 @@ export class ActionDefinition< } // 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destNation = options.worldRef?.getNationById(options.actionArgs.destNationId); + if (!destNation) { + return null; + } + return { + ...base, + destNation, + messageTime: base.general.turnTime, + messageValidMinutes: Math.max(30, Math.floor((options.world.tickSeconds / 60) * 3)), + }; +}; export const commandSpec: NationTurnCommandSpec = { key: 'che_불가침파기제의', diff --git a/packages/logic/src/actions/turn/nation/che_수몰.ts b/packages/logic/src/actions/turn/nation/che_수몰.ts index e423073..60357c6 100644 --- a/packages/logic/src/actions/turn/nation/che_수몰.ts +++ b/packages/logic/src/actions/turn/nation/che_수몰.ts @@ -58,10 +58,19 @@ const battleGroundCity = (): Constraint => ({ export class CommandResolver { private readonly pipeline: GeneralActionPipeline; - constructor(modules: Array | null | undefined>) { + constructor( + modules: Array | null | undefined>, + private readonly initialNationGenLimit = 10 + ) { this.pipeline = new GeneralActionPipeline(modules); } + getPostReqTurn(context: FloodResolveContext): number { + const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit); + const base = Math.round(Math.sqrt(genCount * 4) * 10); + return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base)); + } + getGlobalDelay(context: FloodResolveContext): number { return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY)); } @@ -74,8 +83,12 @@ export class ActionResolver< readonly key = 'che_수몰'; private readonly command: CommandResolver; - constructor(modules: Array | null | undefined>) { - this.command = new CommandResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.command = new CommandResolver(modules, initialNationGenLimit); + } + + getPostReqTurn(context: FloodResolveContext): number { + return this.command.getPostReqTurn(context); } resolve(context: FloodResolveContext, _args: FloodArgs): GeneralActionOutcome { @@ -175,8 +188,8 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor(modules: Array | null | undefined>) { - this.resolver = new ActionResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.resolver = new ActionResolver(modules, initialNationGenLimit); } parseArgs(raw: unknown): FloodArgs | null { @@ -200,6 +213,14 @@ export class ActionDefinition< ]; } + getPreReqTurn(): number { + return PRE_REQ_TURN; + } + + getPostReqTurn(context: FloodResolveContext): number { + return this.resolver.getPostReqTurn(context); + } + resolve(context: FloodResolveContext, args: FloodArgs): GeneralActionOutcome { return this.resolver.resolve(context, args); } @@ -238,5 +259,6 @@ export const commandSpec: NationTurnCommandSpec = { reqArg: true, availabilityArgs: { destCityId: 0 }, argsSchema: ARGS_SCHEMA, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit), }; diff --git a/packages/logic/src/actions/turn/nation/che_의병모집.ts b/packages/logic/src/actions/turn/nation/che_의병모집.ts index 16b9651..fb3f330 100644 --- a/packages/logic/src/actions/turn/nation/che_의병모집.ts +++ b/packages/logic/src/actions/turn/nation/che_의병모집.ts @@ -221,6 +221,11 @@ export class ActionResolver< this.command = new CommandResolver(modules, env); } + getPostReqTurn(context: VolunteerRecruitResolveContext): number { + const value = context.nation ? readMetaNumber(context.nation.meta, 'gennum') : null; + return this.command.getPostDelay(context, value ?? 0); + } + resolve( context: VolunteerRecruitResolveContext, _args: VolunteerRecruitArgs @@ -363,6 +368,14 @@ export class ActionDefinition< ]; } + getPreReqTurn(): number { + return DEFAULT_PRE_TURN; + } + + getPostReqTurn(context: VolunteerRecruitResolveContext): number { + return this.resolver.getPostReqTurn(context); + } + resolve( context: VolunteerRecruitResolveContext, args: VolunteerRecruitArgs diff --git a/packages/logic/src/actions/turn/nation/che_이호경식.ts b/packages/logic/src/actions/turn/nation/che_이호경식.ts index cb36e02..b4551fe 100644 --- a/packages/logic/src/actions/turn/nation/che_이호경식.ts +++ b/packages/logic/src/actions/turn/nation/che_이호경식.ts @@ -54,10 +54,19 @@ const resolveNextTerm = (state: number, term: number): number => (state === DIPL export class CommandResolver { private readonly pipeline: GeneralActionPipeline; - constructor(modules: Array | null | undefined>) { + constructor( + modules: Array | null | undefined>, + private readonly initialNationGenLimit = 10 + ) { this.pipeline = new GeneralActionPipeline(modules); } + getPostReqTurn(context: DegradeRelationsResolveContext): number { + const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit); + const base = Math.round(Math.sqrt(genCount * 16) * 10); + return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base)); + } + getGlobalDelay(context: DegradeRelationsResolveContext): number { return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY)); } @@ -70,8 +79,12 @@ export class ActionResolver< readonly key = 'che_이호경식'; private readonly command: CommandResolver; - constructor(modules: Array | null | undefined>) { - this.command = new CommandResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.command = new CommandResolver(modules, initialNationGenLimit); + } + + getPostReqTurn(context: DegradeRelationsResolveContext): number { + return this.command.getPostReqTurn(context); } resolve( @@ -174,8 +187,8 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor(modules: Array | null | undefined>) { - this.resolver = new ActionResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.resolver = new ActionResolver(modules, initialNationGenLimit); } parseArgs(raw: unknown): DegradeRelationsArgs | null { @@ -198,6 +211,10 @@ export class ActionDefinition< ]; } + getPostReqTurn(context: DegradeRelationsResolveContext): number { + return this.resolver.getPostReqTurn(context); + } + resolve( context: DegradeRelationsResolveContext, args: DegradeRelationsArgs @@ -245,5 +262,6 @@ export const commandSpec: NationTurnCommandSpec = { reqArg: true, availabilityArgs: { destNationId: 0 }, argsSchema: ARGS_SCHEMA, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit), }; diff --git a/packages/logic/src/actions/turn/nation/che_종전제의.ts b/packages/logic/src/actions/turn/nation/che_종전제의.ts index 4904756..aae3df0 100644 --- a/packages/logic/src/actions/turn/nation/che_종전제의.ts +++ b/packages/logic/src/actions/turn/nation/che_종전제의.ts @@ -1,4 +1,4 @@ -import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { allowDiplomacyBetweenStatus, @@ -10,10 +10,10 @@ import { } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; -import { createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { NationTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; import { z } from 'zod'; @@ -27,12 +27,20 @@ const ARGS_SCHEMA = z.object({ }); export type StopWarProposalArgs = z.infer; +interface StopWarProposalContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destNation: Nation; + messageValidMinutes: number; + messageTime: Date; +} + const ACTION_NAME = '종전 제의'; // 종전 제의를 처리하는 국가 커맨드. export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition { +> implements GeneralActionDefinition> { public readonly key = 'che_종전제의'; public readonly name = ACTION_NAME; @@ -56,14 +64,41 @@ export class ActionDefinition< } resolve( - _context: GeneralActionResolveContext, - args: StopWarProposalArgs + context: StopWarProposalContext, + _args: StopWarProposalArgs ): GeneralActionOutcome { - const destNationName = - (_context as { destNation?: { name?: string } }).destNation?.name ?? `국가${args.destNationId}`; + const { general, nation, destNation } = context; + if (!nation) { + return { effects: [createLogEffect('국가 정보가 없습니다.')] }; + } + const destNationName = destNation.name; const josaRo = JosaUtil.pick(destNationName, '로'); + const validUntil = new Date(context.messageTime.getTime() + context.messageValidMinutes * 60_000); return { effects: [ + createMessageEffect({ + msgType: 'diplomacy', + src: { + generalId: general.id, + generalName: general.name, + nationId: nation.id, + nationName: nation.name, + color: nation.color, + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: destNation.id, + nationName: destNation.name, + color: destNation.color, + icon: '', + }, + text: `${nation.name}의 종전 제의 서신`, + time: context.messageTime, + validUntil, + option: { action: 'stopWar', deletable: false }, + }), createLogEffect(`${destNationName}${josaRo} 종전 제의 서신을 보냈습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, @@ -75,7 +110,18 @@ export class ActionDefinition< } // 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destNation = options.worldRef?.getNationById(options.actionArgs.destNationId); + if (!destNation) { + return null; + } + return { + ...base, + destNation, + messageTime: base.general.turnTime, + messageValidMinutes: Math.max(30, Math.floor((options.world.tickSeconds / 60) * 3)), + }; +}; export const commandSpec: NationTurnCommandSpec = { key: 'che_종전제의', diff --git a/packages/logic/src/actions/turn/nation/che_증축.ts b/packages/logic/src/actions/turn/nation/che_증축.ts index d7525de..099917c 100644 --- a/packages/logic/src/actions/turn/nation/che_증축.ts +++ b/packages/logic/src/actions/turn/nation/che_증축.ts @@ -88,6 +88,7 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = 'che_증축'; public readonly name = ACTION_NAME; + public readonly countsAsInheritanceActiveAction = true; constructor(private readonly env: TurnCommandEnv) {} @@ -118,6 +119,15 @@ export class ActionDefinition< ]; } + getPreReqTurn(): number { + return 5; + } + + getStackSequence(context: ExpandCityResolveContext): number { + const value = context.nation?.meta.capset; + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0; + } + resolve( context: ExpandCityResolveContext, _args: ExpandCityArgs @@ -153,6 +163,10 @@ export class ActionDefinition< { gold: nation.gold - cost, rice: nation.rice - cost, + meta: { + ...nation.meta, + capset: (typeof nation.meta.capset === 'number' ? nation.meta.capset : 0) + 1, + }, }, nation.id ), @@ -184,6 +198,11 @@ export class ActionDefinition< format: LogFormat.YEAR_MONTH, } ), + createLogEffect(`${destCityName}${josaUl} ${ACTION_NAME}`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), // General Action Log createLogEffect(`${destCityName}${josaUl} ${ACTION_NAME}했습니다.`, { scope: LogScope.GENERAL, diff --git a/packages/logic/src/actions/turn/nation/che_천도.ts b/packages/logic/src/actions/turn/nation/che_천도.ts index 55d2f88..2d4c390 100644 --- a/packages/logic/src/actions/turn/nation/che_천도.ts +++ b/packages/logic/src/actions/turn/nation/che_천도.ts @@ -36,35 +36,43 @@ export interface MoveCapitalResolveContext< > extends GeneralActionResolveContext { destCity: City; map: MapDefinition; + nationCities: City[]; } const ACTION_NAME = '천도'; -const hasRouteToDestCity = (destCityID: number, develCost: number): Constraint => ({ +const hasRouteToDestCity = (destCityID: number): Constraint => ({ name: 'hasRouteToDestCity', - requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }, { kind: 'env', key: 'map' }], + requires: (ctx) => [ + { kind: 'nation', id: ctx.nationId! }, + { kind: 'env', key: 'map' }, + { kind: 'env', key: 'cities' }, + ], test: (ctx: ConstraintContext, view: StateView) => { const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | undefined; + const cities = view.get({ kind: 'env', key: 'cities' }) as City[] | undefined; if (!nation || !map || nation.capitalCityId === undefined || nation.capitalCityId === null) { return { kind: 'allow' }; } - const dist = calcDistance(nation.capitalCityId, destCityID, map); - if (dist >= 50) { + const allowedCityIds = new Set( + (cities ?? []).filter((city) => city.nationId === nation.id).map((city) => city.id) + ); + const dist = calcDistance(nation.capitalCityId, destCityID, map, allowedCityIds); + if (dist === null) { return { kind: 'deny', reason: '천도 대상으로 도달할 방법이 없습니다.' }; } - const cost = develCost * 5 * Math.pow(2, dist); - if (nation.gold < cost + 1000) { - return { kind: 'allow' }; - } - if (nation.rice < cost + 1000) { - return { kind: 'allow' }; - } return { kind: 'allow' }; }, }); -const calcDistance = (fromCityId: number, toCityId: number, map: MapDefinition): number => { +const calcDistance = ( + fromCityId: number, + toCityId: number, + map: MapDefinition, + allowedCityIds?: ReadonlySet +): number | null => { + if (allowedCityIds && !allowedCityIds.has(toCityId)) return null; if (fromCityId === toCityId) return 0; const connections = new Map(); @@ -82,6 +90,9 @@ const calcDistance = (fromCityId: number, toCityId: number, map: MapDefinition): const nextNodes = connections.get(current) ?? []; for (const next of nextNodes) { + if (allowedCityIds && !allowedCityIds.has(next)) { + continue; + } if (!visited.has(next)) { visited.add(next); queue.push([next, dist + 1]); @@ -89,7 +100,7 @@ const calcDistance = (fromCityId: number, toCityId: number, map: MapDefinition): } } - return 50; + return null; }; export class ActionDefinition< @@ -97,6 +108,7 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = 'che_천도'; public readonly name = ACTION_NAME; + public readonly countsAsInheritanceActiveAction = true; constructor(private readonly env: TurnCommandEnv) {} @@ -119,8 +131,12 @@ export class ActionDefinition< if (!nation || !map || nation.capitalCityId === undefined || nation.capitalCityId === null) { return 0; } - const dist = calcDistance(nation.capitalCityId, args.destCityID, map); - if (dist >= 50) { + const cities = view.get({ kind: 'env', key: 'cities' }) as City[] | undefined; + const allowedCityIds = new Set( + (cities ?? []).filter((city) => city.nationId === nation.id).map((city) => city.id) + ); + const dist = calcDistance(nation.capitalCityId, args.destCityID, map, allowedCityIds); + if (dist === null) { return 0; } return develcost * 5 * Math.pow(2, dist); @@ -132,13 +148,38 @@ export class ActionDefinition< beChief(), suppliedCity(), suppliedDestCity(), - hasRouteToDestCity(args.destCityID, develcost), + hasRouteToDestCity(args.destCityID), reqNationValue('capitalCityId', '수도', '!=', args.destCityID, '이미 수도입니다.'), - reqNationGold((ctx, view) => baseGold + getRequiredCost(ctx, view), [{ kind: 'env', key: 'map' }]), - reqNationRice((ctx, view) => baseRice + getRequiredCost(ctx, view), [{ kind: 'env', key: 'map' }]), + reqNationGold( + (ctx, view) => baseGold + getRequiredCost(ctx, view), + [ + { kind: 'env', key: 'map' }, + { kind: 'env', key: 'cities' }, + ] + ), + reqNationRice( + (ctx, view) => baseRice + getRequiredCost(ctx, view), + [ + { kind: 'env', key: 'map' }, + { kind: 'env', key: 'cities' }, + ] + ), ]; } + getPreReqTurn(context: MoveCapitalResolveContext, args: MoveCapitalArgs): number { + if (!context.nation?.capitalCityId) { + return 0; + } + const allowedCityIds = new Set(context.nationCities.map((city) => city.id)); + return (calcDistance(context.nation.capitalCityId, args.destCityID, context.map, allowedCityIds) ?? 0) * 2; + } + + getStackSequence(context: MoveCapitalResolveContext): number { + const value = context.nation?.meta.capset; + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0; + } + resolve( context: MoveCapitalResolveContext, args: MoveCapitalArgs @@ -148,9 +189,12 @@ export class ActionDefinition< return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; } - const dist = calcDistance(nation.capitalCityId, args.destCityID, map); - const cost = this.env.develCost * 5 * Math.pow(2, dist); - + const nationCities = context.nationCities ?? []; + const allowedCityIds = nationCities.length > 0 ? new Set(nationCities.map((city) => city.id)) : undefined; + const dist = calcDistance(nation.capitalCityId, args.destCityID, map, allowedCityIds); + if (dist === null) { + return { effects: [createLogEffect('천도 대상으로 도달할 방법이 없습니다.', { scope: LogScope.GENERAL })] }; + } const generalName = general.name; const nationName = nation.name; const destCityName = destCity.name; @@ -163,8 +207,11 @@ export class ActionDefinition< createNationPatchEffect( { capitalCityId: args.destCityID, - gold: nation.gold - cost, - rice: nation.rice - cost, + // ref는 비용 보유를 제약에서 검사하지만 실행 시 차감하지 않는다. + meta: { + ...nation.meta, + capset: (typeof nation.meta.capset === 'number' ? nation.meta.capset : 0) + 1, + }, }, nation.id ), @@ -196,6 +243,11 @@ export class ActionDefinition< format: LogFormat.YEAR_MONTH, } ), + createLogEffect(`${destCityName}${josaRo} ${ACTION_NAME}명령`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), // General Action Log createLogEffect(`${destCityName}${josaRo} ${ACTION_NAME}했습니다.`, { scope: LogScope.GENERAL, @@ -226,6 +278,7 @@ export const actionContextBuilder: ActionContextBuilder = (base ...base, destCity, map, + nationCities: worldRef.listCities().filter((city) => city.nationId === base.general.nationId), }; }; diff --git a/packages/logic/src/actions/turn/nation/che_초토화.ts b/packages/logic/src/actions/turn/nation/che_초토화.ts index efde0ad..6e23732 100644 --- a/packages/logic/src/actions/turn/nation/che_초토화.ts +++ b/packages/logic/src/actions/turn/nation/che_초토화.ts @@ -72,6 +72,7 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = 'che_초토화'; public readonly name = ACTION_NAME; + public readonly countsAsInheritanceActiveAction = true; parseArgs(raw: unknown): ScorchedEarthArgs | null { return parseArgsWithSchema(ARGS_SCHEMA, raw); @@ -102,6 +103,10 @@ export class ActionDefinition< ]; } + getPreReqTurn(): number { + return PRE_REQ_TURN; + } + resolve( context: ScorchedEarthResolveContext, _args: ScorchedEarthArgs diff --git a/packages/logic/src/actions/turn/nation/che_피장파장.ts b/packages/logic/src/actions/turn/nation/che_피장파장.ts index ca9848d..080d6e1 100644 --- a/packages/logic/src/actions/turn/nation/che_피장파장.ts +++ b/packages/logic/src/actions/turn/nation/che_피장파장.ts @@ -8,6 +8,7 @@ import { occupiedCity, } from '@sammo-ts/logic/constraints/presets.js'; import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js'; +import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionEffect, @@ -15,7 +16,7 @@ import type { GeneralActionResolveContext, GeneralActionResolver, } from '@sammo-ts/logic/actions/engine.js'; -import { createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; @@ -32,10 +33,12 @@ export interface CounterStrategyResolveContext< destNation: Nation; friendlyGenerals: Array>; destNationGenerals: Array>; + currentYearMonth: number; } const ACTION_NAME = '피장파장'; const DEFAULT_GLOBAL_DELAY = 8; +const TARGET_DELAY = 60; const PRE_REQ_TURN = 1; const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); @@ -89,11 +92,53 @@ const reqValidStrategicCommandType = (): Constraint => ({ }, }); +const alwaysFail = (commandType: CounterStrategyArgs['commandType']): Constraint => ({ + // Legacy inserts AlwaysFail when the selected strategy is still cooling down. + name: 'alwaysFail', + requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), + test: (ctx, view) => { + if (ctx.nationId === undefined) { + return { kind: 'deny', reason: '국가 정보가 없습니다.' }; + } + const nation = view.get({ kind: 'nation', id: ctx.nationId }) as Nation | undefined; + if (!nation) { + return { kind: 'deny', reason: '국가 정보가 없습니다.' }; + } + const raw = nation.meta[`next_execute_${STRATEGIC_COMMANDS[commandType]}`]; + const nextAvailable = typeof raw === 'number' ? raw : Number(raw ?? 0); + const year = Number(ctx.env.currentYear ?? ctx.env.year); + const month = Number(ctx.env.currentMonth ?? ctx.env.month); + if (!Number.isFinite(year) || !Number.isFinite(month)) { + return { kind: 'unknown', missing: [{ kind: 'env', key: 'currentYear' }] }; + } + const currentYearMonth = Math.floor(year) * 12 + Math.floor(month) - 1; + if (Number.isFinite(nextAvailable) && nextAvailable > currentYearMonth) { + return { kind: 'deny', reason: '해당 전략을 아직 사용할 수 없습니다' }; + } + return allow(); + }, +}); + // 피장파장 실행 결과를 계산한다. export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { readonly key = 'che_피장파장'; + private readonly pipeline: GeneralActionPipeline; + + constructor( + modules: Array | null | undefined> = [], + private readonly initialNationGenLimit = 10 + ) { + this.pipeline = new GeneralActionPipeline(modules); + } + + getTargetPostReqTurn(context: CounterStrategyResolveContext): number { + const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit); + const base = Math.round(Math.sqrt(genCount * 2) * 10); + const triggered = Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base)); + return Math.max(triggered, Math.round(TARGET_DELAY * 1.2)); + } resolve( context: CounterStrategyResolveContext, @@ -105,6 +150,7 @@ export class ActionResolver< const nationName = nation?.name ?? '아국'; const destNationName = destNation.name; const targetCommandName = STRATEGIC_COMMANDS[args.commandType] ?? args.commandType; + const currentYearMonth = Number.isFinite(context.currentYearMonth) ? context.currentYearMonth : 0; const actionName = ACTION_NAME; const actionJosa = JosaUtil.pick(actionName, '을'); @@ -117,8 +163,8 @@ export class ActionResolver< context.addLog( `${destNationName}${targetCommandName} ${ACTION_NAME}${actionJosa} 발동`, { - category: LogCategory.HISTORY, - format: LogFormat.YEAR_MONTH, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, } ); @@ -159,6 +205,7 @@ export class ActionResolver< nation.meta = { ...(nation.meta as object), strategic_cmd_limit: globalDelay, + [`next_execute_${targetCommandName}`]: currentYearMonth + this.getTargetPostReqTurn(context), }; effects.push( createLogEffect(broadcastMessage, { @@ -170,6 +217,22 @@ export class ActionResolver< ); } + const destMeta = destNation.meta; + const destKey = `next_execute_${targetCommandName}`; + const destRaw = destMeta[destKey]; + const destNext = typeof destRaw === 'number' ? destRaw : Number(destRaw ?? 0); + effects.push( + createNationPatchEffect( + { + meta: { + ...destMeta, + [destKey]: Math.max(Number.isFinite(destNext) ? destNext : 0, currentYearMonth) + TARGET_DELAY, + }, + }, + destNation.id + ) + ); + effects.push( createLogEffect( `${nationName}${generalName}${generalJosa} 아국에 ${targetCommandName} ${ACTION_NAME}${actionJosa} 발동`, @@ -206,7 +269,11 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = 'che_피장파장'; public readonly name = ACTION_NAME; - private readonly resolver = new ActionResolver(); + private readonly resolver: ActionResolver; + + constructor(modules: Array | null | undefined> = [], initialNationGenLimit = 10) { + this.resolver = new ActionResolver(modules, initialNationGenLimit); + } parseArgs(raw: unknown): CounterStrategyArgs | null { return parseArgsWithSchema(ARGS_SCHEMA, raw); @@ -226,10 +293,22 @@ export class ActionDefinition< allowDiplomacyBetweenStatus([0, 1], '선포, 전쟁중인 상대국에게만 가능합니다.'), availableStrategicCommand(), reqValidStrategicCommandType(), + alwaysFail(_args.commandType), ]; } - resolve(context: CounterStrategyResolveContext, args: CounterStrategyArgs): GeneralActionOutcome { + getPreReqTurn(): number { + return PRE_REQ_TURN; + } + + getPostReqTurn(): number { + return 8; + } + + resolve( + context: CounterStrategyResolveContext, + args: CounterStrategyArgs + ): GeneralActionOutcome { return this.resolver.resolve(context, args); } } @@ -256,6 +335,7 @@ export const actionContextBuilder: ActionContextBuilder = ( destNation, friendlyGenerals, destNationGenerals, + currentYearMonth: options.world.currentYear * 12 + options.world.currentMonth - 1, }; }; @@ -265,5 +345,6 @@ export const commandSpec: NationTurnCommandSpec = { reqArg: true, availabilityArgs: { destNationId: 0, commandType: '' }, argsSchema: ARGS_SCHEMA, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit), }; diff --git a/packages/logic/src/actions/turn/nation/che_필사즉생.ts b/packages/logic/src/actions/turn/nation/che_필사즉생.ts index 9f749aa..a0f017a 100644 --- a/packages/logic/src/actions/turn/nation/che_필사즉생.ts +++ b/packages/logic/src/actions/turn/nation/che_필사즉생.ts @@ -40,10 +40,19 @@ const ATMOS_CAP = 100; export class CommandResolver { private readonly pipeline: GeneralActionPipeline; - constructor(modules: Array | null | undefined>) { + constructor( + modules: Array | null | undefined>, + private readonly initialNationGenLimit = 10 + ) { this.pipeline = new GeneralActionPipeline(modules); } + getPostReqTurn(context: DesperateFightResolveContext): number { + const genCount = Math.max(context.nationGenerals.length, this.initialNationGenLimit); + const base = Math.round(Math.sqrt(genCount * 8) * 10); + return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base)); + } + getGlobalDelay(context: DesperateFightResolveContext): number { return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY)); } @@ -56,8 +65,12 @@ export class ActionResolver< readonly key = 'che_필사즉생'; private readonly command: CommandResolver; - constructor(modules: Array | null | undefined>) { - this.command = new CommandResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.command = new CommandResolver(modules, initialNationGenLimit); + } + + getPostReqTurn(context: DesperateFightResolveContext): number { + return this.command.getPostReqTurn(context); } resolve( @@ -142,8 +155,8 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor(modules: Array | null | undefined>) { - this.resolver = new ActionResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.resolver = new ActionResolver(modules, initialNationGenLimit); } parseArgs(_raw: unknown): DesperateFightArgs | null { @@ -162,6 +175,14 @@ export class ActionDefinition< ]; } + getPreReqTurn(): number { + return PRE_REQ_TURN; + } + + getPostReqTurn(context: DesperateFightResolveContext): number { + return this.resolver.getPostReqTurn(context); + } + resolve( context: DesperateFightResolveContext, args: DesperateFightArgs @@ -188,5 +209,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '전략', reqArg: false, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit), }; diff --git a/packages/logic/src/actions/turn/nation/che_허보.ts b/packages/logic/src/actions/turn/nation/che_허보.ts index 9b62eda..e8dd8f1 100644 --- a/packages/logic/src/actions/turn/nation/che_허보.ts +++ b/packages/logic/src/actions/turn/nation/che_허보.ts @@ -65,10 +65,19 @@ const pickMoveCityId = (rng: GeneralActionResolveContext['rng'], destCityId: num export class CommandResolver { private readonly pipeline: GeneralActionPipeline; - constructor(modules: Array | null | undefined>) { + constructor( + modules: Array | null | undefined>, + private readonly initialNationGenLimit = 10 + ) { this.pipeline = new GeneralActionPipeline(modules); } + getPostReqTurn(context: DeceptionResolveContext): number { + const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit); + const base = Math.round(Math.sqrt(genCount * 4) * 10); + return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base)); + } + getGlobalDelay(context: DeceptionResolveContext): number { return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY)); } @@ -81,8 +90,12 @@ export class ActionResolver< readonly key = 'che_허보'; private readonly command: CommandResolver; - constructor(modules: Array | null | undefined>) { - this.command = new CommandResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.command = new CommandResolver(modules, initialNationGenLimit); + } + + getPostReqTurn(context: DeceptionResolveContext): number { + return this.command.getPostReqTurn(context); } resolve(context: DeceptionResolveContext, _args: DeceptionArgs): GeneralActionOutcome { @@ -176,8 +189,8 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor(modules: Array | null | undefined>) { - this.resolver = new ActionResolver(modules); + constructor(modules: Array | null | undefined>, initialNationGenLimit = 10) { + this.resolver = new ActionResolver(modules, initialNationGenLimit); } parseArgs(raw: unknown): DeceptionArgs | null { @@ -201,6 +214,14 @@ export class ActionDefinition< ]; } + getPreReqTurn(): number { + return PRE_REQ_TURN; + } + + getPostReqTurn(context: DeceptionResolveContext): number { + return this.resolver.getPostReqTurn(context); + } + resolve(context: DeceptionResolveContext, args: DeceptionArgs): GeneralActionOutcome { return this.resolver.resolve(context, args); } @@ -245,5 +266,6 @@ export const commandSpec: NationTurnCommandSpec = { reqArg: true, availabilityArgs: { destCityId: 0 }, argsSchema: ARGS_SCHEMA, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit), }; diff --git a/packages/logic/src/actions/turn/nation/eventResearch.ts b/packages/logic/src/actions/turn/nation/eventResearch.ts index bdbcde8..aef5f9d 100644 --- a/packages/logic/src/actions/turn/nation/eventResearch.ts +++ b/packages/logic/src/actions/turn/nation/eventResearch.ts @@ -3,10 +3,7 @@ import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/c import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js'; import { beChief, occupiedCity, reqNationGold, reqNationRice } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; -import type { - GeneralActionOutcome, - GeneralActionResolveContext, -} from '@sammo-ts/logic/actions/engine.js'; +import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import { createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; @@ -43,7 +40,9 @@ const reqNationAuxValue = (auxKey: string, actionName: string): Constraint => ({ }, }); -export const createEventResearchCommand = (config: EventResearchConfig): { +export const createEventResearchCommand = ( + config: EventResearchConfig +): { ActionDefinition: new ( env: TurnCommandEnv ) => GeneralActionDefinition>; @@ -61,6 +60,7 @@ export const createEventResearchCommand = (config: EventResearchConfig): { > implements GeneralActionDefinition> { public readonly key = config.key; public readonly name = ACTION_NAME; + public readonly countsAsInheritanceActiveAction = true; constructor(private readonly env: TurnCommandEnv) {} @@ -93,6 +93,10 @@ export const createEventResearchCommand = (config: EventResearchConfig): { ]; } + getPreReqTurn(): number { + return PRE_REQ_TURN; + } + resolve( context: GeneralActionResolveContext, _args: Record diff --git a/packages/logic/src/triggers/types.ts b/packages/logic/src/triggers/types.ts index 82529d4..7dd830a 100644 --- a/packages/logic/src/triggers/types.ts +++ b/packages/logic/src/triggers/types.ts @@ -21,20 +21,15 @@ export type TriggerDomesticActionType = export type TriggerDomesticVarType = 'cost' | 'score' | 'success' | 'fail' | 'train' | 'atmos' | 'rice' | 'probability'; -export type TriggerStrategicActionType = '의병모집' | '허보' | '필사즉생' | '백성동원' | '이호경식' | '수몰' | '급습'; +export type TriggerStrategicActionType = + '의병모집' | '허보' | '필사즉생' | '백성동원' | '이호경식' | '수몰' | '급습' | '피장파장'; export type TriggerStrategicVarType = 'delay' | 'globalDelay'; export type TriggerNationalIncomeType = 'gold' | 'rice' | 'pop'; export type GeneralStatName = - | 'leadership' - | 'strength' - | 'intelligence' - | 'experience' - | 'dedication' - | 'sabotageDefence' - | 'sabotageAttack'; + 'leadership' | 'strength' | 'intelligence' | 'experience' | 'dedication' | 'sabotageDefence' | 'sabotageAttack'; export type WarStatName = | GeneralStatName diff --git a/packages/logic/test/actions/turn/nationMissing.test.ts b/packages/logic/test/actions/turn/nationMissing.test.ts index 47dd8fa..01a4f2e 100644 --- a/packages/logic/test/actions/turn/nationMissing.test.ts +++ b/packages/logic/test/actions/turn/nationMissing.test.ts @@ -9,6 +9,7 @@ import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js'; import { ActionDefinition as StopWarProposalAction } from '../../../src/actions/turn/nation/che_종전제의.js'; import { ActionDefinition as ScorchedEarthAction } from '../../../src/actions/turn/nation/che_초토화.js'; import { ActionDefinition as CounterStrategyAction } from '../../../src/actions/turn/nation/che_피장파장.js'; +import { ActionDefinition as MaterialAidAction } from '../../../src/actions/turn/nation/che_물자원조.js'; import { ActionDefinition as PopulationMoveAction } from '../../../src/actions/turn/nation/cr_인구이동.js'; import { ActionDefinition as EventWonyungAction } from '../../../src/actions/turn/nation/event_원융노병연구.js'; import { ActionDefinition as EventHwasibyeongAction } from '../../../src/actions/turn/nation/event_화시병연구.js'; @@ -205,13 +206,7 @@ const buildEnv = (): TurnCommandEnv => ({ maxResourceActionAmount: 100000, }); -const setupDiplomacy = ( - view: TestStateView, - srcNationId: number, - destNationId: number, - state: number, - term = 0 -) => { +const setupDiplomacy = (view: TestStateView, srcNationId: number, destNationId: number, state: number, term = 0) => { view.set( { kind: 'diplomacy', @@ -267,6 +262,9 @@ describe('Nation Missing Actions', () => { general, city, nation, + destNation, + messageTime: new Date('2026-01-01T00:00:00Z'), + messageValidMinutes: 30, rng: {} as any, addLog: () => {}, } as any, @@ -275,6 +273,15 @@ describe('Nation Missing Actions', () => { ); expect(resolution.logs.some((log) => log.text.includes('종전 제의'))).toBe(true); + expect(resolution.effects).toContainEqual( + expect.objectContaining({ + type: 'message:add', + draft: expect.objectContaining({ + msgType: 'diplomacy', + option: { action: 'stopWar', deletable: false }, + }), + }) + ); }); it('che_피장파장: blocks when not at war/declare', () => { @@ -323,6 +330,7 @@ describe('Nation Missing Actions', () => { destNation, friendlyGenerals: [general, otherGeneral], destNationGenerals: [enemyGeneral], + currentYearMonth: 155, rng: {} as any, addLog: () => {}, } as any, @@ -332,6 +340,54 @@ describe('Nation Missing Actions', () => { expect(resolution.general.experience).toBeGreaterThan(100); expect(resolution.nation?.meta?.strategic_cmd_limit).toBe(8); + expect(resolution.nation?.meta?.next_execute_허보).toBe(227); + expect(resolution.patches?.nations).toContainEqual({ + id: destNation.id, + patch: expect.objectContaining({ + meta: expect.objectContaining({ next_execute_허보: 215 }), + }), + }); + }); + + it('che_물자원조: validates amounts and records the legacy receive-assist accumulator', () => { + const definition = new MaterialAidAction(buildEnv()); + expect(definition.parseArgs({ destNationId: 2, amountList: [0, 0] })).toBeNull(); + expect(definition.parseArgs({ destNationId: 2, amountList: [-1, 10] })).toBeNull(); + + const general = buildGeneral(1, 1, 1); + const nation = { ...buildNation(1), gold: 1000, rice: 1000 }; + const destNation = { ...buildNation(2), gold: 100, rice: 100 }; + const resolution = resolveGeneralAction( + definition, + { + general, + city: buildCity(1, 1), + nation, + destNation, + friendlyChiefs: [general], + destNationChiefs: [], + rng: {} as any, + addLog: () => {}, + } as any, + { now: new Date(), schedule }, + { destNationId: 2, amountList: [100, 50] } + ); + + expect(resolution.nation).toMatchObject({ + gold: 900, + rice: 950, + meta: { surlimit: 12 }, + }); + expect(resolution.patches?.nations).toContainEqual({ + id: 2, + patch: expect.objectContaining({ + gold: 200, + rice: 150, + meta: expect.objectContaining({ + recv_assist: { n1: { 0: 1, 1: 150 } }, + }), + }), + }); }); it('che_초토화: blocks when diplomacy limit exists', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fd601e..b2f5b58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 3.9.6 tsdown: specifier: ^0.22.14 - version: 0.22.14(@volar/typescript@2.4.27)(tsx@4.21.0)(typescript@7.0.2)(unrun@0.2.22(synckit@0.11.13))(vue-tsc@3.2.2(typescript@7.0.2)) + version: 0.22.14(@volar/typescript@2.4.27)(tsx@4.21.0)(typescript@7.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.2.2(typescript@7.0.2)) turbo: specifier: ^2.10.6 version: 2.10.6 @@ -50,6 +50,9 @@ importers: typescript-eslint: specifier: ^8.65.0 version: 8.65.0(eslint@10.8.0(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + typescript-legacy: + specifier: npm:typescript@5.9.3 + version: typescript@5.9.3 vue-eslint-parser: specifier: ^10.4.1 version: 10.4.1(eslint@10.8.0(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) @@ -95,7 +98,7 @@ importers: devDependencies: tsdown: specifier: ^0.18.4 - version: 0.18.4(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@7.0.2)) + version: 0.18.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@7.0.2)) vite-tsconfig-paths: specifier: ^6.0.3 version: 6.0.3(supports-color@7.2.0)(typescript@5.9.3)(vite@7.3.1(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) @@ -126,7 +129,7 @@ importers: devDependencies: tsdown: specifier: ^0.18.4 - version: 0.18.4(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@5.9.3)) + version: 0.18.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@5.9.3)) vite-tsconfig-paths: specifier: ^6.0.3 version: 6.0.3(supports-color@7.2.0)(typescript@5.9.3)(vite@7.3.1(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) @@ -266,7 +269,7 @@ importers: devDependencies: tsdown: specifier: ^0.18.4 - version: 0.18.4(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@7.0.2)) + version: 0.18.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@7.0.2)) vite-tsconfig-paths: specifier: ^6.0.3 version: 6.0.3(supports-color@7.2.0)(typescript@7.0.2)(vite@7.3.1(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) @@ -355,7 +358,7 @@ importers: devDependencies: tsdown: specifier: ^0.18.4 - version: 0.18.4(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@5.9.3)) + version: 0.18.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@5.9.3)) vitest: specifier: ^4.0.16 version: 4.0.16(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) @@ -392,7 +395,7 @@ importers: version: 7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) tsdown: specifier: ^0.18.4 - version: 0.18.4(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@5.9.3)) + version: 0.18.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@5.9.3)) packages/logic: dependencies: @@ -411,7 +414,7 @@ importers: devDependencies: tsdown: specifier: ^0.18.4 - version: 0.18.4(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@5.9.3)) + version: 0.18.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@5.9.3)) vite-tsconfig-paths: specifier: ^6.0.3 version: 6.0.3(supports-color@7.2.0)(typescript@5.9.3)(vite@7.3.1(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) @@ -5230,9 +5233,12 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.58': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.58(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true '@rolldown/binding-wasm32-wasi@1.2.0': @@ -6036,7 +6042,7 @@ snapshots: alien-signals: 3.1.2 muggle-string: 0.4.1 path-browserify: 1.0.1 - picomatch: 4.0.3 + picomatch: 4.0.5 '@vue/reactivity@3.5.26': dependencies: @@ -6290,7 +6296,7 @@ snapshots: dependencies: chokidar: 4.0.3 confbox: 0.2.2 - defu: 6.1.4 + defu: 6.1.7 dotenv: 16.6.1 exsolve: 1.0.8 giget: 2.0.0 @@ -6778,7 +6784,7 @@ snapshots: dependencies: citty: 0.1.6 consola: 3.4.2 - defu: 6.1.4 + defu: 6.1.7 node-fetch-native: 1.6.7 nypm: 0.6.2 pathe: 2.0.3 @@ -7111,7 +7117,7 @@ snapshots: consola: 3.4.2 pathe: 2.0.3 pkg-types: 2.3.0 - tinyexec: 1.0.2 + tinyexec: 1.2.4 obug@2.1.1: {} @@ -7547,7 +7553,7 @@ snapshots: rc9@2.1.2: dependencies: - defu: 6.1.4 + defu: 6.1.7 destr: 2.0.5 react-dom@19.2.3(react@19.2.3): @@ -7678,7 +7684,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.57 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.57 - rolldown@1.0.0-beta.58: + rolldown@1.0.0-beta.58(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2): dependencies: '@oxc-project/types': 0.106.0 '@rolldown/pluginutils': 1.0.0-beta.58 @@ -7693,9 +7699,12 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.58 '@rolldown/binding-linux-x64-musl': 1.0.0-beta.58 '@rolldown/binding-openharmony-arm64': 1.0.0-beta.58 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.58 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.58(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.58 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.58 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' rolldown@1.2.0: dependencies: @@ -7965,7 +7974,7 @@ snapshots: optionalDependencies: typescript: 7.0.2 - tsdown@0.18.4(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@5.9.3)): + tsdown@0.18.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@5.9.3)): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -7982,17 +7991,19 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig-core: 7.4.2 - unrun: 0.2.22(synckit@0.11.13) + unrun: 0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - '@ts-macro/tsc' - '@typescript/native-preview' - oxc-resolver - synckit - vue-tsc - tsdown@0.18.4(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@7.0.2)): + tsdown@0.18.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)(typescript@5.9.3)(vue-tsc@3.2.2(typescript@7.0.2)): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -8009,17 +8020,19 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig-core: 7.4.2 - unrun: 0.2.22(synckit@0.11.13) + unrun: 0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - '@ts-macro/tsc' - '@typescript/native-preview' - oxc-resolver - synckit - vue-tsc - tsdown@0.22.14(@volar/typescript@2.4.27)(tsx@4.21.0)(typescript@7.0.2)(unrun@0.2.22(synckit@0.11.13))(vue-tsc@3.2.2(typescript@7.0.2)): + tsdown@0.22.14(@volar/typescript@2.4.27)(tsx@4.21.0)(typescript@7.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.2.2(typescript@7.0.2)): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -8039,7 +8052,7 @@ snapshots: optionalDependencies: tsx: 4.21.0 typescript: 7.0.2 - unrun: 0.2.22(synckit@0.11.13) + unrun: 0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13) transitivePeerDependencies: - '@typescript/native-preview' - '@volar/typescript' @@ -8131,11 +8144,14 @@ snapshots: undici-types@8.3.0: {} - unrun@0.2.22(synckit@0.11.13): + unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13): dependencies: - rolldown: 1.0.0-beta.58 + rolldown: 1.0.0-beta.58(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optionalDependencies: synckit: 0.11.13 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: @@ -8200,11 +8216,11 @@ snapshots: vite@7.3.1(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 postcss: 8.5.6 rollup: 4.55.1 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.1 fsevents: 2.3.3 diff --git a/tools/compare-command-constraints.mjs b/tools/compare-command-constraints.mjs index 0a9661d..168a136 100644 --- a/tools/compare-command-constraints.mjs +++ b/tools/compare-command-constraints.mjs @@ -1,6 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import ts from 'typescript'; +import ts from 'typescript-legacy'; const ROOT_DIR = process.cwd(); const PHP_ROOT = path.join(ROOT_DIR, 'legacy', 'hwe', 'sammo', 'Command'); @@ -25,6 +25,7 @@ Options: --json Print JSON report. --show-matches Print matched command keys. --show-compat Print compatibility-matched pairs. + --check Exit non-zero when a command is missing or mismatched. --no-compat Disable compatibility alias rules (default: enabled). --compat-file Compatibility rules JSON file (default: tools/compare-command-constraints.compat.json). --similarity <0..1> Near-match threshold for non-strict mode (default: 0.6). @@ -32,6 +33,7 @@ Options: `; const args = process.argv.slice(2); +const check = args.includes('--check'); if (args.includes('--help')) { console.log(HELP_TEXT.trim()); process.exit(0); @@ -114,7 +116,7 @@ const maskPhpComments = (text) => { continue; } - if (ch === '\'' || ch === '"') { + if (ch === "'" || ch === '"') { quote = ch; i += 1; continue; @@ -205,7 +207,7 @@ const scanToDelimiter = (text, startIndex, delimiter) => { i += 1; continue; } - if (ch === '\'' || ch === '"') { + if (ch === "'" || ch === '"') { quote = ch; i += 1; continue; @@ -242,7 +244,7 @@ const scanToParenEnd = (text, startIndex) => { i += 1; continue; } - if (ch === '\'' || ch === '"') { + if (ch === "'" || ch === '"') { quote = ch; i += 1; continue; @@ -280,7 +282,7 @@ const splitTopLevel = (text, delimiter) => { } continue; } - if (ch === '\'' || ch === '"') { + if (ch === "'" || ch === '"') { quote = ch; current += ch; continue; @@ -389,7 +391,7 @@ const stripWrappingQuotes = (value) => { return ''; } if ( - (text.startsWith('\'') && text.endsWith('\'')) || + (text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')) || (text.startsWith('`') && text.endsWith('`')) ) { @@ -830,8 +832,7 @@ const extractPhpFileConstraints = (file, text) => { regex.lastIndex = endIndex; } - const classMatch = - /class\s+([\\\p{L}_][\\\p{L}\p{N}_]*)\s+extends\s+([\\\p{L}_][\\\p{L}\p{N}_]*)/mu.exec(text); + const classMatch = /class\s+([\\\p{L}_][\\\p{L}\p{N}_]*)\s+extends\s+([\\\p{L}_][\\\p{L}\p{N}_]*)/mu.exec(text); const parentClass = classMatch?.[2]?.split('\\').pop() ?? null; return { @@ -902,11 +903,7 @@ const readStringLikeProperty = (objLiteral, keyName) => { if (!ts.isPropertyAssignment(prop)) { continue; } - const key = ts.isIdentifier(prop.name) - ? prop.name.text - : ts.isStringLiteral(prop.name) - ? prop.name.text - : null; + const key = ts.isIdentifier(prop.name) ? prop.name.text : ts.isStringLiteral(prop.name) ? prop.name.text : null; if (key !== keyName) { continue; } @@ -1337,7 +1334,12 @@ const extractTsFileConstraints = (file, text) => { let parentFile = null; const visit = (node) => { - if (!parentFile && ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) { + if ( + !parentFile && + ts.isVariableDeclaration(node) && + node.initializer && + ts.isCallExpression(node.initializer) + ) { const callee = node.initializer.expression; if (ts.isIdentifier(callee)) { const moduleText = imports.get(callee.text); @@ -1373,15 +1375,11 @@ const extractTsFileConstraints = (file, text) => { const name = node.name.text; if (name === 'buildConstraints') { assigned.full = true; - byKind.full.push( - ...extractTsMethodConstraints(node, sourceFile, 'ts', 'full', relFile, factorySet) - ); + byKind.full.push(...extractTsMethodConstraints(node, sourceFile, 'ts', 'full', relFile, factorySet)); } if (name === 'buildMinConstraints') { assigned.min = true; - byKind.min.push( - ...extractTsMethodConstraints(node, sourceFile, 'ts', 'min', relFile, factorySet) - ); + byKind.min.push(...extractTsMethodConstraints(node, sourceFile, 'ts', 'min', relFile, factorySet)); } } ts.forEachChild(node, visit); @@ -1834,7 +1832,9 @@ const printReport = (report) => { if (result.near.length > 0) { console.log(` Near match (${result.near.length}):`); for (const pair of result.near) { - console.log(` - PHP "${pair.phpName}" ~ TS "${pair.tsName}" (score ${pair.score.toFixed(2)})`); + console.log( + ` - PHP "${pair.phpName}" ~ TS "${pair.tsName}" (score ${pair.score.toFixed(2)})` + ); } } if (result.argDiffs.length > 0) { @@ -1904,9 +1904,18 @@ const main = async () => { })), }; console.log(JSON.stringify(jsonFriendly, null, 2)); - return; + } else { + printReport(report); + } + if ( + check && + (report.totals.mismatch > 0 || + report.totals.nearMatch > 0 || + report.totals.missingCommandInTs > 0 || + report.totals.missingCommandInPhp > 0) + ) { + process.exitCode = 1; } - printReport(report); }; main().catch((error) => { diff --git a/tools/compare-command-logs.mjs b/tools/compare-command-logs.mjs index 86a705f..2d7e722 100644 --- a/tools/compare-command-logs.mjs +++ b/tools/compare-command-logs.mjs @@ -1,6 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import ts from 'typescript'; +import ts from 'typescript-legacy'; const ROOT_DIR = process.cwd(); const PHP_ROOT = path.join(ROOT_DIR, 'legacy', 'hwe', 'sammo', 'Command'); @@ -24,6 +24,7 @@ Options: --keep-date Keep <1>... date markers in normalized output. --ignore-file JSON ignore list file (default: tools/compare-command-logs.ignore.json). --checklist Output a markdown checklist for mismatches. + --check Exit non-zero when a command is missing or mismatched. --json Output JSON report. --help Show this help. `; @@ -40,6 +41,7 @@ const includeGuards = args.includes('--include-guards'); const includeTarget = args.includes('--include-target'); const countSensitive = args.includes('--count-sensitive'); const checklist = args.includes('--checklist'); +const check = args.includes('--check'); const ignoreFileIndex = args.indexOf('--ignore-file'); const ignoreFile = ignoreFileIndex >= 0 ? args[ignoreFileIndex + 1] : DEFAULT_IGNORE_FILE; @@ -62,6 +64,7 @@ const guardPatterns = [ /병종 정보를 확인할 수 없어/, /현재 선택할 수 없는 병종입니다/, /도시 정보가 없어/, + /도달할 방법이 없습니다/, ]; const excludeGuards = DEFAULT_EXCLUDE_GUARDS && !includeGuards; @@ -127,7 +130,7 @@ const scanToDelimiter = (text, startIndex, delimiter) => { continue; } - if (ch === '\'' || ch === '"') { + if (ch === "'" || ch === '"') { quote = ch; i += 1; continue; @@ -166,7 +169,7 @@ const scanToFirstArgumentEnd = (text, startIndex) => { continue; } - if (ch === '\'' || ch === '"') { + if (ch === "'" || ch === '"') { quote = ch; i += 1; continue; @@ -208,7 +211,7 @@ const scanToParenEnd = (text, startIndex) => { continue; } - if (ch === '\'' || ch === '"') { + if (ch === "'" || ch === '"') { quote = ch; i += 1; continue; @@ -249,7 +252,7 @@ const splitTopLevel = (text, delimiter) => { continue; } - if (ch === '\'' || ch === '"') { + if (ch === "'" || ch === '"') { quote = ch; current += ch; continue; @@ -280,7 +283,7 @@ const splitTopLevel = (text, delimiter) => { const parsePhpStringLiteral = (segment) => { const trimmed = segment.trim(); const quote = trimmed[0]; - if (quote !== '\'' && quote !== '"') { + if (quote !== "'" && quote !== '"') { return null; } @@ -505,7 +508,8 @@ const extractPhpLogCalls = (text, assignments) => { } const parsed = parsePhpExprToTemplate(value, assignments, match.index); - const hasGeneralId = methodMeta.generalMethod && !isPhpActorLoggerExpr(calleeExpr, actorGeneralVars, actorLoggerVars); + const hasGeneralId = + methodMeta.generalMethod && !isPhpActorLoggerExpr(calleeExpr, actorGeneralVars, actorLoggerVars); results.push({ pos: match.index, @@ -615,10 +619,7 @@ const extractTsLogs = (filePath, text) => { if (!ts.isIdentifier(decl.name) || !decl.initializer) { continue; } - if ( - ts.isStringLiteral(decl.initializer) || - ts.isNoSubstitutionTemplateLiteral(decl.initializer) - ) { + if (ts.isStringLiteral(decl.initializer) || ts.isNoSubstitutionTemplateLiteral(decl.initializer)) { constants.set(decl.name.text, decl.initializer.text); } } @@ -684,8 +685,7 @@ const extractTsLogs = (filePath, text) => { return; } const nameProp = arg.properties.find( - (prop) => - ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === 'name' + (prop) => ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === 'name' ); if (!nameProp || !ts.isPropertyAssignment(nameProp)) { ts.forEachChild(node, visitFactory); @@ -780,8 +780,7 @@ const loadIgnoreConfig = async () => { const compileIgnoreRules = (config) => { const global = config.Global ?? {}; const normalizeList = (list) => (Array.isArray(list) ? list.map((item) => normalizeTemplate(String(item))) : []); - const compileRegex = (list) => - Array.isArray(list) ? list.map((item) => new RegExp(String(item))) : []; + const compileRegex = (list) => (Array.isArray(list) ? list.map((item) => new RegExp(String(item))) : []); return { globalTemplates: new Set(normalizeList(global.templates)), @@ -1127,69 +1126,71 @@ const main = async () => { if (asJson) { console.log(JSON.stringify(report, null, 2)); - return; - } + } else { + console.log( + `Compare command logs (mode: ${mode}, strict: ${strict ? 'on' : 'off'}, keepDate: ${keepDate ? 'on' : 'off'}, excludeGuards: ${excludeGuards ? 'on' : 'off'}, excludeTarget: ${excludeTarget ? 'on' : 'off'}, countSensitive: ${countSensitive ? 'on' : 'off'})` + ); + console.log(`PHP commands: ${report.totals.phpCommands}`); + console.log(`TS commands: ${report.totals.tsCommands}`); + console.log(`Matched commands: ${report.totals.matches}`); + console.log(`Mismatched commands: ${report.totals.mismatches}`); + console.log(`Missing in TS: ${report.missingInTs.length}`); + console.log(`Missing in PHP: ${report.missingInPhp.length}`); + console.log(`Ignored mismatches: ${report.totals.ignored}`); - console.log( - `Compare command logs (mode: ${mode}, strict: ${strict ? 'on' : 'off'}, keepDate: ${keepDate ? 'on' : 'off'}, excludeGuards: ${excludeGuards ? 'on' : 'off'}, excludeTarget: ${excludeTarget ? 'on' : 'off'}, countSensitive: ${countSensitive ? 'on' : 'off'})` - ); - console.log(`PHP commands: ${report.totals.phpCommands}`); - console.log(`TS commands: ${report.totals.tsCommands}`); - console.log(`Matched commands: ${report.totals.matches}`); - console.log(`Mismatched commands: ${report.totals.mismatches}`); - console.log(`Missing in TS: ${report.missingInTs.length}`); - console.log(`Missing in PHP: ${report.missingInPhp.length}`); - console.log(`Ignored mismatches: ${report.totals.ignored}`); - - if (report.missingInTs.length > 0) { - console.log('\nMissing in TS:'); - for (const key of report.missingInTs) { - console.log(`- ${key}`); - } - } - - if (report.missingInPhp.length > 0) { - console.log('\nMissing in PHP:'); - for (const key of report.missingInPhp) { - console.log(`- ${key}`); - } - } - - if (report.mismatches.length > 0) { - console.log('\nMismatch Details:'); - for (const mismatch of report.mismatches) { - console.log(`\n== ${mismatch.key} ==`); - if (mismatch.missingDetails.length > 0) { - console.log( - `PHP only: ${mismatch.missingDetails - .map((item) => (item.count > 1 ? `${item.template} x${item.count}` : item.template)) - .join(' | ')}` - ); - } - if (mismatch.extraDetails.length > 0) { - console.log( - `TS only: ${mismatch.extraDetails - .map((item) => (item.count > 1 ? `${item.template} x${item.count}` : item.template)) - .join(' | ')}` - ); - } - const phpLines = formatEntries(mismatch.phpEntries); - const tsLines = formatEntries(mismatch.tsEntries); - - console.log('PHP:'); - for (const line of phpLines) { - console.log(line); - } - console.log('TS:'); - for (const line of tsLines) { - console.log(line); + if (report.missingInTs.length > 0) { + console.log('\nMissing in TS:'); + for (const key of report.missingInTs) { + console.log(`- ${key}`); } } - } - if (checklist) { - console.log('\nChecklist:'); - console.log(renderChecklist(report)); + if (report.missingInPhp.length > 0) { + console.log('\nMissing in PHP:'); + for (const key of report.missingInPhp) { + console.log(`- ${key}`); + } + } + + if (report.mismatches.length > 0) { + console.log('\nMismatch Details:'); + for (const mismatch of report.mismatches) { + console.log(`\n== ${mismatch.key} ==`); + if (mismatch.missingDetails.length > 0) { + console.log( + `PHP only: ${mismatch.missingDetails + .map((item) => (item.count > 1 ? `${item.template} x${item.count}` : item.template)) + .join(' | ')}` + ); + } + if (mismatch.extraDetails.length > 0) { + console.log( + `TS only: ${mismatch.extraDetails + .map((item) => (item.count > 1 ? `${item.template} x${item.count}` : item.template)) + .join(' | ')}` + ); + } + const phpLines = formatEntries(mismatch.phpEntries); + const tsLines = formatEntries(mismatch.tsEntries); + + console.log('PHP:'); + for (const line of phpLines) { + console.log(line); + } + console.log('TS:'); + for (const line of tsLines) { + console.log(line); + } + } + } + + if (checklist) { + console.log('\nChecklist:'); + console.log(renderChecklist(report)); + } + } + if (check && (report.totals.mismatches > 0 || report.missingInTs.length > 0 || report.missingInPhp.length > 0)) { + process.exitCode = 1; } };