feat: add 인재탐색 및 의병모집 기능 구현
This commit is contained in:
@@ -40,6 +40,13 @@ export interface GeneralPatchEffect<
|
||||
targetId?: GeneralId;
|
||||
}
|
||||
|
||||
export interface GeneralAddEffect<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> {
|
||||
type: 'general:add';
|
||||
general: General<TriggerState>;
|
||||
}
|
||||
|
||||
export interface CityPatchEffect {
|
||||
type: 'city:patch';
|
||||
patch: Partial<City>;
|
||||
@@ -66,6 +73,7 @@ export type GeneralActionEffect<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> =
|
||||
| GeneralPatchEffect<TriggerState>
|
||||
| GeneralAddEffect<TriggerState>
|
||||
| CityPatchEffect
|
||||
| NationPatchEffect
|
||||
| LogEffect
|
||||
@@ -95,6 +103,9 @@ export interface GeneralActionResolution {
|
||||
nextTurnAt: Date;
|
||||
logs: LogEntryDraft[];
|
||||
effects: GeneralActionEffect[];
|
||||
created?: {
|
||||
generals: General[];
|
||||
};
|
||||
patches?: {
|
||||
generals: Array<{ id: GeneralId; patch: Partial<General> }>;
|
||||
cities: Array<{ id: CityId; patch: Partial<City> }>;
|
||||
@@ -177,6 +188,15 @@ export const createGeneralPatchEffect = <
|
||||
...(targetId !== undefined ? { targetId } : {}),
|
||||
});
|
||||
|
||||
export const createGeneralAddEffect = <
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
>(
|
||||
general: General<TriggerState>
|
||||
): GeneralAddEffect<TriggerState> => ({
|
||||
type: 'general:add',
|
||||
general,
|
||||
});
|
||||
|
||||
export const createCityPatchEffect = (
|
||||
patch: Partial<City>,
|
||||
targetId?: CityId
|
||||
@@ -240,6 +260,7 @@ export const resolveGeneralAction = <
|
||||
let nextCity = context.city;
|
||||
let nextNation = context.nation ?? null;
|
||||
let nextTurnAtOverride: Date | null = null;
|
||||
const createdGenerals: General[] = [];
|
||||
const patches: NonNullable<GeneralActionResolution['patches']> = {
|
||||
generals: [],
|
||||
cities: [],
|
||||
@@ -274,6 +295,9 @@ export const resolveGeneralAction = <
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'general:add':
|
||||
createdGenerals.push(effect.general as General);
|
||||
break;
|
||||
case 'city:patch':
|
||||
if (
|
||||
effect.targetId === undefined ||
|
||||
@@ -371,6 +395,11 @@ export const resolveGeneralAction = <
|
||||
) {
|
||||
resolution.patches = patches;
|
||||
}
|
||||
if (createdGenerals.length > 0) {
|
||||
resolution.created = {
|
||||
generals: createdGenerals,
|
||||
};
|
||||
}
|
||||
|
||||
return resolution;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
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,
|
||||
createGeneralPatchEffect,
|
||||
createLogEffect,
|
||||
createNationPatchEffect,
|
||||
} from '../../engine.js';
|
||||
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
|
||||
import { buildRecruitmentGeneral } from './recruitment.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 effects: Array<GeneralActionEffect<TriggerState>> = [];
|
||||
const nation = context.nation ?? null;
|
||||
|
||||
const expGain = 5 * (DEFAULT_PRE_TURN + 1);
|
||||
const dedGain = 5 * (DEFAULT_PRE_TURN + 1);
|
||||
effects.push(
|
||||
createGeneralPatchEffect({
|
||||
experience: context.general.experience + expGain,
|
||||
dedication: context.general.dedication + dedGain,
|
||||
})
|
||||
);
|
||||
|
||||
effects.push(
|
||||
createLogEffect(`${ACTION_NAME} 발동!`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
})
|
||||
);
|
||||
effects.push(
|
||||
createLogEffect(`${ACTION_NAME} 발동`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
})
|
||||
);
|
||||
|
||||
if (nation?.id) {
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
`<Y>${context.general.name}</>이 <M>${ACTION_NAME}</>을 발동했습니다.`,
|
||||
{
|
||||
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) {
|
||||
effects.push(
|
||||
createNationPatchEffect({
|
||||
meta: {
|
||||
gennum: nextGennum,
|
||||
strategic_cmd_limit: globalDelay,
|
||||
},
|
||||
}, nation.id)
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import type { RandomGenerator } from '@sammo-ts/common';
|
||||
import type {
|
||||
City,
|
||||
General,
|
||||
GeneralTriggerState,
|
||||
StatBlock,
|
||||
TriggerValue,
|
||||
} from '../../../domain/entities.js';
|
||||
import type {
|
||||
Constraint,
|
||||
ConstraintContext,
|
||||
} from '../../../constraints/types.js';
|
||||
import {
|
||||
reqGeneralGold,
|
||||
reqGeneralRice,
|
||||
} 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,
|
||||
createGeneralPatchEffect,
|
||||
createLogEffect,
|
||||
} from '../../engine.js';
|
||||
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
|
||||
import { buildRecruitmentGeneral } from './recruitment.js';
|
||||
|
||||
export interface TalentScoutArgs {}
|
||||
|
||||
export interface TalentScoutCandidate {
|
||||
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 TalentScoutWorldSummary {
|
||||
totalGeneralCount: number;
|
||||
totalNpcCount: number;
|
||||
averageStats?: StatBlock;
|
||||
}
|
||||
|
||||
export interface TalentScoutResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
currentYear: number;
|
||||
worldSummary: TalentScoutWorldSummary;
|
||||
generalPool?: TalentScoutCandidate[];
|
||||
cityPool?: City[];
|
||||
createGeneralId: () => number;
|
||||
}
|
||||
|
||||
export interface TalentScoutEnvironment {
|
||||
develCost: number;
|
||||
maxGeneral: number;
|
||||
defaultNpcGold: number;
|
||||
defaultNpcRice: number;
|
||||
defaultCrewTypeId: number;
|
||||
defaultSpecialDomestic: string | null;
|
||||
defaultSpecialWar: string | null;
|
||||
minNpcAge?: number;
|
||||
maxNpcAge?: number;
|
||||
minDeathYears?: number;
|
||||
maxDeathYears?: number;
|
||||
decorateName?: (name: string, npcState: number) => string;
|
||||
pickCandidate?: (
|
||||
context: TalentScoutResolveContext,
|
||||
rng: RandomGenerator
|
||||
) => TalentScoutCandidate | null;
|
||||
pickSpawnCityId?: (
|
||||
context: TalentScoutResolveContext,
|
||||
rng: RandomGenerator
|
||||
) => number | null;
|
||||
buildStats?: (
|
||||
context: TalentScoutResolveContext,
|
||||
rng: RandomGenerator,
|
||||
candidate: TalentScoutCandidate
|
||||
) => StatBlock;
|
||||
}
|
||||
|
||||
type StatExpKey = 'leadership_exp' | 'strength_exp' | 'intel_exp';
|
||||
|
||||
const ACTION_NAME = '인재탐색';
|
||||
const ACTION_KEY = '인재탐색';
|
||||
const NPC_TYPE = 3;
|
||||
const DEFAULT_MIN_AGE = 20;
|
||||
const DEFAULT_MAX_AGE = 25;
|
||||
const DEFAULT_DEATH_MIN = 10;
|
||||
const DEFAULT_DEATH_MAX = 50;
|
||||
|
||||
const addMetaValue = (
|
||||
meta: Record<string, TriggerValue>,
|
||||
key: string,
|
||||
value: TriggerValue | null | undefined
|
||||
): void => {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
meta[key] = value;
|
||||
};
|
||||
|
||||
const addMetaNumber = (
|
||||
meta: Record<string, TriggerValue>,
|
||||
key: StatExpKey,
|
||||
delta: number
|
||||
): Record<string, TriggerValue> => {
|
||||
const current =
|
||||
typeof meta[key] === 'number' ? (meta[key] as number) : 0;
|
||||
return { ...meta, [key]: current + delta };
|
||||
};
|
||||
|
||||
const pickByWeight = <T extends string>(
|
||||
rng: RandomGenerator,
|
||||
weights: Record<T, number>
|
||||
): T => {
|
||||
const entries = Object.entries(weights) as Array<[T, number]>;
|
||||
const first = entries[0];
|
||||
if (!first) {
|
||||
throw new Error('Empty weights');
|
||||
}
|
||||
let total = 0;
|
||||
for (const [, weight] of entries) {
|
||||
if (weight > 0) {
|
||||
total += weight;
|
||||
}
|
||||
}
|
||||
if (total <= 0) {
|
||||
return first[0];
|
||||
}
|
||||
let cursor = rng.nextFloat() * total;
|
||||
for (const [key, weight] of entries) {
|
||||
if (weight <= 0) {
|
||||
continue;
|
||||
}
|
||||
cursor -= weight;
|
||||
if (cursor <= 0) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
const last = entries[entries.length - 1];
|
||||
return last ? last[0] : first[0];
|
||||
};
|
||||
|
||||
const pickStatExpKey = (
|
||||
rng: RandomGenerator,
|
||||
general: General
|
||||
): StatExpKey =>
|
||||
pickByWeight(rng, {
|
||||
leadership_exp: general.stats.leadership,
|
||||
strength_exp: general.stats.strength,
|
||||
intel_exp: general.stats.intelligence,
|
||||
});
|
||||
|
||||
const calcFoundProp = (
|
||||
maxGeneral: number,
|
||||
totalGeneralCount: number,
|
||||
totalNpcCount: number
|
||||
): number => {
|
||||
if (maxGeneral <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const current =
|
||||
totalGeneralCount + totalNpcCount / 2;
|
||||
const remainSlot = Math.max(maxGeneral - current, 0);
|
||||
const main = Math.pow(remainSlot / maxGeneral, 6);
|
||||
const small = 1 / (totalNpcCount / 3 + 1);
|
||||
const big = 1 / maxGeneral;
|
||||
if (totalNpcCount < 50) {
|
||||
return Math.max(main, small);
|
||||
}
|
||||
return Math.max(main, big);
|
||||
};
|
||||
|
||||
const randomRangeInt = (
|
||||
rng: RandomGenerator,
|
||||
min: number,
|
||||
max: number
|
||||
): number => rng.nextInt(min, max + 1);
|
||||
|
||||
const resolveCandidate = (
|
||||
context: TalentScoutResolveContext,
|
||||
rng: RandomGenerator,
|
||||
env: TalentScoutEnvironment
|
||||
): TalentScoutCandidate | 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 resolveSpawnCityId = (
|
||||
context: TalentScoutResolveContext,
|
||||
rng: RandomGenerator,
|
||||
env: TalentScoutEnvironment
|
||||
): number => {
|
||||
if (env.pickSpawnCityId) {
|
||||
const picked = env.pickSpawnCityId(context, rng);
|
||||
if (picked !== null && picked !== undefined) {
|
||||
return picked;
|
||||
}
|
||||
}
|
||||
const pool = context.cityPool ?? [];
|
||||
if (pool.length > 0) {
|
||||
const idx = rng.nextInt(0, pool.length);
|
||||
return pool[idx]!.id;
|
||||
}
|
||||
return context.general.cityId;
|
||||
};
|
||||
|
||||
const resolveStats = (
|
||||
context: TalentScoutResolveContext,
|
||||
rng: RandomGenerator,
|
||||
env: TalentScoutEnvironment,
|
||||
candidate: TalentScoutCandidate
|
||||
): StatBlock => {
|
||||
if (env.buildStats) {
|
||||
return env.buildStats(context, rng, candidate);
|
||||
}
|
||||
const fallback =
|
||||
context.worldSummary.averageStats ?? 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: TalentScoutEnvironment;
|
||||
|
||||
constructor(
|
||||
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||
env: TalentScoutEnvironment
|
||||
) {
|
||||
this.pipeline = new GeneralActionPipeline(modules);
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
getCost(): { gold: number; rice: number } {
|
||||
return {
|
||||
gold: this.env.develCost,
|
||||
rice: 0,
|
||||
};
|
||||
}
|
||||
|
||||
calcFoundProp(context: TalentScoutResolveContext<TriggerState>): number {
|
||||
const base = calcFoundProp(
|
||||
this.env.maxGeneral,
|
||||
context.worldSummary.totalGeneralCount,
|
||||
context.worldSummary.totalNpcCount
|
||||
);
|
||||
return this.pipeline.onCalcDomestic(
|
||||
context,
|
||||
ACTION_KEY,
|
||||
'probability',
|
||||
base
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 인재탐색 실행 결과를 계산한다.
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> {
|
||||
private readonly env: TalentScoutEnvironment;
|
||||
private readonly command: CommandResolver<TriggerState>;
|
||||
|
||||
constructor(
|
||||
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||
env: TalentScoutEnvironment
|
||||
) {
|
||||
this.env = env;
|
||||
this.command = new CommandResolver(modules, env);
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: TalentScoutResolveContext<TriggerState>,
|
||||
_args: TalentScoutArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
void _args;
|
||||
const { gold: reqGold, rice: reqRice } = this.command.getCost();
|
||||
const prop = this.command.calcFoundProp(context);
|
||||
const found = context.rng.nextBool(prop);
|
||||
|
||||
const statKey = pickStatExpKey(context.rng, context.general);
|
||||
const metaAfter =
|
||||
found
|
||||
? addMetaNumber(context.general.meta, statKey, 3)
|
||||
: addMetaNumber(context.general.meta, statKey, 1);
|
||||
|
||||
const nextGold = Math.max(0, context.general.gold - reqGold);
|
||||
const nextRice = Math.max(0, context.general.rice - reqRice);
|
||||
const expGain = found ? 200 : 100;
|
||||
const dedGain = found ? 300 : 70;
|
||||
|
||||
const effects: Array<GeneralActionEffect<TriggerState>> = [
|
||||
createGeneralPatchEffect({
|
||||
gold: nextGold,
|
||||
rice: nextRice,
|
||||
experience: context.general.experience + expGain,
|
||||
dedication: context.general.dedication + dedGain,
|
||||
meta: metaAfter,
|
||||
}),
|
||||
];
|
||||
|
||||
if (!found) {
|
||||
effects.push(
|
||||
createLogEffect('인재를 찾을 수 없었습니다.', {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
})
|
||||
);
|
||||
return { effects };
|
||||
}
|
||||
|
||||
const candidate = resolveCandidate(context, context.rng, this.env);
|
||||
const newGeneralId = context.createGeneralId();
|
||||
const resolvedCandidate: TalentScoutCandidate =
|
||||
candidate ?? { name: `NPC_${newGeneralId}` };
|
||||
|
||||
const age = randomRangeInt(
|
||||
context.rng,
|
||||
this.env.minNpcAge ?? DEFAULT_MIN_AGE,
|
||||
this.env.maxNpcAge ?? DEFAULT_MAX_AGE
|
||||
);
|
||||
const birthYear = context.currentYear - age;
|
||||
const deathYear =
|
||||
context.currentYear +
|
||||
randomRangeInt(
|
||||
context.rng,
|
||||
this.env.minDeathYears ?? DEFAULT_DEATH_MIN,
|
||||
this.env.maxDeathYears ?? DEFAULT_DEATH_MAX
|
||||
);
|
||||
const stats = resolveStats(
|
||||
context,
|
||||
context.rng,
|
||||
this.env,
|
||||
resolvedCandidate
|
||||
);
|
||||
const name = this.env.decorateName
|
||||
? this.env.decorateName(resolvedCandidate.name, NPC_TYPE)
|
||||
: resolvedCandidate.name;
|
||||
const meta: Record<string, TriggerValue> = {
|
||||
npcType: NPC_TYPE,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
};
|
||||
addMetaValue(meta, 'affinity', resolvedCandidate.affinity ?? null);
|
||||
addMetaValue(meta, 'picture', resolvedCandidate.picture ?? null);
|
||||
addMetaValue(meta, 'birthYear', birthYear);
|
||||
addMetaValue(meta, 'deathYear', deathYear);
|
||||
addMetaValue(meta, 'text', resolvedCandidate.text ?? null);
|
||||
|
||||
const newGeneral = buildRecruitmentGeneral<TriggerState>({
|
||||
id: newGeneralId,
|
||||
name,
|
||||
nationId: 0,
|
||||
cityId: resolveSpawnCityId(context, context.rng, this.env),
|
||||
stats,
|
||||
officerLevel: 0,
|
||||
age,
|
||||
npcState: NPC_TYPE,
|
||||
gold: this.env.defaultNpcGold,
|
||||
rice: this.env.defaultNpcRice,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
role: {
|
||||
personality: resolvedCandidate.personality ?? null,
|
||||
specialDomestic: this.env.defaultSpecialDomestic,
|
||||
specialWar: this.env.defaultSpecialWar,
|
||||
},
|
||||
meta,
|
||||
});
|
||||
|
||||
effects.push(createGeneralAddEffect(newGeneral));
|
||||
effects.push(
|
||||
createLogEffect(`인재 <Y>${name}</>를 발견했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
})
|
||||
);
|
||||
effects.push(
|
||||
createLogEffect(`인재 <Y>${name}</>가 등장했습니다.`, {
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
})
|
||||
);
|
||||
effects.push(
|
||||
createLogEffect(`인재 <Y>${name}</>를 발견했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
})
|
||||
);
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements GeneralActionDefinition<
|
||||
TriggerState,
|
||||
TalentScoutArgs,
|
||||
TalentScoutResolveContext<TriggerState>
|
||||
> {
|
||||
public readonly key = 'che_인재탐색';
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly command: CommandResolver<TriggerState>;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor(
|
||||
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||
env: TalentScoutEnvironment
|
||||
) {
|
||||
this.command = new CommandResolver(modules, env);
|
||||
this.resolver = new ActionResolver(modules, env);
|
||||
}
|
||||
|
||||
parseArgs(_raw: unknown): TalentScoutArgs | null {
|
||||
void _raw;
|
||||
return {};
|
||||
}
|
||||
|
||||
buildConstraints(
|
||||
_ctx: ConstraintContext,
|
||||
_args: TalentScoutArgs
|
||||
): Constraint[] {
|
||||
void _ctx;
|
||||
void _args;
|
||||
const { gold, rice } = this.command.getCost();
|
||||
return [
|
||||
reqGeneralGold(() => gold),
|
||||
reqGeneralRice(() => rice),
|
||||
];
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: TalentScoutResolveContext<TriggerState>,
|
||||
args: TalentScoutArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
return this.resolver.resolve(context, args);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
export type GeneralTurnCommandKey = 'che_상업투자' | 'che_화계';
|
||||
export type GeneralTurnCommandKey =
|
||||
| 'che_상업투자'
|
||||
| 'che_화계'
|
||||
| 'che_인재탐색'
|
||||
| 'che_의병모집';
|
||||
|
||||
export type GeneralTurnCommandModule =
|
||||
| typeof import('./che_상업투자.js')
|
||||
| typeof import('./che_화계.js');
|
||||
| typeof import('./che_화계.js')
|
||||
| typeof import('./che_인재탐색.js')
|
||||
| typeof import('./che_의병모집.js');
|
||||
|
||||
export type GeneralTurnCommandImporter = () => Promise<GeneralTurnCommandModule>;
|
||||
|
||||
@@ -12,6 +18,8 @@ const defaultImporters: Record<
|
||||
> = {
|
||||
che_상업투자: async () => import('./che_상업투자.js'),
|
||||
che_화계: async () => import('./che_화계.js'),
|
||||
che_인재탐색: async () => import('./che_인재탐색.js'),
|
||||
che_의병모집: async () => import('./che_의병모집.js'),
|
||||
};
|
||||
|
||||
export class GeneralTurnCommandLoader {
|
||||
@@ -43,3 +51,13 @@ export {
|
||||
ActionResolver as FireAttackActionResolver,
|
||||
CommandResolver as FireAttackCommandResolver,
|
||||
} from './che_화계.js';
|
||||
export {
|
||||
ActionDefinition as TalentScoutActionDefinition,
|
||||
ActionResolver as TalentScoutActionResolver,
|
||||
CommandResolver as TalentScoutCommandResolver,
|
||||
} from './che_인재탐색.js';
|
||||
export {
|
||||
ActionDefinition as VolunteerRecruitActionDefinition,
|
||||
ActionResolver as VolunteerRecruitActionResolver,
|
||||
CommandResolver as VolunteerRecruitCommandResolver,
|
||||
} from './che_의병모집.js';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type {
|
||||
General,
|
||||
GeneralRole,
|
||||
GeneralTriggerState,
|
||||
StatBlock,
|
||||
TriggerValue,
|
||||
} from '../../../domain/entities.js';
|
||||
|
||||
export interface GeneralRecruitmentInput<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> {
|
||||
id: number;
|
||||
name: string;
|
||||
nationId: number;
|
||||
cityId: number;
|
||||
stats: StatBlock;
|
||||
officerLevel: number;
|
||||
age: number;
|
||||
npcState: number;
|
||||
gold: number;
|
||||
rice: number;
|
||||
experience: number;
|
||||
dedication: number;
|
||||
crewTypeId: number;
|
||||
role: {
|
||||
personality: string | null;
|
||||
specialDomestic: string | null;
|
||||
specialWar: string | null;
|
||||
};
|
||||
meta?: Record<string, TriggerValue>;
|
||||
triggerState?: TriggerState;
|
||||
}
|
||||
|
||||
const createEmptyTriggerState = (): GeneralTriggerState => ({
|
||||
flags: {},
|
||||
counters: {},
|
||||
modifiers: {},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const createEmptyRole = (): GeneralRole => ({
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: {
|
||||
horse: null,
|
||||
weapon: null,
|
||||
book: null,
|
||||
item: null,
|
||||
},
|
||||
});
|
||||
|
||||
// 모집/탐색 등으로 생성되는 장수의 기본 모델을 구성한다.
|
||||
export const buildRecruitmentGeneral = <
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
>(
|
||||
input: GeneralRecruitmentInput<TriggerState>
|
||||
): General<TriggerState> => ({
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
nationId: input.nationId,
|
||||
cityId: input.cityId,
|
||||
troopId: 0,
|
||||
stats: input.stats,
|
||||
experience: input.experience,
|
||||
dedication: input.dedication,
|
||||
officerLevel: input.officerLevel,
|
||||
role: {
|
||||
...createEmptyRole(),
|
||||
...input.role,
|
||||
},
|
||||
injury: 0,
|
||||
gold: input.gold,
|
||||
rice: input.rice,
|
||||
crew: 0,
|
||||
crewTypeId: input.crewTypeId,
|
||||
train: 0,
|
||||
age: input.age,
|
||||
npcState: input.npcState,
|
||||
triggerState:
|
||||
input.triggerState ??
|
||||
(createEmptyTriggerState() as TriggerState),
|
||||
meta: input.meta ?? {},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { City, General, Nation } from '../domain/entities.js';
|
||||
import type { City, General, Nation, TriggerValue } from '../domain/entities.js';
|
||||
import type {
|
||||
Constraint,
|
||||
ConstraintContext,
|
||||
@@ -78,6 +78,14 @@ const readNation = (
|
||||
return view.get(req) as Nation | null;
|
||||
};
|
||||
|
||||
const readMetaNumber = (
|
||||
meta: Record<string, TriggerValue>,
|
||||
key: string
|
||||
): number | null => {
|
||||
const value = meta[key];
|
||||
return typeof value === 'number' ? value : null;
|
||||
};
|
||||
|
||||
const readDiplomacyState = (
|
||||
view: StateView,
|
||||
srcNationId: number,
|
||||
@@ -127,6 +135,25 @@ export const notBeNeutral = (): Constraint => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const beChief = (): Constraint => ({
|
||||
name: 'BeChief',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx, view) => {
|
||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||
if (!view.has(req)) {
|
||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
}
|
||||
const general = view.get(req) as General | null;
|
||||
if (!general) {
|
||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
}
|
||||
if (general.officerLevel > 4) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '수뇌가 아닙니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const notWanderingNation = (): Constraint => ({
|
||||
name: 'NotWanderingNation',
|
||||
requires: (ctx) =>
|
||||
@@ -149,6 +176,63 @@ export const notWanderingNation = (): Constraint => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const availableStrategicCommand = (
|
||||
allowTurnCnt = 0
|
||||
): Constraint => ({
|
||||
name: 'AvailableStrategicCommand',
|
||||
requires: (ctx) => {
|
||||
const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }];
|
||||
if (ctx.nationId !== undefined) {
|
||||
reqs.push({ kind: 'nation', id: ctx.nationId });
|
||||
}
|
||||
return reqs;
|
||||
},
|
||||
test: (ctx, view) => {
|
||||
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||
if (!view.has(generalReq)) {
|
||||
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
|
||||
}
|
||||
const general = view.get(generalReq) as General | null;
|
||||
if (!general) {
|
||||
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
|
||||
}
|
||||
const nationId = ctx.nationId ?? general.nationId;
|
||||
if (!nationId) {
|
||||
return unknownOrDeny(ctx, [], '국가 정보가 없습니다.');
|
||||
}
|
||||
const nationReq: RequirementKey = { kind: 'nation', id: nationId };
|
||||
if (!view.has(nationReq)) {
|
||||
return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.');
|
||||
}
|
||||
const nation = view.get(nationReq) as Nation | null;
|
||||
if (!nation) {
|
||||
return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.');
|
||||
}
|
||||
const limit = readMetaNumber(nation.meta, 'strategic_cmd_limit');
|
||||
if (limit === null) {
|
||||
return unknownOrDeny(ctx, [nationReq], '전략기한 정보가 없습니다.');
|
||||
}
|
||||
if (limit <= allowTurnCnt) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '전략기한이 남았습니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const notOpeningPart = (
|
||||
relYear: number,
|
||||
openingPartYear: number
|
||||
): Constraint => ({
|
||||
name: 'NotOpeningPart',
|
||||
requires: () => [],
|
||||
test: (_ctx) => {
|
||||
if (relYear >= openingPartYear) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '초반 제한 중에는 불가능합니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const occupiedCity = (
|
||||
options: { allowNeutral?: boolean } = {}
|
||||
): Constraint => ({
|
||||
|
||||
Reference in New Issue
Block a user