Merge feature/nation-turn-audit
This commit is contained in:
@@ -11,18 +11,16 @@ export const do불가침제의 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
const meta = asRecord(ai.nation.meta);
|
||||
const recvAssist = Array.isArray(meta.recv_assist) ? meta.recv_assist : [];
|
||||
const recvAssist = Array.isArray(meta.recv_assist) ? meta.recv_assist : Object.values(asRecord(meta.recv_assist));
|
||||
const respAssist = asRecord(meta.resp_assist);
|
||||
const respAssistTry = asRecord(meta.resp_assist_try);
|
||||
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
|
||||
|
||||
const candidateList: Record<number, number> = {};
|
||||
for (const entry of recvAssist) {
|
||||
if (!Array.isArray(entry) || entry.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const destNationId = Number(entry[0]);
|
||||
const amount = Number(entry[1]);
|
||||
const entryRecord = asRecord(entry);
|
||||
const destNationId = Number(Array.isArray(entry) ? entry[0] : entryRecord['0']);
|
||||
const amount = Number(Array.isArray(entry) ? entry[1] : entryRecord['1']);
|
||||
if (!Number.isFinite(destNationId) || !Number.isFinite(amount)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
type TurnEngineTroopUpdateInput,
|
||||
type TurnEngineWorldStateUpdateInput,
|
||||
} from '@sammo-ts/infra';
|
||||
import { finalizeLogEntry, LogCategory, LogScope, type LogEntryDraft } from '@sammo-ts/logic';
|
||||
import {
|
||||
finalizeLogEntry,
|
||||
LogCategory,
|
||||
LogScope,
|
||||
sendMessage,
|
||||
type LogEntryDraft,
|
||||
type MessageRecordDraft,
|
||||
} from '@sammo-ts/logic';
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
|
||||
@@ -323,6 +330,7 @@ export const createDatabaseTurnHooks = async (
|
||||
deletedNationSnapshots,
|
||||
diplomacy,
|
||||
logs,
|
||||
messages,
|
||||
createdGenerals,
|
||||
createdNations,
|
||||
createdTroops,
|
||||
@@ -566,6 +574,33 @@ export const createDatabaseTurnHooks = async (
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const message of messages) {
|
||||
await sendMessage(
|
||||
{
|
||||
insertMessage: async (draft: MessageRecordDraft) => {
|
||||
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${draft.validUntil},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error('Failed to persist turn message.');
|
||||
}
|
||||
return id;
|
||||
},
|
||||
},
|
||||
message
|
||||
);
|
||||
}
|
||||
if (options?.reservedTurns && reservedTurnChanges) {
|
||||
await options.reservedTurns.persistChanges(prisma, reservedTurnChanges);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { City, LogEntryDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic';
|
||||
import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic';
|
||||
import { getNextTurnAt } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
||||
@@ -25,6 +25,7 @@ export interface GeneralTurnResult {
|
||||
nation?: Nation | null;
|
||||
nextTurnAt?: Date;
|
||||
logs?: LogEntryDraft[];
|
||||
messages?: MessageDraft[];
|
||||
patches?: {
|
||||
generals: Array<{ id: number; patch: Partial<TurnGeneral> }>;
|
||||
cities: Array<{ id: number; patch: Partial<City> }>;
|
||||
@@ -79,6 +80,7 @@ export interface TurnWorldChanges {
|
||||
deletedNationSnapshots: Array<{ nation: Nation; generalIds: number[]; removedAt: Date }>;
|
||||
diplomacy: TurnDiplomacy[];
|
||||
logs: LogEntryDraft[];
|
||||
messages: MessageDraft[];
|
||||
createdGenerals: TurnGeneral[];
|
||||
createdNations: Nation[];
|
||||
createdTroops: Troop[];
|
||||
@@ -247,6 +249,7 @@ export class InMemoryTurnWorld {
|
||||
removedAt: Date;
|
||||
}> = [];
|
||||
private readonly logs: LogEntryDraft[] = [];
|
||||
private readonly messages: MessageDraft[] = [];
|
||||
private readonly scenarioConfig: ScenarioConfig;
|
||||
private checkpoint?: TurnCheckpoint;
|
||||
private state: TurnWorldState;
|
||||
@@ -587,6 +590,9 @@ export class InMemoryTurnWorld {
|
||||
if (result.logs && result.logs.length > 0) {
|
||||
this.logs.push(...result.logs);
|
||||
}
|
||||
if (result.messages && result.messages.length > 0) {
|
||||
this.messages.push(...result.messages);
|
||||
}
|
||||
if (result.patches) {
|
||||
for (const patch of result.patches.generals) {
|
||||
const target = this.generals.get(patch.id);
|
||||
@@ -744,6 +750,7 @@ export class InMemoryTurnWorld {
|
||||
const deletedNations = Array.from(this.deletedNationIds);
|
||||
const deletedNationSnapshots = this.deletedNationSnapshots.slice();
|
||||
const logs = this.logs.slice();
|
||||
const messages = this.messages.slice();
|
||||
|
||||
return {
|
||||
generals,
|
||||
@@ -756,6 +763,7 @@ export class InMemoryTurnWorld {
|
||||
deletedNationSnapshots,
|
||||
diplomacy,
|
||||
logs,
|
||||
messages,
|
||||
createdGenerals,
|
||||
createdNations,
|
||||
createdTroops,
|
||||
@@ -782,6 +790,7 @@ export class InMemoryTurnWorld {
|
||||
for (const id of changes.deletedNations) this.deletedNationIds.delete(id);
|
||||
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
|
||||
this.logs.splice(0, changes.logs.length);
|
||||
this.messages.splice(0, changes.messages.length);
|
||||
}
|
||||
|
||||
consumeDirtyState(): TurnWorldChanges {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
|
||||
|
||||
const decrementLimit = (value: unknown): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.floor(value) - 1);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.max(0, Math.floor(parsed) - 1);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// ref preUpdateMonthly(): 전략 제한과 외교 제한은 매 월턴마다 1씩 감소한다.
|
||||
export const createNationTurnMonthlyHandler = (options: {
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
}): TurnCalendarHandler => ({
|
||||
onMonthChanged: () => {
|
||||
const world = options.getWorld();
|
||||
if (!world) {
|
||||
return;
|
||||
}
|
||||
for (const nation of world.listNations()) {
|
||||
const meta = asRecord(nation.meta);
|
||||
world.updateNation(nation.id, {
|
||||
meta: {
|
||||
...nation.meta,
|
||||
strategic_cmd_limit: decrementLimit(meta.strategic_cmd_limit),
|
||||
surlimit: decrementLimit(meta.surlimit),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
GeneralActionDefinition,
|
||||
LogEntryDraft,
|
||||
MapDefinition,
|
||||
MessageDraft,
|
||||
Nation,
|
||||
ScenarioConfig,
|
||||
ScenarioMeta,
|
||||
@@ -100,6 +101,40 @@ const serializeSeed = (...values: Array<string | number>): string =>
|
||||
|
||||
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
||||
|
||||
type NationLastTurn = {
|
||||
command: string;
|
||||
arg?: Record<string, unknown>;
|
||||
term?: number;
|
||||
seq?: number;
|
||||
};
|
||||
|
||||
const nationLastTurnKey = (officerLevel: number): string => `turn_last_${officerLevel}`;
|
||||
|
||||
const normalizeLastTurn = (value: unknown): NationLastTurn => {
|
||||
const raw = asRecord(value);
|
||||
return {
|
||||
command: typeof raw.command === 'string' ? raw.command : '휴식',
|
||||
...(asRecord(raw.arg) && Object.keys(asRecord(raw.arg)).length > 0 ? { arg: asRecord(raw.arg) } : undefined),
|
||||
...(typeof raw.term === 'number' && Number.isFinite(raw.term) ? { term: Math.floor(raw.term) } : undefined),
|
||||
...(typeof raw.seq === 'number' && Number.isFinite(raw.seq) ? { seq: Math.floor(raw.seq) } : undefined),
|
||||
};
|
||||
};
|
||||
|
||||
const sameArgs = (left: Record<string, unknown> | undefined, right: Record<string, unknown>): boolean =>
|
||||
JSON.stringify(left ?? {}) === JSON.stringify(right);
|
||||
|
||||
const readNextAvailableTurn = (nation: Nation, actionName: string): number | null => {
|
||||
const raw = asRecord(nation.meta)[`next_execute_${actionName}`];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
return Math.floor(raw);
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
|
||||
const value = meta[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -214,7 +249,6 @@ const buildUniqueLotteryRunner = (options: {
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
type WorldView = {
|
||||
getGeneralById(id: number): TurnGeneral | null;
|
||||
getCityById(id: number): City | null;
|
||||
@@ -573,6 +607,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
...(options.unitSet ? { unitSet: options.unitSet } : {}),
|
||||
};
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const messages: MessageDraft[] = [];
|
||||
const patches = {
|
||||
generals: [] as Array<{ id: number; patch: Partial<TurnGeneral> }>,
|
||||
cities: [] as Array<{ id: number; patch: Partial<City> }>,
|
||||
@@ -592,6 +627,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
let currentNation = context.nation ?? null;
|
||||
|
||||
const runAction = (
|
||||
kind: 'nation' | 'general',
|
||||
definitionMap: Map<string, GeneralActionDefinition>,
|
||||
fallbackDefinition: GeneralActionDefinition,
|
||||
command: ReservedTurnEntry,
|
||||
@@ -644,6 +680,19 @@ export const createReservedTurnHandler = async (options: {
|
||||
const meta = result.kind === 'deny' ? { constraintName: result.constraintName } : undefined;
|
||||
logs.push(createActionLog(reason, meta));
|
||||
}
|
||||
if (kind === 'nation' && !usedFallback && currentNation) {
|
||||
const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth);
|
||||
const nextAvailableTurn = readNextAvailableTurn(currentNation, definition.name);
|
||||
if (nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn) {
|
||||
const remainTurn = nextAvailableTurn - currentYearMonth;
|
||||
definition = fallbackDefinition;
|
||||
actionArgs = definition.parseArgs({}) ?? {};
|
||||
actionKey = definition.key;
|
||||
usedFallback = true;
|
||||
blockedReason = `${remainTurn}턴 더 기다려야 합니다`;
|
||||
logs.push(createActionLog(blockedReason));
|
||||
}
|
||||
}
|
||||
|
||||
const seedBase = buildSeedBase(context.world);
|
||||
const buildRng = (key: string) => {
|
||||
@@ -694,6 +743,8 @@ export const createReservedTurnHandler = async (options: {
|
||||
definition = fallbackDefinition;
|
||||
actionArgs = definition.parseArgs({}) ?? {};
|
||||
actionKey = definition.key;
|
||||
usedFallback = true;
|
||||
blockedReason = '예약된 명령을 실행하지 못했습니다.';
|
||||
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
|
||||
baseContext = {
|
||||
general: currentGeneral,
|
||||
@@ -704,6 +755,52 @@ export const createReservedTurnHandler = async (options: {
|
||||
specificContext = baseContext;
|
||||
}
|
||||
const actionContext = specificContext ?? baseContext;
|
||||
const executionDefinition = definition as unknown as {
|
||||
getPreReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
||||
getPostReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
||||
getStackSequence?: (context: ActionContextBase, args: unknown) => number | null;
|
||||
};
|
||||
const preReqTurn =
|
||||
kind === 'nation' && !usedFallback
|
||||
? Math.max(0, Math.floor(executionDefinition.getPreReqTurn?.(actionContext, actionArgs) ?? 0))
|
||||
: 0;
|
||||
const postReqTurn =
|
||||
kind === 'nation' && !usedFallback
|
||||
? Math.max(0, Math.floor(executionDefinition.getPostReqTurn?.(actionContext, actionArgs) ?? 0))
|
||||
: 0;
|
||||
|
||||
if (kind === 'nation' && !usedFallback && currentNation && preReqTurn > 0) {
|
||||
const metaKey = nationLastTurnKey(currentGeneral.officerLevel);
|
||||
const lastTurn = normalizeLastTurn(asRecord(currentNation.meta)[metaKey]);
|
||||
const stackSequence = executionDefinition.getStackSequence?.(actionContext, actionArgs) ?? null;
|
||||
const sequenceChanged =
|
||||
stackSequence !== null && (lastTurn.seq === undefined || lastTurn.seq < stackSequence);
|
||||
const continuing =
|
||||
lastTurn.command === definition.name &&
|
||||
sameArgs(lastTurn.arg, actionArgsRecord) &&
|
||||
!sequenceChanged;
|
||||
const nextTerm = continuing ? (lastTurn.term ?? 0) + 1 : 1;
|
||||
|
||||
if (!continuing || (lastTurn.term ?? 0) < preReqTurn) {
|
||||
const nextLastTurn: NationLastTurn = {
|
||||
command: definition.name,
|
||||
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
||||
term: nextTerm,
|
||||
...(stackSequence !== null ? { seq: stackSequence } : undefined),
|
||||
};
|
||||
const nextNation: Nation = {
|
||||
...currentNation,
|
||||
meta: {
|
||||
...currentNation.meta,
|
||||
[metaKey]: nextLastTurn,
|
||||
} as Nation['meta'],
|
||||
};
|
||||
currentNation = nextNation;
|
||||
worldOverlay?.syncNation(nextNation);
|
||||
logs.push(createActionLog(`${definition.name} 수행중... (${nextTerm}/${preReqTurn + 1})`));
|
||||
return { actionKey, usedFallback, blockedReason };
|
||||
}
|
||||
}
|
||||
|
||||
const resolution = resolveGeneralAction(
|
||||
definition,
|
||||
@@ -718,12 +815,39 @@ export const createReservedTurnHandler = async (options: {
|
||||
currentGeneral = resolution.general as TurnGeneral;
|
||||
currentCity = resolution.city ?? currentCity;
|
||||
currentNation = resolution.nation ?? currentNation;
|
||||
if (kind === 'nation' && !usedFallback && definition.countsAsInheritanceActiveAction) {
|
||||
const meta = { ...currentGeneral.meta };
|
||||
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
|
||||
meta.inherit_active_action = active + 1;
|
||||
currentGeneral = { ...currentGeneral, meta };
|
||||
}
|
||||
|
||||
if (!currentNation && resolution.created?.nations) {
|
||||
currentNation =
|
||||
(resolution.created.nations as Nation[]).find((n) => n.id === currentGeneral.nationId) ??
|
||||
currentNation;
|
||||
}
|
||||
if (kind === 'nation' && !usedFallback && currentNation) {
|
||||
const metaKey = nationLastTurnKey(currentGeneral.officerLevel);
|
||||
const nextMeta: Record<string, unknown> = {
|
||||
...currentNation.meta,
|
||||
[metaKey]: {
|
||||
command: definition.name,
|
||||
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
||||
term: 0,
|
||||
} satisfies NationLastTurn,
|
||||
};
|
||||
if (postReqTurn > 0) {
|
||||
nextMeta[`next_execute_${definition.name}`] =
|
||||
joinYearMonth(context.world.currentYear, context.world.currentMonth) +
|
||||
postReqTurn -
|
||||
preReqTurn;
|
||||
}
|
||||
currentNation = {
|
||||
...currentNation,
|
||||
meta: nextMeta as Nation['meta'],
|
||||
};
|
||||
}
|
||||
|
||||
logs.push(...resolution.logs);
|
||||
if (worldOverlay) {
|
||||
@@ -738,15 +862,16 @@ export const createReservedTurnHandler = async (options: {
|
||||
|
||||
if (resolution.effects.length > 0) {
|
||||
for (const effect of resolution.effects) {
|
||||
if (effect.type !== 'diplomacy:patch') {
|
||||
continue;
|
||||
if (effect.type === 'message:add') {
|
||||
messages.push(effect.draft);
|
||||
} else if (effect.type === 'diplomacy:patch') {
|
||||
diplomacyPatches.push({
|
||||
srcNationId: effect.srcNationId,
|
||||
destNationId: effect.destNationId,
|
||||
patch: effect.patch,
|
||||
});
|
||||
worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch);
|
||||
}
|
||||
diplomacyPatches.push({
|
||||
srcNationId: effect.srcNationId,
|
||||
destNationId: effect.destNationId,
|
||||
patch: effect.patch,
|
||||
});
|
||||
worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,7 +951,12 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
}
|
||||
|
||||
return { nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined, actionKey, usedFallback, blockedReason };
|
||||
return {
|
||||
nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined,
|
||||
actionKey,
|
||||
usedFallback,
|
||||
blockedReason,
|
||||
};
|
||||
};
|
||||
|
||||
if (currentNation && currentGeneral.officerLevel >= 5) {
|
||||
@@ -860,7 +990,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
nationAiState = ai.getDebugState();
|
||||
}
|
||||
const nationResult = runAction(nationDefinitions, nationFallback, nationCommand, false);
|
||||
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
|
||||
options.onActionResolved?.({
|
||||
kind: 'nation',
|
||||
generalId: currentGeneral.id,
|
||||
@@ -900,7 +1030,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
generalAiState = ai.getDebugState();
|
||||
}
|
||||
const generalResult = runAction(generalDefinitions, generalFallback, generalCommand, true);
|
||||
const generalResult = runAction('general', generalDefinitions, generalFallback, generalCommand, true);
|
||||
options.onActionResolved?.({
|
||||
kind: 'general',
|
||||
generalId: currentGeneral.id,
|
||||
@@ -935,6 +1065,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
nation: currentNation,
|
||||
nextTurnAt,
|
||||
logs,
|
||||
...(messages.length > 0 ? { messages } : undefined),
|
||||
patches,
|
||||
...(diplomacyPatches.length > 0 ? { diplomacyPatches } : undefined),
|
||||
created:
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js';
|
||||
import { createGatewayProfileGate } from './gatewayProfileGate.js';
|
||||
import { composeCalendarHandlers } from './calendarHandlers.js';
|
||||
import { createIncomeHandler } from './incomeHandler.js';
|
||||
import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js';
|
||||
import { createFrontStateHandler } from './frontStateHandler.js';
|
||||
import { createReservedTurnHandler } from './reservedTurnHandler.js';
|
||||
import { createReservedTurnStore } from './reservedTurnStore.js';
|
||||
@@ -124,6 +125,9 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
scenarioConfig: snapshot.scenarioConfig,
|
||||
nationTraits: nationTraitMap,
|
||||
});
|
||||
const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({
|
||||
getWorld: () => worldRef,
|
||||
});
|
||||
const frontStateHandler = createFrontStateHandler({
|
||||
getWorld: () => worldRef,
|
||||
map: snapshot.map ?? null,
|
||||
@@ -141,6 +145,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
});
|
||||
const calendarHandler = composeCalendarHandlers(
|
||||
options.calendarHandler ?? unification?.handler,
|
||||
nationTurnMonthlyHandler,
|
||||
incomeHandler,
|
||||
frontStateHandler,
|
||||
tournamentAutoStartHandler,
|
||||
|
||||
@@ -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<InMemoryTurnWorld['getCityById']>) => {
|
||||
};
|
||||
};
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<string, unknown> }> = [];
|
||||
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<string, unknown>) => 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 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<TriggerState>;
|
||||
}
|
||||
|
||||
@@ -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<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
||||
| GeneralPatchEffect<TriggerState>
|
||||
| GeneralAddEffect<TriggerState>
|
||||
@@ -94,6 +100,7 @@ export type GeneralActionEffect<TriggerState extends GeneralTriggerState = Gener
|
||||
| NationAddEffect
|
||||
| DiplomacyPatchEffect
|
||||
| LogEffect
|
||||
| MessageAddEffect
|
||||
| NextTurnOverrideEffect;
|
||||
|
||||
export interface GeneralActionOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
@@ -203,6 +210,11 @@ export const createLogEffect = (message: string, options: Partial<Omit<LogEntryD
|
||||
},
|
||||
});
|
||||
|
||||
export const createMessageEffect = (draft: MessageDraft): MessageAddEffect => ({
|
||||
type: 'message:add',
|
||||
draft,
|
||||
});
|
||||
|
||||
export const createNextTurnOverrideEffect = (nextTurnAt: Date): NextTurnOverrideEffect => ({
|
||||
type: 'schedule:override',
|
||||
nextTurnAt,
|
||||
@@ -301,6 +313,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
||||
createdNations.push(effect.nation as Nation);
|
||||
break;
|
||||
case 'diplomacy:patch':
|
||||
case 'message:add':
|
||||
pendingEffects.push(effect);
|
||||
break;
|
||||
case 'general:patch':
|
||||
|
||||
@@ -47,14 +47,15 @@ const requireCapitalCity = (reason: string): Constraint => ({
|
||||
},
|
||||
});
|
||||
|
||||
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<TriggerState, ReduceCityArgs, ReduceCityResolveContext<TriggerState>> {
|
||||
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<TriggerState>): number {
|
||||
const value = context.nation?.meta.capset;
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: ReduceCityResolveContext<TriggerState>,
|
||||
_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(`<G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}),
|
||||
// General Action Log
|
||||
createLogEffect(`<G><b>${destCityName}</b></>${josaUl} ${ACTION_NAME}했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
|
||||
@@ -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<typeof ARGS_SCHEMA>;
|
||||
|
||||
@@ -63,6 +67,7 @@ export class ActionDefinition<
|
||||
> implements GeneralActionDefinition<TriggerState, ChangeFlagArgs> {
|
||||
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(`<span style='color:${color};'><b>국기</b></span>를 변경`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}),
|
||||
// General Action Log
|
||||
createLogEffect(`<span style='color:${color};'><b>국기</b></span>를 변경하였습니다`, {
|
||||
scope: LogScope.GENERAL,
|
||||
|
||||
@@ -27,6 +27,7 @@ export class ActionDefinition<
|
||||
> implements GeneralActionDefinition<TriggerState, ChangeNationNameArgs> {
|
||||
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(`국호를 <D><b>${newNationName}</b></>${josaRo} 변경`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}),
|
||||
// General Action Log
|
||||
createLogEffect(`국호를 <D><b>${newNationName}</b></>${josaRo} 변경합니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
|
||||
@@ -53,10 +53,19 @@ const TERM_REDUCE = 3;
|
||||
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
constructor(
|
||||
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||
private readonly initialNationGenLimit = 10
|
||||
) {
|
||||
this.pipeline = new GeneralActionPipeline(modules);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: RaidResolveContext<TriggerState>): 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<TriggerState>): 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<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.command = new CommandResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
|
||||
this.command = new CommandResolver(modules, initialNationGenLimit);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: RaidResolveContext<TriggerState>): number {
|
||||
return this.command.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(context: RaidResolveContext<TriggerState>, _args: RaidArgs): GeneralActionOutcome<TriggerState> {
|
||||
@@ -167,8 +180,8 @@ export class ActionDefinition<
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.resolver = new ActionResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | 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<TriggerState>): number {
|
||||
return this.resolver.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(context: RaidResolveContext<TriggerState>, args: RaidArgs): GeneralActionOutcome<TriggerState> {
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -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<TriggerState>,
|
||||
_args: RandomMoveCapitalArgs
|
||||
@@ -142,6 +153,11 @@ export class ActionDefinition<
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}
|
||||
),
|
||||
createLogEffect(`<G><b>${destCityName}</b></>${josaRo} <M>${ACTION_NAME}</>`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}),
|
||||
// General Action Log
|
||||
createLogEffect(`<G><b>${destCityName}</b></>${josaRo} 국가를 옮겼습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
|
||||
@@ -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<typeof ARGS_SCHEMA>;
|
||||
|
||||
export interface MaterialAidResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
destNation: Nation;
|
||||
friendlyChiefs: Array<General<TriggerState>>;
|
||||
destNationChiefs: Array<General<TriggerState>>;
|
||||
}
|
||||
|
||||
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 = `<D><b>${destNation.name}</b></>${josaRo} 금<C>${goldText}</> 쌀<C>${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<GeneralActionEffect<TriggerState>> = [
|
||||
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(
|
||||
`<D><b>${destNation.name}</b></>${josaRo} 금<C>${goldText}</> 쌀<C>${riceText}</>${josaUlRice} 지원`,
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}
|
||||
),
|
||||
createLogEffect(
|
||||
`<D><b>${destNation.name}</b></>${josaRo} 금<C>${goldText}</> 쌀<C>${riceText}</>${josaUlRice} 지원`,
|
||||
{
|
||||
@@ -157,7 +194,7 @@ export class ActionDefinition<
|
||||
),
|
||||
// Dest Nation History Log
|
||||
createLogEffect(
|
||||
`<D><b>${nationName}</b></>${JosaUtil.pick(nationName, '부터')} 금<C>${goldText}</> 쌀<C>${riceText}</>${josaUlRice} 지원 받음`,
|
||||
`<D><b>${nationName}</b></>${josaRoSrc}부터 금<C>${goldText}</> 쌀<C>${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 = `<D><b>${nationName}</b></>에서 금<C>${goldText}</> 쌀<C>${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<MaterialAidArgs> = (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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -48,10 +48,19 @@ const DEFENCE_RATE = 0.8;
|
||||
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
constructor(
|
||||
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||
private readonly initialNationGenLimit = 10
|
||||
) {
|
||||
this.pipeline = new GeneralActionPipeline(modules);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: MobilizePeopleResolveContext<TriggerState>): 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<TriggerState>): 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<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.command = new CommandResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
|
||||
this.command = new CommandResolver(modules, initialNationGenLimit);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: MobilizePeopleResolveContext<TriggerState>): number {
|
||||
return this.command.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(
|
||||
@@ -144,8 +157,8 @@ export class ActionDefinition<
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.resolver = new ActionResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | 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<TriggerState>): number {
|
||||
return this.resolver.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: MobilizePeopleResolveContext<TriggerState>,
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -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<typeof ARGS_SCHEMA>;
|
||||
|
||||
interface NonAggressionProposalContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
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<TriggerState, NonAggressionProposalArgs> {
|
||||
> implements GeneralActionDefinition<
|
||||
TriggerState,
|
||||
NonAggressionProposalArgs,
|
||||
NonAggressionProposalContext<TriggerState>
|
||||
> {
|
||||
public readonly key = 'che_불가침제의';
|
||||
public readonly name = ACTION_NAME;
|
||||
|
||||
@@ -117,14 +129,42 @@ export class ActionDefinition<
|
||||
}
|
||||
|
||||
resolve(
|
||||
_context: GeneralActionResolveContext<TriggerState>,
|
||||
context: NonAggressionProposalContext<TriggerState>,
|
||||
args: NonAggressionProposalArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
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(`<D><b>${destNationName}</b></>${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<NonAggressionProposalArgs> = (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_불가침제의',
|
||||
|
||||
@@ -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<typeof ARGS_SCHEMA>;
|
||||
|
||||
interface NonAggressionCancelProposalContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
destNation: Nation;
|
||||
messageValidMinutes: number;
|
||||
messageTime: Date;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '불가침 파기 제의';
|
||||
const DIPLOMACY_NON_AGGRESSION = 7;
|
||||
|
||||
// 불가침 파기 제의를 처리하는 국가 커맨드.
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionDefinition<TriggerState, NonAggressionCancelProposalArgs> {
|
||||
> implements GeneralActionDefinition<
|
||||
TriggerState,
|
||||
NonAggressionCancelProposalArgs,
|
||||
NonAggressionCancelProposalContext<TriggerState>
|
||||
> {
|
||||
public readonly key = 'che_불가침파기제의';
|
||||
public readonly name = ACTION_NAME;
|
||||
|
||||
@@ -57,14 +69,41 @@ export class ActionDefinition<
|
||||
}
|
||||
|
||||
resolve(
|
||||
_context: GeneralActionResolveContext<TriggerState>,
|
||||
args: NonAggressionCancelProposalArgs
|
||||
context: NonAggressionCancelProposalContext<TriggerState>,
|
||||
_args: NonAggressionCancelProposalArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
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(`<D><b>${destNationName}</b></>${josaRo} 불가침 파기 제의 서신을 보냈습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
@@ -76,7 +115,18 @@ export class ActionDefinition<
|
||||
}
|
||||
|
||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
||||
export const actionContextBuilder = defaultActionContextBuilder;
|
||||
export const actionContextBuilder: ActionContextBuilder<NonAggressionCancelProposalArgs> = (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_불가침파기제의',
|
||||
|
||||
@@ -58,10 +58,19 @@ const battleGroundCity = (): Constraint => ({
|
||||
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
constructor(
|
||||
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||
private readonly initialNationGenLimit = 10
|
||||
) {
|
||||
this.pipeline = new GeneralActionPipeline(modules);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: FloodResolveContext<TriggerState>): 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<TriggerState>): 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<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.command = new CommandResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
|
||||
this.command = new CommandResolver(modules, initialNationGenLimit);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: FloodResolveContext<TriggerState>): number {
|
||||
return this.command.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(context: FloodResolveContext<TriggerState>, _args: FloodArgs): GeneralActionOutcome<TriggerState> {
|
||||
@@ -175,8 +188,8 @@ export class ActionDefinition<
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.resolver = new ActionResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | 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<TriggerState>): number {
|
||||
return this.resolver.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(context: FloodResolveContext<TriggerState>, args: FloodArgs): GeneralActionOutcome<TriggerState> {
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -221,6 +221,11 @@ export class ActionResolver<
|
||||
this.command = new CommandResolver(modules, env);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: VolunteerRecruitResolveContext<TriggerState>): number {
|
||||
const value = context.nation ? readMetaNumber(context.nation.meta, 'gennum') : null;
|
||||
return this.command.getPostDelay(context, value ?? 0);
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: VolunteerRecruitResolveContext<TriggerState>,
|
||||
_args: VolunteerRecruitArgs
|
||||
@@ -363,6 +368,14 @@ export class ActionDefinition<
|
||||
];
|
||||
}
|
||||
|
||||
getPreReqTurn(): number {
|
||||
return DEFAULT_PRE_TURN;
|
||||
}
|
||||
|
||||
getPostReqTurn(context: VolunteerRecruitResolveContext<TriggerState>): number {
|
||||
return this.resolver.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: VolunteerRecruitResolveContext<TriggerState>,
|
||||
args: VolunteerRecruitArgs
|
||||
|
||||
@@ -54,10 +54,19 @@ const resolveNextTerm = (state: number, term: number): number => (state === DIPL
|
||||
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
constructor(
|
||||
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||
private readonly initialNationGenLimit = 10
|
||||
) {
|
||||
this.pipeline = new GeneralActionPipeline(modules);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: DegradeRelationsResolveContext<TriggerState>): 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<TriggerState>): 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<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.command = new CommandResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
|
||||
this.command = new CommandResolver(modules, initialNationGenLimit);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: DegradeRelationsResolveContext<TriggerState>): number {
|
||||
return this.command.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(
|
||||
@@ -174,8 +187,8 @@ export class ActionDefinition<
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.resolver = new ActionResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | 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<TriggerState>): number {
|
||||
return this.resolver.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: DegradeRelationsResolveContext<TriggerState>,
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -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<typeof ARGS_SCHEMA>;
|
||||
|
||||
interface StopWarProposalContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
destNation: Nation;
|
||||
messageValidMinutes: number;
|
||||
messageTime: Date;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '종전 제의';
|
||||
|
||||
// 종전 제의를 처리하는 국가 커맨드.
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionDefinition<TriggerState, StopWarProposalArgs> {
|
||||
> implements GeneralActionDefinition<TriggerState, StopWarProposalArgs, StopWarProposalContext<TriggerState>> {
|
||||
public readonly key = 'che_종전제의';
|
||||
public readonly name = ACTION_NAME;
|
||||
|
||||
@@ -56,14 +64,41 @@ export class ActionDefinition<
|
||||
}
|
||||
|
||||
resolve(
|
||||
_context: GeneralActionResolveContext<TriggerState>,
|
||||
args: StopWarProposalArgs
|
||||
context: StopWarProposalContext<TriggerState>,
|
||||
_args: StopWarProposalArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
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(`<D><b>${destNationName}</b></>${josaRo} 종전 제의 서신을 보냈습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
@@ -75,7 +110,18 @@ export class ActionDefinition<
|
||||
}
|
||||
|
||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
||||
export const actionContextBuilder = defaultActionContextBuilder;
|
||||
export const actionContextBuilder: ActionContextBuilder<StopWarProposalArgs> = (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_종전제의',
|
||||
|
||||
@@ -88,6 +88,7 @@ export class ActionDefinition<
|
||||
> implements GeneralActionDefinition<TriggerState, ExpandCityArgs, ExpandCityResolveContext<TriggerState>> {
|
||||
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<TriggerState>): number {
|
||||
const value = context.nation?.meta.capset;
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: ExpandCityResolveContext<TriggerState>,
|
||||
_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(`<G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}),
|
||||
// General Action Log
|
||||
createLogEffect(`<G><b>${destCityName}</b></>${josaUl} ${ACTION_NAME}했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
|
||||
@@ -36,35 +36,43 @@ export interface MoveCapitalResolveContext<
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
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>
|
||||
): number | null => {
|
||||
if (allowedCityIds && !allowedCityIds.has(toCityId)) return null;
|
||||
if (fromCityId === toCityId) return 0;
|
||||
|
||||
const connections = new Map<number, number[]>();
|
||||
@@ -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<TriggerState, MoveCapitalArgs, MoveCapitalResolveContext<TriggerState>> {
|
||||
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<TriggerState>, 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<TriggerState>): number {
|
||||
const value = context.nation?.meta.capset;
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: MoveCapitalResolveContext<TriggerState>,
|
||||
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(`<G><b>${destCityName}</b></>${josaRo} <M>${ACTION_NAME}</>명령`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}),
|
||||
// General Action Log
|
||||
createLogEffect(`<G><b>${destCityName}</b></>${josaRo} ${ACTION_NAME}했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
@@ -226,6 +278,7 @@ export const actionContextBuilder: ActionContextBuilder<MoveCapitalArgs> = (base
|
||||
...base,
|
||||
destCity,
|
||||
map,
|
||||
nationCities: worldRef.listCities().filter((city) => city.nationId === base.general.nationId),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export class ActionDefinition<
|
||||
> implements GeneralActionDefinition<TriggerState, ScorchedEarthArgs, ScorchedEarthResolveContext<TriggerState>> {
|
||||
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<TriggerState>,
|
||||
_args: ScorchedEarthArgs
|
||||
|
||||
@@ -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<General<TriggerState>>;
|
||||
destNationGenerals: Array<General<TriggerState>>;
|
||||
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<TriggerState, CounterStrategyArgs> {
|
||||
readonly key = 'che_피장파장';
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(
|
||||
modules: Array<GeneralActionModule<TriggerState> | null | undefined> = [],
|
||||
private readonly initialNationGenLimit = 10
|
||||
) {
|
||||
this.pipeline = new GeneralActionPipeline(modules);
|
||||
}
|
||||
|
||||
getTargetPostReqTurn(context: CounterStrategyResolveContext<TriggerState>): 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<TriggerState>,
|
||||
@@ -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(
|
||||
`<D><b>${destNationName}</b></>에 <G><b>${targetCommandName}</b></> <M>${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(
|
||||
`<D><b>${nationName}</b></>의 <Y>${generalName}</>${generalJosa} 아국에 <G><b>${targetCommandName}</b></> <M>${ACTION_NAME}</>${actionJosa} 발동`,
|
||||
@@ -206,7 +269,11 @@ export class ActionDefinition<
|
||||
> implements GeneralActionDefinition<TriggerState, CounterStrategyArgs, CounterStrategyResolveContext<TriggerState>> {
|
||||
public readonly key = 'che_피장파장';
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver = new ActionResolver<TriggerState>();
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | 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<TriggerState>, args: CounterStrategyArgs): GeneralActionOutcome<TriggerState> {
|
||||
getPreReqTurn(): number {
|
||||
return PRE_REQ_TURN;
|
||||
}
|
||||
|
||||
getPostReqTurn(): number {
|
||||
return 8;
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: CounterStrategyResolveContext<TriggerState>,
|
||||
args: CounterStrategyArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
return this.resolver.resolve(context, args);
|
||||
}
|
||||
}
|
||||
@@ -256,6 +335,7 @@ export const actionContextBuilder: ActionContextBuilder<CounterStrategyArgs> = (
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -40,10 +40,19 @@ const ATMOS_CAP = 100;
|
||||
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
constructor(
|
||||
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||
private readonly initialNationGenLimit = 10
|
||||
) {
|
||||
this.pipeline = new GeneralActionPipeline(modules);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: DesperateFightResolveContext<TriggerState>): 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<TriggerState>): 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<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.command = new CommandResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
|
||||
this.command = new CommandResolver(modules, initialNationGenLimit);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: DesperateFightResolveContext<TriggerState>): number {
|
||||
return this.command.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(
|
||||
@@ -142,8 +155,8 @@ export class ActionDefinition<
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.resolver = new ActionResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | 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<TriggerState>): number {
|
||||
return this.resolver.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: DesperateFightResolveContext<TriggerState>,
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -65,10 +65,19 @@ const pickMoveCityId = (rng: GeneralActionResolveContext['rng'], destCityId: num
|
||||
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
constructor(
|
||||
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||
private readonly initialNationGenLimit = 10
|
||||
) {
|
||||
this.pipeline = new GeneralActionPipeline(modules);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: DeceptionResolveContext<TriggerState>): 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<TriggerState>): 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<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.command = new CommandResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
|
||||
this.command = new CommandResolver(modules, initialNationGenLimit);
|
||||
}
|
||||
|
||||
getPostReqTurn(context: DeceptionResolveContext<TriggerState>): number {
|
||||
return this.command.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(context: DeceptionResolveContext<TriggerState>, _args: DeceptionArgs): GeneralActionOutcome<TriggerState> {
|
||||
@@ -176,8 +189,8 @@ export class ActionDefinition<
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||
this.resolver = new ActionResolver(modules);
|
||||
constructor(modules: Array<GeneralActionModule<TriggerState> | 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<TriggerState>): number {
|
||||
return this.resolver.getPostReqTurn(context);
|
||||
}
|
||||
|
||||
resolve(context: DeceptionResolveContext<TriggerState>, args: DeceptionArgs): GeneralActionOutcome<TriggerState> {
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -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 <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||
env: TurnCommandEnv
|
||||
) => GeneralActionDefinition<TriggerState, Record<string, never>>;
|
||||
@@ -61,6 +60,7 @@ export const createEventResearchCommand = (config: EventResearchConfig): {
|
||||
> implements GeneralActionDefinition<TriggerState, Record<string, never>> {
|
||||
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<TriggerState>,
|
||||
_args: Record<string, never>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Generated
+43
-27
@@ -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
|
||||
|
||||
@@ -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 <path> 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) => {
|
||||
|
||||
@@ -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 <path> 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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user