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';
|
||||||
@@ -22,6 +22,21 @@ export interface GeneralTriggerState {
|
|||||||
meta: Record<string, TriggerValue>;
|
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> {
|
export interface General<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
id: GeneralId;
|
id: GeneralId;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -32,6 +47,7 @@ export interface General<TriggerState extends GeneralTriggerState = GeneralTrigg
|
|||||||
experience: number;
|
experience: number;
|
||||||
dedication: number;
|
dedication: number;
|
||||||
officerLevel: number;
|
officerLevel: number;
|
||||||
|
role: GeneralRole;
|
||||||
injury: number;
|
injury: number;
|
||||||
gold: number;
|
gold: number;
|
||||||
rice: number;
|
rice: number;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './domain/entities.js';
|
export * from './domain/entities.js';
|
||||||
export type { RandomGenerator } from '@sammo-ts/common';
|
export type { RandomGenerator } from '@sammo-ts/common';
|
||||||
|
export * from './actions/index.js';
|
||||||
export * from './ports/world.js';
|
export * from './ports/world.js';
|
||||||
export * from './ports/worldSnapshot.js';
|
export * from './ports/worldSnapshot.js';
|
||||||
export * from './scenario/index.js';
|
export * from './scenario/index.js';
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ const parseGeneralRow = (
|
|||||||
personality,
|
personality,
|
||||||
special,
|
special,
|
||||||
text,
|
text,
|
||||||
|
specialWar,
|
||||||
|
horse,
|
||||||
|
weapon,
|
||||||
|
book,
|
||||||
|
item,
|
||||||
] = values;
|
] = values;
|
||||||
|
|
||||||
if (typeof name !== 'string') {
|
if (typeof name !== 'string') {
|
||||||
@@ -236,6 +241,11 @@ const parseGeneralRow = (
|
|||||||
deathYear: asNumber(deathYear, 0),
|
deathYear: asNumber(deathYear, 0),
|
||||||
personality: asNullableString(personality),
|
personality: asNullableString(personality),
|
||||||
special: asNullableString(special),
|
special: asNullableString(special),
|
||||||
|
specialWar: asNullableString(specialWar),
|
||||||
|
horse: asNullableString(horse),
|
||||||
|
weapon: asNullableString(weapon),
|
||||||
|
book: asNullableString(book),
|
||||||
|
item: asNullableString(item),
|
||||||
text: asNullableString(text),
|
text: asNullableString(text),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export interface ScenarioGeneral {
|
|||||||
deathYear: number;
|
deathYear: number;
|
||||||
personality: string | null;
|
personality: string | null;
|
||||||
special: string | null;
|
special: string | null;
|
||||||
|
specialWar?: string | null;
|
||||||
|
horse?: string | null;
|
||||||
|
weapon?: string | null;
|
||||||
|
book?: string | null;
|
||||||
|
item?: string | null;
|
||||||
text: string | null;
|
text: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,21 @@ const buildGeneralSeeds = (
|
|||||||
if (row.picture !== null) {
|
if (row.picture !== null) {
|
||||||
seedMeta.picture = row.picture;
|
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) {
|
if (row.text !== null) {
|
||||||
seedMeta.text = row.text;
|
seedMeta.text = row.text;
|
||||||
}
|
}
|
||||||
@@ -310,6 +325,11 @@ const buildGeneralSeeds = (
|
|||||||
affinity: row.affinity,
|
affinity: row.affinity,
|
||||||
personality: row.personality,
|
personality: row.personality,
|
||||||
special: row.special,
|
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,
|
picture: row.picture,
|
||||||
npcType,
|
npcType,
|
||||||
text: row.text,
|
text: row.text,
|
||||||
@@ -326,6 +346,15 @@ const buildGeneralSeeds = (
|
|||||||
addTriggerMeta(generalMeta, 'personality', row.personality ?? undefined);
|
addTriggerMeta(generalMeta, 'personality', row.personality ?? undefined);
|
||||||
addTriggerMeta(generalMeta, 'special', row.special ?? undefined);
|
addTriggerMeta(generalMeta, 'special', row.special ?? undefined);
|
||||||
addTriggerMeta(generalMeta, 'picture', row.picture ?? 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, 'birthYear', birthYear);
|
||||||
addTriggerMeta(generalMeta, 'deathYear', deathYear);
|
addTriggerMeta(generalMeta, 'deathYear', deathYear);
|
||||||
addTriggerMeta(generalMeta, 'text', row.text ?? undefined);
|
addTriggerMeta(generalMeta, 'text', row.text ?? undefined);
|
||||||
@@ -340,6 +369,17 @@ const buildGeneralSeeds = (
|
|||||||
experience: 0,
|
experience: 0,
|
||||||
dedication: 0,
|
dedication: 0,
|
||||||
officerLevel,
|
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,
|
injury: 0,
|
||||||
gold: defaultGold,
|
gold: defaultGold,
|
||||||
rice: defaultRice,
|
rice: defaultRice,
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ export interface GeneralSeed {
|
|||||||
affinity: number | null;
|
affinity: number | null;
|
||||||
personality: string | null;
|
personality: string | null;
|
||||||
special: string | null;
|
special: string | null;
|
||||||
|
specialWar: string | null;
|
||||||
|
horse: string | null;
|
||||||
|
weapon: string | null;
|
||||||
|
book: string | null;
|
||||||
|
item: string | null;
|
||||||
picture: number | string | null;
|
picture: number | string | null;
|
||||||
npcType: number;
|
npcType: number;
|
||||||
text: string | null;
|
text: string | null;
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ describe('scenario bootstrap', () => {
|
|||||||
expect(result.seed.cities[0]?.nationId).toBe(1);
|
expect(result.seed.cities[0]?.nationId).toBe(1);
|
||||||
expect(result.snapshot.generals[0]?.cityId).toBe(1);
|
expect(result.snapshot.generals[0]?.cityId).toBe(1);
|
||||||
expect(result.snapshot.generals[0]?.crewTypeId).toBe(1200);
|
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.seed.generals[0]?.npcType).toBe(2);
|
||||||
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
|
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user