feat: 커맨드 로그 체크리스트 업데이트 및 로그 메시지 개선

This commit is contained in:
2026-02-06 16:50:19 +00:00
parent 39935404bc
commit dc752294fb
6 changed files with 295 additions and 52 deletions
+41 -11
View File
@@ -1,3 +1,43 @@
Compare command logs (mode: action, strict: off, keepDate: off, excludeGuards: on, excludeTarget: on)
PHP commands: 83
TS commands: 63
Matched commands: 60
Mismatched commands: 0
Missing in TS: 23
Missing in PHP: 3
Ignored mismatches: 10
Missing in TS:
- General/che_군량매매
- General/che_등용수락
- General/che_모반시도
- General/che_무작위건국
- General/che_선양
- General/che_장비매매
- General/che_장수대상임관
- General/che_전투태세
- General/che_접경귀환
- General/che_증여
- General/che_해산
- General/cr_건국
- General/cr_맹훈련
- Nation/che_선전포고
- Nation/event_극병연구
- Nation/event_대검병연구
- Nation/event_무희연구
- Nation/event_산저병연구
- Nation/event_상병연구
- Nation/event_원융노병연구
- Nation/event_음귀병연구
- Nation/event_화륜차연구
- Nation/event_화시병연구
Missing in PHP:
- General/che_내정특기초기화
- Nation/che_불가침수락
- Nation/che_불가침파기수락
Checklist:
# Command Log Checklist
Mode: action
@@ -7,14 +47,4 @@ Exclude guards: on
Exclude target: on
Ignore file: tools/compare-command-logs.ignore.json
- [ ] General/che_건국
PHP only: <D>${}</>${} 건국하였습니다.
TS only: <D>${}</>을 건국하였습니다.
- [ ] General/che_정착장려
PHP only: ${}${} <span class='ev_failed'>실패</span>하여 주민이 <C>${}</>명 증가했습니다. | ${}${} <S>성공</>하여 주민이 <C>${}</>명 증가했습니다.
- [ ] General/che_징병
PHP only: ${} <C>${}</>명을 추가${}했습니다. | ${} <C>${}</>명을 ${}했습니다.
TS only: ${} 추가징병했습니다. | ${} 징병했습니다.
- [ ] General/che_첩보
PHP only: <G>${}</>의 정보를 많이 얻었습니다. | 【<G>${}</>】주민:${}, 민심:${}, 장수:${}, 병력:${} | 【<M>첩보</>】농업:${}, 상업:${}, 치안:${}, 수비:${}, 성벽:${} | 【<S>병종</>】 ${} | 【<span class='ev_notice'>${}</span>】아국대비기술:${} | <G>${}</>의 정보를 어느 정도 얻었습니다. | <G>${}</>의 소문만 들을 수 있었습니다.
TS only: <G>${}</>의 정보를 ${} 얻었습니다. | 주민:${}, 민심:${}, ...
- [x] All command logs match.
@@ -16,13 +16,14 @@ import {
createGeneralPatchEffect,
createNationPatchEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { z } from 'zod';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { JosaUtil } from '@sammo-ts/common';
const ACTION_NAME = '건국';
const ARGS_SCHEMA = z.object({
@@ -107,31 +108,33 @@ export class ActionDefinition<
}
const color = NATION_COLORS[args.colorType];
const josaUl = '을'; // Mock JosaUtil.pick
const josaYi = '이';
const josaNationUl = JosaUtil.pick(args.nationName, '을');
const josaNationYi = JosaUtil.pick(args.nationName, '이');
const josaGeneralYi = JosaUtil.pick(general.name, '이');
const city = context.city;
context.addLog(`${args.nationName}${josaUl} 건국하였습니다.`, {
context.addLog(`${args.nationName}${josaNationUl} 건국하였습니다.`, {
category: LogCategory.USER,
format: LogFormat.PLAIN,
});
context.addLog(`<D><b>${args.nationName}</b></>${josaUl} 건국하였습니다.`, {
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국하였습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
context.addLog(`${general.name}${josaYi} ${city?.name}에 국가를 건설하였습니다.`, {
context.addLog(`${general.name}${josaGeneralYi} ${city?.name}에 국가를 건설하였습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
scope: LogScope.SYSTEM,
format: LogFormat.PLAIN,
});
context.addLog(`【건국】${args.nationType} ${args.nationName}${josaYi} 새로이 등장하였습니다.`, {
context.addLog(`【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, {
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
});
context.addLog(`${args.nationName}${josaUl} 건국`, {
context.addLog(`${args.nationName}${josaNationUl} 건국`, {
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
});
context.addLog(`${general.name}${josaYi} ${args.nationName}${josaUl} 건국`, {
context.addLog(`${general.name}${josaGeneralYi} ${args.nationName}${josaNationUl} 건국`, {
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
});
@@ -18,6 +18,39 @@ import type { GeneralTurnCommandSpec } from './index.js';
import { JosaUtil } from '@sammo-ts/common';
export interface SettlementArgs {}
type SettlementPick = 'success' | 'normal' | 'fail';
const pickByWeight = <T extends string>(rng: GeneralActionResolveContext['rng'], weights: Record<T, number>): T => {
const entries = Object.entries(weights) as Array<[T, number]>;
const first = entries[0];
if (!first) {
throw new Error('Empty weights');
}
let total = 0;
for (const [, weight] of entries) {
if (weight > 0) {
total += weight;
}
}
if (total <= 0) {
return first[0];
}
let cursor = rng.nextFloat1() * total;
for (const [key, weight] of entries) {
if (weight <= 0) {
continue;
}
cursor -= weight;
if (cursor <= 0) {
return key;
}
}
const last = entries[entries.length - 1];
return last ? last[0] : first[0];
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -59,7 +92,14 @@ export class ActionDefinition<
return { effects: [] };
}
const baseAmount = 1000;
const pick = pickByWeight<SettlementPick>(context.rng, {
fail: 0.2,
success: 0.2,
normal: 0.6,
});
const scoreCoef = pick === 'success' ? 1.3 : pick === 'fail' ? 0.7 : 1;
const baseAmount = Math.round(1000 * scoreCoef);
const current = city.population;
const max = city.populationMax;
@@ -72,7 +112,15 @@ export class ActionDefinition<
const scoreText = (nextValue - current).toLocaleString();
const logName = this.name.replace(' ', '');
const josaUl = JosaUtil.pick(logName, '을');
context.addLog(`${logName}${josaUl} 하여 주민이 <C>${scoreText}</>명 증가했습니다.`);
if (pick === 'fail') {
context.addLog(
`${logName}${josaUl} <span class='ev_failed'>실패</span>하여 주민이 <C>${scoreText}</>명 증가했습니다.`
);
} else if (pick === 'success') {
context.addLog(`${logName}${josaUl} <S>성공</>하여 주민이 <C>${scoreText}</>명 증가했습니다.`);
} else {
context.addLog(`${logName}${josaUl} 하여 주민이 <C>${scoreText}</>명 증가했습니다.`);
}
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: '정착 장려' });
return { effects: [] };
@@ -37,6 +37,7 @@ export interface RecruitEnvironment {
defaultAtmos?: number;
minAvailableRecruitPop?: number;
defaultTrust?: number;
actionName?: string;
}
export interface RecruitResolveContext<
@@ -350,7 +351,7 @@ export class ActionResolver<
let nextCrew = general.crew;
let nextTrain = general.train;
let nextAtmos = general.atmos;
const crewLabel = `${crewType.name} <C>${appliedCrew.toLocaleString()}</>명`;
const actionName = this.env.actionName ?? ACTION_NAME;
if (crewType.id === general.crewTypeId && general.crew > 0) {
nextCrew = general.crew + appliedCrew;
nextTrain = Math.round(
@@ -359,13 +360,13 @@ export class ActionResolver<
nextAtmos = Math.round(
(general.crew * general.atmos + appliedCrew * setAtmos) / (general.crew + appliedCrew)
);
context.addLog(`${crewLabel} 추가${ACTION_NAME}했습니다.`);
context.addLog(`${crewType.name} <C>${appliedCrew.toLocaleString()}</>명을 추가${actionName}했습니다.`);
} else {
nextCrewTypeId = crewType.id;
nextCrew = appliedCrew;
nextTrain = Math.round(setTrain);
nextAtmos = Math.round(setAtmos);
context.addLog(`${crewLabel} ${ACTION_NAME}했습니다.`);
context.addLog(`${crewType.name} <C>${appliedCrew.toLocaleString()}</>명을 ${actionName}했습니다.`);
}
const nextGold = Math.max(0, general.gold - plan.gold);
@@ -1,4 +1,4 @@
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
notOccupiedDestCity,
@@ -15,18 +15,21 @@ import type {
GeneralActionEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
import { z } from 'zod';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { GeneralTurnCommandSpec } from './index.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface SpyResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destCity?: City;
destNation?: Nation | null;
allGenerals?: General[];
unitSet?: UnitSetDefinition | null;
env?: TurnCommandEnv;
map?: MapDefinition;
}
@@ -38,6 +41,88 @@ const ARGS_SCHEMA = z.object({
});
export type SpyArgs = z.infer<typeof ARGS_SCHEMA>;
const getCityDistance = (map: MapDefinition | undefined, sourceCityId: number, targetCityId: number): number => {
if (!map) {
return 3;
}
if (sourceCityId === targetCityId) {
return 0;
}
const cityById = new Map(map.cities.map((city) => [city.id, city]));
if (!cityById.has(sourceCityId) || !cityById.has(targetCityId)) {
return 3;
}
const queue: Array<{ cityId: number; distance: number }> = [{ cityId: sourceCityId, distance: 0 }];
const visited = new Set<number>([sourceCityId]);
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
break;
}
if (current.distance >= 2) {
continue;
}
const city = cityById.get(current.cityId);
if (!city) {
continue;
}
for (const neighbor of city.connections) {
if (neighbor === targetCityId) {
return current.distance + 1;
}
if (visited.has(neighbor)) {
continue;
}
visited.add(neighbor);
queue.push({ cityId: neighbor, distance: current.distance + 1 });
}
}
return 3;
};
const readNationTech = (nation: Nation | null | undefined): number => {
if (!nation) {
return 0;
}
const tech = nation.meta.tech;
return typeof tech === 'number' ? Math.floor(tech) : 0;
};
const readSpyMeta = (raw: unknown): Record<string, number> => {
let source: unknown = raw;
if (typeof source === 'string') {
try {
source = JSON.parse(source);
} catch (_error) {
source = null;
}
}
if (!source || typeof source !== 'object' || Array.isArray(source)) {
return {};
}
const result: Record<string, number> = {};
for (const [key, value] of Object.entries(source)) {
if (typeof value === 'number' && Number.isFinite(value)) {
result[key] = value;
}
}
return result;
};
const summarizeCrewType = (
crewTypeId: number,
count: number,
unitSet: UnitSetDefinition | null | undefined
): string => {
const crewTypeName = unitSet?.crewTypes?.find((entry) => entry.id === crewTypeId)?.name ?? String(crewTypeId);
return `${crewTypeName.slice(0, 2)}:${count}`;
};
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, SpyArgs> {
@@ -57,37 +142,108 @@ export class ActionResolver<
const effects: GeneralActionEffect<TriggerState>[] = [];
const destCityName = destCity.name;
const currentCityId = general.cityId;
let isAdjacent = false;
if (ctx.map) {
const cityDef = ctx.map.cities.find((c) => c.id === currentCityId);
if (cityDef && (cityDef.connections.includes(destCityId) || currentCityId === destCityId)) {
isAdjacent = true;
const distance = getCityDistance(ctx.map, currentCityId, destCityId);
const cityGenerals = (ctx.allGenerals ?? []).filter(
(candidate) => candidate.cityId === destCityId && candidate.nationId === destCity.nationId
);
const totalCrew = cityGenerals.reduce((sum, candidate) => sum + candidate.crew, 0);
const byCrewType = cityGenerals.reduce<Map<number, number>>((acc, candidate) => {
const current = acc.get(candidate.crewTypeId) ?? 0;
acc.set(candidate.crewTypeId, current + 1);
return acc;
}, new Map<number, number>());
const trust = destCity.meta.trust;
const trustText = typeof trust === 'number' ? trust.toFixed(1) : '?';
if (distance <= 1) {
ctx.addLog(`<G><b>${destCityName}</b></>의 정보를 많이 얻었습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
ctx.addLog(
`【<G>${destCityName}</>】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`,
{
category: LogCategory.ACTION,
format: LogFormat.RAWTEXT,
}
);
ctx.addLog(
`【<M>첩보</>】농업:${destCity.agriculture.toLocaleString()}, 상업:${destCity.commerce.toLocaleString()}, 치안:${destCity.security.toLocaleString()}, 수비:${destCity.defence.toLocaleString()}, 성벽:${destCity.wall.toLocaleString()}`,
{
category: LogCategory.ACTION,
format: LogFormat.RAWTEXT,
}
);
const crewTypeSummary = Array.from(byCrewType.entries())
.map(([crewTypeId, count]) => summarizeCrewType(crewTypeId, count, ctx.unitSet))
.join(' ');
ctx.addLog(`【<S>병종</>】 ${crewTypeSummary}`, {
category: LogCategory.ACTION,
format: LogFormat.RAWTEXT,
});
const destNation = ctx.destNation;
if (destNation && destNation.id !== 0 && nation && nation.id !== 0) {
const techDiff = readNationTech(destNation) - readNationTech(nation);
const techText =
techDiff >= 1000
? '<M>↑</>압도'
: techDiff >= 250
? '<Y>▲</>우위'
: techDiff >= -250
? '<W>↕</>대등'
: techDiff >= -1000
? '<G>▼</>열위'
: '<C>↓</>미미';
ctx.addLog(`【<span class='ev_notice'>${destNation.name}</span>】아국대비기술:${techText}`, {
category: LogCategory.ACTION,
format: LogFormat.RAWTEXT,
});
}
} else if (distance === 2) {
ctx.addLog(`<G><b>${destCityName}</b></>의 정보를 어느 정도 얻었습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
ctx.addLog(
`【<G>${destCityName}</>】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`,
{
category: LogCategory.ACTION,
format: LogFormat.RAWTEXT,
}
);
ctx.addLog(
`【<M>첩보</>】농업:${destCity.agriculture.toLocaleString()}, 상업:${destCity.commerce.toLocaleString()}, 치안:${destCity.security.toLocaleString()}, 수비:${destCity.defence.toLocaleString()}, 성벽:${destCity.wall.toLocaleString()}`,
{
category: LogCategory.ACTION,
format: LogFormat.RAWTEXT,
}
);
} else {
ctx.addLog(`<G><b>${destCityName}</b></>의 소문만 들을 수 있었습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
ctx.addLog(
`【<G>${destCityName}</>】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`,
{
category: LogCategory.ACTION,
format: LogFormat.RAWTEXT,
}
);
}
const destCityName = destCity.name;
ctx.addLog(`<G>${destCityName}</>의 정보를 ${isAdjacent ? '많이' : '어느 정도'} 얻었습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
const trust = destCity.meta.trust ?? '?';
ctx.addLog(`주민:${destCity.population}, 민심:${trust}, ...`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
scope: LogScope.GENERAL,
});
if (nation) {
const spyInfo = (nation.meta.spy as unknown as Record<string, number>) || {};
const spyInfo = readSpyMeta(nation.meta.spy);
const newSpyInfo = { ...spyInfo, [destCityId]: 3 };
effects.push(
createNationPatchEffect(
{
...nation,
meta: { ...nation.meta, spy: newSpyInfo as unknown as string }, // Workaround for TriggerValue mismatch, engine handles nested objects in meta sometimes or we should JSON stringify.
meta: { ...nation.meta, spy: newSpyInfo },
},
nation.id
)
@@ -157,12 +313,19 @@ export class ActionDefinition<
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
const destCityId = options.actionArgs?.destCityId;
let destCity = null;
let destNation = null;
if (typeof destCityId === 'number' && options.worldRef) {
destCity = options.worldRef.getCityById(destCityId);
if (destCity && destCity.nationId) {
destNation = options.worldRef.getNationById(destCity.nationId);
}
}
return {
...base,
destCity,
destNation,
allGenerals: options.worldRef?.listGenerals() ?? [],
unitSet: options.unitSet ?? null,
map: options.map,
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
};
-2
View File
@@ -10,8 +10,6 @@
"경로에 도달할 방법이 없습니다.",
"${}${} <span class='ev_failed'>실패</span>하여 <C>${}</> 상승했습니다.",
"${}${} <S>성공</>하여 <C>${}</> 상승했습니다.",
"${}이 ${}에 국가를 건설하였습니다.",
"민심이 ${} 상승했습니다.",
"다음 턴부터 건국할 수 있습니다."
],
"regex": []