feat: 일반 역할 및 슬롯 인터페이스 추가, 시나리오 파싱 및 부트스트랩 로직 개선

This commit is contained in:
2025-12-28 17:11:05 +00:00
parent 62c1c0f9c6
commit 8a2fc4fec5
10 changed files with 343 additions and 0 deletions
@@ -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<TriggerState> {
general: General<TriggerState>;
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<string, unknown>,
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, number>
): 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<TriggerState>;
private readonly env: InvestmentEnvironment;
private readonly actionKey = '상업';
private readonly statKey = 'intelligence';
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
env: InvestmentEnvironment
) {
this.pipeline = new GeneralActionPipeline(modules);
this.env = env;
}
getCost(context: DomesticActionContext<TriggerState>): {
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<TriggerState>,
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<TriggerState>,
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,
};
}
}
@@ -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<DomesticCommandKey, DomesticCommandImporter> = {
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<unknown> {
const { CommandResolver } = await this.load(key);
return new CommandResolver(...args);
}
}
+1
View File
@@ -0,0 +1 @@
export * from './domestic/index.js';
+16
View File
@@ -22,6 +22,21 @@ export interface GeneralTriggerState {
meta: Record<string, TriggerValue>;
}
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<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
id: GeneralId;
name: string;
@@ -32,6 +47,7 @@ export interface General<TriggerState extends GeneralTriggerState = GeneralTrigg
experience: number;
dedication: number;
officerLevel: number;
role: GeneralRole;
injury: number;
gold: number;
rice: number;
+1
View File
@@ -1,5 +1,6 @@
export * from './domain/entities.js';
export type { RandomGenerator } from '@sammo-ts/common';
export * from './actions/index.js';
export * from './ports/world.js';
export * from './ports/worldSnapshot.js';
export * from './scenario/index.js';
@@ -210,6 +210,11 @@ const parseGeneralRow = (
personality,
special,
text,
specialWar,
horse,
weapon,
book,
item,
] = values;
if (typeof name !== 'string') {
@@ -236,6 +241,11 @@ const parseGeneralRow = (
deathYear: asNumber(deathYear, 0),
personality: asNullableString(personality),
special: asNullableString(special),
specialWar: asNullableString(specialWar),
horse: asNullableString(horse),
weapon: asNullableString(weapon),
book: asNullableString(book),
item: asNullableString(item),
text: asNullableString(text),
};
};
+5
View File
@@ -61,6 +61,11 @@ export interface ScenarioGeneral {
deathYear: number;
personality: string | null;
special: string | null;
specialWar?: string | null;
horse?: string | null;
weapon?: string | null;
book?: string | null;
item?: string | null;
text: string | null;
}
+40
View File
@@ -294,6 +294,21 @@ const buildGeneralSeeds = (
if (row.picture !== null) {
seedMeta.picture = row.picture;
}
if (row.specialWar !== null && row.specialWar !== undefined) {
seedMeta.specialWar = row.specialWar;
}
if (row.horse !== null && row.horse !== undefined) {
seedMeta.horse = row.horse;
}
if (row.weapon !== null && row.weapon !== undefined) {
seedMeta.weapon = row.weapon;
}
if (row.book !== null && row.book !== undefined) {
seedMeta.book = row.book;
}
if (row.item !== null && row.item !== undefined) {
seedMeta.item = row.item;
}
if (row.text !== null) {
seedMeta.text = row.text;
}
@@ -310,6 +325,11 @@ const buildGeneralSeeds = (
affinity: row.affinity,
personality: row.personality,
special: row.special,
specialWar: row.specialWar ?? null,
horse: row.horse ?? null,
weapon: row.weapon ?? null,
book: row.book ?? null,
item: row.item ?? null,
picture: row.picture,
npcType,
text: row.text,
@@ -326,6 +346,15 @@ const buildGeneralSeeds = (
addTriggerMeta(generalMeta, 'personality', row.personality ?? undefined);
addTriggerMeta(generalMeta, 'special', row.special ?? undefined);
addTriggerMeta(generalMeta, 'picture', row.picture ?? undefined);
addTriggerMeta(
generalMeta,
'specialWar',
row.specialWar ?? undefined
);
addTriggerMeta(generalMeta, 'horse', row.horse ?? undefined);
addTriggerMeta(generalMeta, 'weapon', row.weapon ?? undefined);
addTriggerMeta(generalMeta, 'book', row.book ?? undefined);
addTriggerMeta(generalMeta, 'item', row.item ?? undefined);
addTriggerMeta(generalMeta, 'birthYear', birthYear);
addTriggerMeta(generalMeta, 'deathYear', deathYear);
addTriggerMeta(generalMeta, 'text', row.text ?? undefined);
@@ -340,6 +369,17 @@ const buildGeneralSeeds = (
experience: 0,
dedication: 0,
officerLevel,
role: {
personality: row.personality,
specialDomestic: row.special,
specialWar: row.specialWar ?? null,
items: {
horse: row.horse ?? null,
weapon: row.weapon ?? null,
book: row.book ?? null,
item: row.item ?? null,
},
},
injury: 0,
gold: defaultGold,
rice: defaultRice,
+5
View File
@@ -116,6 +116,11 @@ export interface GeneralSeed {
affinity: number | null;
personality: string | null;
special: string | null;
specialWar: string | null;
horse: string | null;
weapon: string | null;
book: string | null;
item: string | null;
picture: number | string | null;
npcType: number;
text: string | null;
@@ -117,6 +117,8 @@ describe('scenario bootstrap', () => {
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');
});