feat: remove VolunteerRecruit module and related imports

This commit is contained in:
2026-01-02 18:35:58 +00:00
parent 10ff64946e
commit f58b8b2553
4 changed files with 14 additions and 14 deletions
@@ -0,0 +1,426 @@
import type { RandomGenerator } from '@sammo-ts/common';
import type {
GeneralTriggerState,
StatBlock,
TriggerValue,
} from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
} from '../../../constraints/types.js';
import {
availableStrategicCommand,
beChief,
notBeNeutral,
notOpeningPart,
occupiedCity,
} from '../../../constraints/presets.js';
import {
GeneralActionPipeline,
type GeneralActionModule,
} from '../../../triggers/general-action.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import {
createGeneralAddEffect,
} from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
import { buildRecruitmentGeneral } from '../general/recruitment.js';
import { JosaUtil } from '@sammo-ts/common';
import type { TurnCommandEnv } from '../commandEnv.js';
import type { NationTurnCommandSpec } from './index.js';
export interface VolunteerRecruitArgs {}
export interface VolunteerRecruitCandidate {
name: string;
stats?: Partial<StatBlock>;
personality?: string | null;
affinity?: number | null;
specialDomestic?: string | null;
specialWar?: string | null;
picture?: number | string | null;
text?: string | null;
}
export interface VolunteerRecruitResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
currentYear: number;
startYear: number;
averageNationGeneralCount: number;
nationAverageStats?: StatBlock;
nationAverageExperience?: number;
nationAverageDedication?: number;
generalPool?: VolunteerRecruitCandidate[];
createGeneralId: () => number;
}
export interface VolunteerRecruitEnvironment {
openingPartYear: number;
initialNationGenLimit: number;
defaultNpcGold: number;
defaultNpcRice: number;
defaultCrewTypeId: number;
defaultSpecialDomestic: string | null;
defaultSpecialWar: string | null;
createCountBase?: number;
createCountDivisor?: number;
globalDelayBase?: number;
npcAge?: number;
npcDeathYears?: number;
killTurnMin?: number;
killTurnMax?: number;
decorateName?: (name: string, npcState: number) => string;
pickCandidate?: (
context: VolunteerRecruitResolveContext,
rng: RandomGenerator
) => VolunteerRecruitCandidate | null;
buildStats?: (
context: VolunteerRecruitResolveContext,
rng: RandomGenerator,
candidate: VolunteerRecruitCandidate
) => StatBlock;
}
const ACTION_NAME = '의병모집';
const NPC_TYPE = 4;
const DEFAULT_PRE_TURN = 2;
const DEFAULT_CREATE_BASE = 3;
const DEFAULT_CREATE_DIVISOR = 8;
const DEFAULT_GLOBAL_DELAY = 9;
const DEFAULT_NPC_AGE = 20;
const DEFAULT_NPC_DEATH_YEARS = 10;
const DEFAULT_KILLTURN_MIN = 64;
const DEFAULT_KILLTURN_MAX = 70;
const DEFAULT_SPEC_AGE = 19;
const addMetaValue = (
meta: Record<string, TriggerValue>,
key: string,
value: TriggerValue | null | undefined
): void => {
if (value === null || value === undefined) {
return;
}
meta[key] = value;
};
const readMetaNumber = (
meta: Record<string, TriggerValue>,
key: string
): number | null => {
const value = meta[key];
return typeof value === 'number' ? value : null;
};
const randomRangeInt = (
rng: RandomGenerator,
min: number,
max: number
): number => rng.nextInt(min, max + 1);
const resolveRelYear = (ctx: ConstraintContext): number => {
const relYear = ctx.env.relYear;
if (typeof relYear === 'number') {
return relYear;
}
const year = ctx.env.year;
const currentYear = ctx.env.currentYear;
const startYear = ctx.env.startYear;
if (typeof currentYear === 'number' && typeof startYear === 'number') {
return currentYear - startYear;
}
if (typeof year === 'number' && typeof startYear === 'number') {
return year - startYear;
}
return 0;
};
const resolveCandidate = (
context: VolunteerRecruitResolveContext,
rng: RandomGenerator,
env: VolunteerRecruitEnvironment
): VolunteerRecruitCandidate | null => {
if (env.pickCandidate) {
return env.pickCandidate(context, rng);
}
const pool = context.generalPool ?? [];
if (pool.length === 0) {
return null;
}
const idx = rng.nextInt(0, pool.length);
return pool[idx] ?? null;
};
const resolveStats = (
context: VolunteerRecruitResolveContext,
rng: RandomGenerator,
env: VolunteerRecruitEnvironment,
candidate: VolunteerRecruitCandidate
): StatBlock => {
if (env.buildStats) {
return env.buildStats(context, rng, candidate);
}
const fallback =
context.nationAverageStats ?? context.general.stats;
return {
leadership: candidate.stats?.leadership ?? fallback.leadership,
strength: candidate.stats?.strength ?? fallback.strength,
intelligence: candidate.stats?.intelligence ?? fallback.intelligence,
};
};
// 의병모집 쿨타임/인원 계산을 제공한다.
export class CommandResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
private readonly env: VolunteerRecruitEnvironment;
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
env: VolunteerRecruitEnvironment
) {
this.pipeline = new GeneralActionPipeline(modules);
this.env = env;
}
getPostDelay(
context: VolunteerRecruitResolveContext<TriggerState>,
gennum: number
): number {
const fitted = Math.max(gennum, this.env.initialNationGenLimit);
const base = Math.round(Math.sqrt(fitted * 10) * 10);
return Math.round(
this.pipeline.onCalcStrategic(
context,
ACTION_NAME,
'delay',
base
)
);
}
getGlobalDelay(
context: VolunteerRecruitResolveContext<TriggerState>
): number {
const base = this.env.globalDelayBase ?? DEFAULT_GLOBAL_DELAY;
return Math.round(
this.pipeline.onCalcStrategic(
context,
ACTION_NAME,
'globalDelay',
base
)
);
}
getCreateCount(avgNationGenCount: number): number {
const base = this.env.createCountBase ?? DEFAULT_CREATE_BASE;
const divisor = this.env.createCountDivisor ?? DEFAULT_CREATE_DIVISOR;
return base + Math.round(avgNationGenCount / divisor);
}
}
// 의병모집 실행 결과를 계산한다.
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
private readonly env: VolunteerRecruitEnvironment;
private readonly command: CommandResolver<TriggerState>;
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
env: VolunteerRecruitEnvironment
) {
this.env = env;
this.command = new CommandResolver(modules, env);
}
resolve(
context: VolunteerRecruitResolveContext<TriggerState>,
_args: VolunteerRecruitArgs
): GeneralActionOutcome<TriggerState> {
void _args;
const general = context.general;
const nation = context.nation;
const expGain = 5 * (DEFAULT_PRE_TURN + 1);
const dedGain = 5 * (DEFAULT_PRE_TURN + 1);
// 직접 수정 (Immer Draft)
general.experience += expGain;
general.dedication += dedGain;
context.addLog(`${ACTION_NAME} 발동!`);
context.addLog(`${ACTION_NAME} 발동`, {
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
});
if (nation) {
const generalName = general.name;
const generalJosa = JosaUtil.pick(generalName, '이');
const actionJosa = JosaUtil.pick(ACTION_NAME, '을');
context.addLog(
`<Y>${generalName}</>${generalJosa} <M>${ACTION_NAME}</>${actionJosa} 발동했습니다.`,
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: nation.id,
format: LogFormat.YEAR_MONTH,
}
);
}
const avgNationGen =
Number.isFinite(context.averageNationGeneralCount)
? context.averageNationGeneralCount
: 0;
const createCount = Math.max(
0,
this.command.getCreateCount(avgNationGen)
);
const gennumValue = nation
? readMetaNumber(nation.meta, 'gennum')
: null;
const currentGennum = gennumValue ?? 0;
const nextGennum = currentGennum + createCount;
const globalDelay = this.command.getGlobalDelay(context);
if (nation) {
nation.meta = {
...nation.meta as object,
gennum: nextGennum,
strategic_cmd_limit: globalDelay,
};
}
const effects: Array<GeneralActionEffect<TriggerState>> = [];
const baseAge = this.env.npcAge ?? DEFAULT_NPC_AGE;
const deathYears = this.env.npcDeathYears ?? DEFAULT_NPC_DEATH_YEARS;
const killTurnMin = this.env.killTurnMin ?? DEFAULT_KILLTURN_MIN;
const killTurnMax = this.env.killTurnMax ?? DEFAULT_KILLTURN_MAX;
for (let idx = 0; idx < createCount; idx += 1) {
const newGeneralId = context.createGeneralId();
const candidate =
resolveCandidate(context, context.rng, this.env) ??
{ name: `NPC_${newGeneralId}` };
const name = this.env.decorateName
? this.env.decorateName(candidate.name, NPC_TYPE)
: candidate.name;
const birthYear = context.currentYear - baseAge;
const deathYear = context.currentYear + deathYears;
const stats = resolveStats(
context,
context.rng,
this.env,
candidate
);
const meta: Record<string, TriggerValue> = {
npcType: NPC_TYPE,
crewTypeId: this.env.defaultCrewTypeId,
};
addMetaValue(meta, 'affinity', candidate.affinity ?? null);
addMetaValue(meta, 'picture', candidate.picture ?? null);
addMetaValue(meta, 'birthYear', birthYear);
addMetaValue(meta, 'deathYear', deathYear);
addMetaValue(meta, 'specAge', DEFAULT_SPEC_AGE);
addMetaValue(meta, 'specAge2', DEFAULT_SPEC_AGE);
addMetaValue(
meta,
'killturn',
randomRangeInt(context.rng, killTurnMin, killTurnMax)
);
addMetaValue(meta, 'text', candidate.text ?? null);
const newGeneral = buildRecruitmentGeneral<TriggerState>({
id: newGeneralId,
name,
nationId: context.general.nationId,
cityId: context.general.cityId,
stats,
officerLevel: 1,
age: baseAge,
npcState: NPC_TYPE,
gold: this.env.defaultNpcGold,
rice: this.env.defaultNpcRice,
experience: context.nationAverageExperience ?? 0,
dedication: context.nationAverageDedication ?? 0,
crewTypeId: this.env.defaultCrewTypeId,
role: {
personality: candidate.personality ?? null,
specialDomestic: this.env.defaultSpecialDomestic,
specialWar: this.env.defaultSpecialWar,
},
meta,
});
effects.push(createGeneralAddEffect(newGeneral));
}
return { effects };
}
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<
TriggerState,
VolunteerRecruitArgs,
VolunteerRecruitResolveContext<TriggerState>
> {
public readonly key = 'che_의병모집';
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
private readonly env: VolunteerRecruitEnvironment;
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
env: VolunteerRecruitEnvironment
) {
this.env = env;
this.resolver = new ActionResolver(modules, env);
}
parseArgs(_raw: unknown): VolunteerRecruitArgs | null {
void _raw;
return {};
}
buildConstraints(
ctx: ConstraintContext,
_args: VolunteerRecruitArgs
): Constraint[] {
void _args;
const relYear = resolveRelYear(ctx);
return [
beChief(),
notBeNeutral(),
occupiedCity(),
availableStrategicCommand(),
notOpeningPart(relYear, this.env.openingPartYear),
];
}
resolve(
context: VolunteerRecruitResolveContext<TriggerState>,
args: VolunteerRecruitArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
export const commandSpec: NationTurnCommandSpec = {
key: 'che_의병모집',
category: '전략',
reqArg: false,
args: {},
createDefinition: (env: TurnCommandEnv) => new ActionDefinition([], env),
};
@@ -5,6 +5,7 @@ import type * as AwardModule from './che_포상.js';
import type * as AssignmentModule from './che_발령.js';
import type * as DeclarationModule from './che_선전포고.js';
import type * as NonAggressionProposalModule from './che_불가침제의.js';
import type * as VolunteerRecruitModule from './che_의병모집.js';
@@ -13,7 +14,8 @@ export type NationTurnCommandModule =
| typeof AwardModule
| typeof AssignmentModule
| typeof DeclarationModule
| typeof NonAggressionProposalModule;
| typeof NonAggressionProposalModule
| typeof VolunteerRecruitModule;
export type NationTurnCommandImporter = () => Promise<NationTurnCommandModule>;
@@ -23,6 +25,7 @@ const defaultImporters = {
che_발령: async () => import('./che_발령.js'),
che_선전포고: async () => import('./che_선전포고.js'),
che_불가침제의: async () => import('./che_불가침제의.js'),
che_의병모집: async () => import('./che_의병모집.js'),
} as const satisfies Record<string, NationTurnCommandImporter>;
export type NationTurnCommandKey = keyof typeof defaultImporters;
@@ -101,3 +104,8 @@ export {
export {
ActionDefinition as NonAggressionProposalActionDefinition,
} from './che_불가침제의.js';
export {
ActionDefinition as VolunteerRecruitActionDefinition,
ActionResolver as VolunteerRecruitActionResolver,
CommandResolver as VolunteerRecruitCommandResolver,
} from './che_의병모집.js';