feat: 일반 역할 및 슬롯 인터페이스 추가, 시나리오 파싱 및 부트스트랩 로직 개선
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './domestic/index.js';
|
||||
Reference in New Issue
Block a user