From dc752294fb49eeb63009b1cc8ff22e4ead8a3f5e Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 6 Feb 2026 16:50:19 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/command-log-checklist.md | 52 ++++- .../src/actions/turn/general/che_건국.ts | 23 +- .../src/actions/turn/general/che_정착장려.ts | 52 ++++- .../src/actions/turn/general/che_징병.ts | 7 +- .../src/actions/turn/general/che_첩보.ts | 211 ++++++++++++++++-- tools/compare-command-logs.ignore.json | 2 - 6 files changed, 295 insertions(+), 52 deletions(-) diff --git a/docs/command-log-checklist.md b/docs/command-log-checklist.md index 136be0f..f3e85e8 100644 --- a/docs/command-log-checklist.md +++ b/docs/command-log-checklist.md @@ -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: ${}${} 건국하였습니다. -TS only: ${}을 건국하였습니다. -- [ ] General/che_정착장려 -PHP only: ${}${} 실패하여 주민이 ${}명 증가했습니다. | ${}${} 성공하여 주민이 ${}명 증가했습니다. -- [ ] General/che_징병 -PHP only: ${} ${}명을 추가${}했습니다. | ${} ${}명을 ${}했습니다. -TS only: ${} 추가징병했습니다. | ${} 징병했습니다. -- [ ] General/che_첩보 -PHP only: ${}의 정보를 많이 얻었습니다. | 【${}】주민:${}, 민심:${}, 장수:${}, 병력:${} | 【첩보】농업:${}, 상업:${}, 치안:${}, 수비:${}, 성벽:${} | 【병종】 ${} | 【${}】아국대비기술:${} | ${}의 정보를 어느 정도 얻었습니다. | ${}의 소문만 들을 수 있었습니다. -TS only: ${}의 정보를 ${} 얻었습니다. | 주민:${}, 민심:${}, ... +- [x] All command logs match. diff --git a/packages/logic/src/actions/turn/general/che_건국.ts b/packages/logic/src/actions/turn/general/che_건국.ts index cb25b99..87ee6e2 100644 --- a/packages/logic/src/actions/turn/general/che_건국.ts +++ b/packages/logic/src/actions/turn/general/che_건국.ts @@ -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(`${args.nationName}${josaUl} 건국하였습니다.`, { + context.addLog(`${args.nationName}${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, }); diff --git a/packages/logic/src/actions/turn/general/che_정착장려.ts b/packages/logic/src/actions/turn/general/che_정착장려.ts index 6ab6cfa..39059e1 100644 --- a/packages/logic/src/actions/turn/general/che_정착장려.ts +++ b/packages/logic/src/actions/turn/general/che_정착장려.ts @@ -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 = (rng: GeneralActionResolveContext['rng'], weights: Record): 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(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} 하여 주민이 ${scoreText}명 증가했습니다.`); + if (pick === 'fail') { + context.addLog( + `${logName}${josaUl} 실패하여 주민이 ${scoreText}명 증가했습니다.` + ); + } else if (pick === 'success') { + context.addLog(`${logName}${josaUl} 성공하여 주민이 ${scoreText}명 증가했습니다.`); + } else { + context.addLog(`${logName}${josaUl} 하여 주민이 ${scoreText}명 증가했습니다.`); + } tryApplyUniqueLottery(context, { acquireType: '아이템', reason: '정착 장려' }); return { effects: [] }; diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index 6ccabf8..1cc51df 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -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} ${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} ${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} ${appliedCrew.toLocaleString()}명을 ${actionName}했습니다.`); } const nextGold = Math.max(0, general.gold - plan.gold); diff --git a/packages/logic/src/actions/turn/general/che_첩보.ts b/packages/logic/src/actions/turn/general/che_첩보.ts index 832fe17..8444621 100644 --- a/packages/logic/src/actions/turn/general/che_첩보.ts +++ b/packages/logic/src/actions/turn/general/che_첩보.ts @@ -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 { 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; +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([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 => { + 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 = {}; + 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 { @@ -57,37 +142,108 @@ export class ActionResolver< const effects: GeneralActionEffect[] = []; + 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>((acc, candidate) => { + const current = acc.get(candidate.crewTypeId) ?? 0; + acc.set(candidate.crewTypeId, current + 1); + return acc; + }, new Map()); + + const trust = destCity.meta.trust; + const trustText = typeof trust === 'number' ? trust.toFixed(1) : '?'; + if (distance <= 1) { + ctx.addLog(`${destCityName}의 정보를 많이 얻었습니다.`, { + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + ctx.addLog( + `【${destCityName}】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`, + { + category: LogCategory.ACTION, + format: LogFormat.RAWTEXT, + } + ); + ctx.addLog( + `【첩보】농업:${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(`【병종】 ${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 + ? '↑압도' + : techDiff >= 250 + ? '▲우위' + : techDiff >= -250 + ? '↕대등' + : techDiff >= -1000 + ? '▼열위' + : '↓미미'; + ctx.addLog(`【${destNation.name}】아국대비기술:${techText}`, { + category: LogCategory.ACTION, + format: LogFormat.RAWTEXT, + }); } + } else if (distance === 2) { + ctx.addLog(`${destCityName}의 정보를 어느 정도 얻었습니다.`, { + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + ctx.addLog( + `【${destCityName}】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`, + { + category: LogCategory.ACTION, + format: LogFormat.RAWTEXT, + } + ); + ctx.addLog( + `【첩보】농업:${destCity.agriculture.toLocaleString()}, 상업:${destCity.commerce.toLocaleString()}, 치안:${destCity.security.toLocaleString()}, 수비:${destCity.defence.toLocaleString()}, 성벽:${destCity.wall.toLocaleString()}`, + { + category: LogCategory.ACTION, + format: LogFormat.RAWTEXT, + } + ); + } else { + ctx.addLog(`${destCityName}의 소문만 들을 수 있었습니다.`, { + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + ctx.addLog( + `【${destCityName}】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`, + { + category: LogCategory.ACTION, + format: LogFormat.RAWTEXT, + } + ); } - const destCityName = destCity.name; - ctx.addLog(`${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) || {}; + 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, }; diff --git a/tools/compare-command-logs.ignore.json b/tools/compare-command-logs.ignore.json index ec18617..212ce53 100644 --- a/tools/compare-command-logs.ignore.json +++ b/tools/compare-command-logs.ignore.json @@ -10,8 +10,6 @@ "경로에 도달할 방법이 없습니다.", "${}${} 실패하여 ${} 상승했습니다.", "${}${} 성공하여 ${} 상승했습니다.", - "${}이 ${}에 국가를 건설하였습니다.", - "민심이 ${} 상승했습니다.", "다음 턴부터 건국할 수 있습니다." ], "regex": []