From 8a2fc4fec5e778cb58837c3ac609aa5ba49b2a16 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 28 Dec 2025 17:11:05 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B0=98=20=EC=97=AD=ED=95=A0?= =?UTF-8?q?=20=EB=B0=8F=20=EC=8A=AC=EB=A1=AF=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80,=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=ED=8C=8C=EC=8B=B1=20=EB=B0=8F=20=EB=B6=80?= =?UTF-8?q?=ED=8A=B8=EC=8A=A4=ED=8A=B8=EB=9E=A9=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/actions/domestic/che_상업투자.ts | 228 ++++++++++++++++++ packages/logic/src/actions/domestic/index.ts | 35 +++ packages/logic/src/actions/index.ts | 1 + packages/logic/src/domain/entities.ts | 16 ++ packages/logic/src/index.ts | 1 + packages/logic/src/scenario/parseScenario.ts | 10 + packages/logic/src/scenario/types.ts | 5 + packages/logic/src/world/bootstrap.ts | 40 +++ packages/logic/src/world/types.ts | 5 + packages/logic/test/worldBootstrap.test.ts | 2 + 10 files changed, 343 insertions(+) create mode 100644 packages/logic/src/actions/domestic/che_상업투자.ts create mode 100644 packages/logic/src/actions/domestic/index.ts create mode 100644 packages/logic/src/actions/index.ts diff --git a/packages/logic/src/actions/domestic/che_상업투자.ts b/packages/logic/src/actions/domestic/che_상업투자.ts new file mode 100644 index 0000000..57b8f1e --- /dev/null +++ b/packages/logic/src/actions/domestic/che_상업투자.ts @@ -0,0 +1,228 @@ +import type { RandomGenerator } from '@sammo-ts/common'; +import type { + City, + General, + GeneralTriggerState, + Nation, +} from '../../domain/entities.js'; +import type { GeneralActionContext } from '../../triggers/general.js'; +import { + GeneralActionPipeline, + type GeneralActionModule, +} from '../../triggers/general-action.js'; + +export type DomesticCriticalPick = 'fail' | 'normal' | 'success'; + +export interface DomesticActionContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionContext { + general: General; + city: City; + nation?: Nation | null; +} + +export interface InvestmentEnvironment { + develCost: number; + defaultTrust?: number; + frontDebuff?: number; + frontStatesWithDebuff?: number[]; + getDomesticExpLevelBonus?: (expLevel: number) => number; + getCriticalRatio?: ( + context: DomesticActionContext, + statKey: string + ) => { success: number; fail: number }; + getCriticalScoreMultiplier?: ( + rng: RandomGenerator, + pick: DomesticCriticalPick + ) => number; + adjustFrontDebuff?: (context: DomesticActionContext, debuff: number) => number; +} + +export interface CommerceInvestmentResult { + pick: DomesticCriticalPick; + score: number; + exp: number; + dedication: number; + costGold: number; + costRice: number; + appliedFrontDebuff: boolean; +} + +const DEFAULT_TRUST = 50; +const DEFAULT_FRONT_DEBUFF = 0.5; +const DEFAULT_FRONT_STATES = [1, 3]; + +const getMetaNumber = ( + meta: Record, + key: string +): number | null => { + const raw = meta[key]; + return typeof raw === 'number' ? raw : null; +}; + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +const randomRange = (rng: RandomGenerator, min: number, max: number): number => + min + (max - min) * rng.nextFloat(); + +const pickByWeight = ( + rng: RandomGenerator, + weights: Record +): DomesticCriticalPick => { + const total = + weights.fail + weights.normal + weights.success; + if (total <= 0) { + return 'normal'; + } + let cursor = rng.nextFloat() * total; + for (const key of ['fail', 'normal', 'success'] as const) { + cursor -= weights[key]; + if (cursor <= 0) { + return key; + } + } + return 'normal'; +}; + +// 상업 투자 결과치를 계산하는 경로를 제공한다. +export class CommandResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private readonly pipeline: GeneralActionPipeline; + private readonly env: InvestmentEnvironment; + private readonly actionKey = '상업'; + private readonly statKey = 'intelligence'; + + constructor( + modules: Array | null | undefined>, + env: InvestmentEnvironment + ) { + this.pipeline = new GeneralActionPipeline(modules); + this.env = env; + } + + getCost(context: DomesticActionContext): { + gold: number; + rice: number; + } { + const baseGold = this.env.develCost; + const gold = Math.round( + this.pipeline.onCalcDomestic( + context, + this.actionKey, + 'cost', + baseGold + ) + ); + return { gold, rice: 0 }; + } + + calcBaseScore( + context: DomesticActionContext, + rng: RandomGenerator + ): number { + const trust = + getMetaNumber(context.city.meta, 'trust') ?? + this.env.defaultTrust ?? + DEFAULT_TRUST; + + let score = this.pipeline.onCalcStat( + context, + this.statKey, + context.general.stats.intelligence + ); + + const expLevel = + getMetaNumber(context.general.meta, 'explevel') ?? + getMetaNumber(context.general.meta, 'expLevel') ?? + 0; + const expBonus = + this.env.getDomesticExpLevelBonus?.(expLevel) ?? 1; + + score *= trust / 100; + score *= expBonus; + score *= randomRange(rng, 0.8, 1.2); + + return this.pipeline.onCalcDomestic( + context, + this.actionKey, + 'score', + score + ); + } + + resolve( + context: DomesticActionContext, + rng: RandomGenerator + ): CommerceInvestmentResult { + const { gold: costGold, rice: costRice } = this.getCost(context); + const trust = + getMetaNumber(context.city.meta, 'trust') ?? + this.env.defaultTrust ?? + DEFAULT_TRUST; + let score = clamp(this.calcBaseScore(context, rng), 1, Number.MAX_SAFE_INTEGER); + + const ratio = + this.env.getCriticalRatio?.(context, this.statKey) ?? { + success: 0, + fail: 0, + }; + let successRatio = ratio.success; + let failRatio = ratio.fail; + if (trust < 80) { + successRatio *= trust / 80; + } + successRatio = this.pipeline.onCalcDomestic( + context, + this.actionKey, + 'success', + successRatio + ); + failRatio = this.pipeline.onCalcDomestic( + context, + this.actionKey, + 'fail', + failRatio + ); + + successRatio = clamp(successRatio, 0, 1); + failRatio = clamp(failRatio, 0, 1 - successRatio); + const normalRatio = 1 - successRatio - failRatio; + + const pick = pickByWeight(rng, { + fail: failRatio, + success: successRatio, + normal: normalRatio, + }); + + const criticalMultiplier = + this.env.getCriticalScoreMultiplier?.(rng, pick) ?? 1; + score = Math.round(score * criticalMultiplier); + + const frontStates = + this.env.frontStatesWithDebuff ?? DEFAULT_FRONT_STATES; + let appliedFrontDebuff = false; + if (frontStates.includes(context.city.frontState)) { + const baseDebuff = + this.env.frontDebuff ?? DEFAULT_FRONT_DEBUFF; + const adjustedDebuff = + this.env.adjustFrontDebuff?.(context, baseDebuff) ?? baseDebuff; + score *= adjustedDebuff; + appliedFrontDebuff = true; + } + + const exp = score * 0.7; + const dedication = score * 1.0; + + return { + pick, + score, + exp, + dedication, + costGold, + costRice, + appliedFrontDebuff, + }; + } +} diff --git a/packages/logic/src/actions/domestic/index.ts b/packages/logic/src/actions/domestic/index.ts new file mode 100644 index 0000000..c0deb84 --- /dev/null +++ b/packages/logic/src/actions/domestic/index.ts @@ -0,0 +1,35 @@ +import type { CommandResolver as CommandResolverType } from './che_상업투자.js'; + +export type DomesticCommandKey = 'che_상업투자'; + +export type DomesticCommandImporter = () => Promise<{ + CommandResolver: typeof CommandResolverType; +}>; + +const defaultImporters: Record = { + che_상업투자: async () => import('./che_상업투자.js'), +}; + +export class DomesticCommandLoader { + constructor( + private readonly importers: Record< + DomesticCommandKey, + DomesticCommandImporter + > = defaultImporters + ) {} + + async load( + key: DomesticCommandKey + ): Promise<{ CommandResolver: typeof CommandResolverType }> { + const importer = this.importers[key]; + if (!importer) { + throw new Error(`Unknown domestic command key: ${key}`); + } + return importer(); + } + + async create(key: DomesticCommandKey, ...args: unknown[]): Promise { + const { CommandResolver } = await this.load(key); + return new CommandResolver(...args); + } +} diff --git a/packages/logic/src/actions/index.ts b/packages/logic/src/actions/index.ts new file mode 100644 index 0000000..13cea07 --- /dev/null +++ b/packages/logic/src/actions/index.ts @@ -0,0 +1 @@ +export * from './domestic/index.js'; diff --git a/packages/logic/src/domain/entities.ts b/packages/logic/src/domain/entities.ts index e4a2e26..63b6dee 100644 --- a/packages/logic/src/domain/entities.ts +++ b/packages/logic/src/domain/entities.ts @@ -22,6 +22,21 @@ export interface GeneralTriggerState { meta: Record; } +export interface GeneralItemSlots { + horse: string | null; + weapon: string | null; + book: string | null; + item: string | null; +} + +export interface GeneralRole { + // General.php의 raw 컬럼을 그대로 유지하는 역할 데이터. + personality: string | null; + specialDomestic: string | null; + specialWar: string | null; + items: GeneralItemSlots; +} + export interface General { id: GeneralId; name: string; @@ -32,6 +47,7 @@ export interface General { expect(result.seed.cities[0]?.nationId).toBe(1); expect(result.snapshot.generals[0]?.cityId).toBe(1); expect(result.snapshot.generals[0]?.crewTypeId).toBe(1200); + expect(result.snapshot.generals[0]?.role.specialDomestic).toBe('Special'); + expect(result.snapshot.generals[0]?.role.specialWar).toBeNull(); expect(result.seed.generals[0]?.npcType).toBe(2); expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario'); });