Merge feature/nation-turn-audit

This commit is contained in:
2026-07-25 06:18:23 +00:00
36 changed files with 1346 additions and 283 deletions
@@ -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;
}
+36 -1
View File
@@ -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);
}
+10 -1
View File
@@ -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),
},
});
}
},
});
+143 -12
View File
@@ -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:
+5
View File
@@ -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
View File
@@ -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"
}
+5
View File
@@ -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>;
}
+13
View File
@@ -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>
+3 -8
View File
@@ -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', () => {
+43 -27
View File
@@ -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
+32 -23
View File
@@ -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) => {
+75 -74
View File
@@ -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;
}
};