feat: add 인재탐색 및 의병모집 기능 구현
This commit is contained in:
@@ -40,6 +40,13 @@ export interface GeneralPatchEffect<
|
|||||||
targetId?: GeneralId;
|
targetId?: GeneralId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeneralAddEffect<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||||
|
> {
|
||||||
|
type: 'general:add';
|
||||||
|
general: General<TriggerState>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CityPatchEffect {
|
export interface CityPatchEffect {
|
||||||
type: 'city:patch';
|
type: 'city:patch';
|
||||||
patch: Partial<City>;
|
patch: Partial<City>;
|
||||||
@@ -66,6 +73,7 @@ export type GeneralActionEffect<
|
|||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||||
> =
|
> =
|
||||||
| GeneralPatchEffect<TriggerState>
|
| GeneralPatchEffect<TriggerState>
|
||||||
|
| GeneralAddEffect<TriggerState>
|
||||||
| CityPatchEffect
|
| CityPatchEffect
|
||||||
| NationPatchEffect
|
| NationPatchEffect
|
||||||
| LogEffect
|
| LogEffect
|
||||||
@@ -95,6 +103,9 @@ export interface GeneralActionResolution {
|
|||||||
nextTurnAt: Date;
|
nextTurnAt: Date;
|
||||||
logs: LogEntryDraft[];
|
logs: LogEntryDraft[];
|
||||||
effects: GeneralActionEffect[];
|
effects: GeneralActionEffect[];
|
||||||
|
created?: {
|
||||||
|
generals: General[];
|
||||||
|
};
|
||||||
patches?: {
|
patches?: {
|
||||||
generals: Array<{ id: GeneralId; patch: Partial<General> }>;
|
generals: Array<{ id: GeneralId; patch: Partial<General> }>;
|
||||||
cities: Array<{ id: CityId; patch: Partial<City> }>;
|
cities: Array<{ id: CityId; patch: Partial<City> }>;
|
||||||
@@ -177,6 +188,15 @@ export const createGeneralPatchEffect = <
|
|||||||
...(targetId !== undefined ? { targetId } : {}),
|
...(targetId !== undefined ? { targetId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createGeneralAddEffect = <
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||||
|
>(
|
||||||
|
general: General<TriggerState>
|
||||||
|
): GeneralAddEffect<TriggerState> => ({
|
||||||
|
type: 'general:add',
|
||||||
|
general,
|
||||||
|
});
|
||||||
|
|
||||||
export const createCityPatchEffect = (
|
export const createCityPatchEffect = (
|
||||||
patch: Partial<City>,
|
patch: Partial<City>,
|
||||||
targetId?: CityId
|
targetId?: CityId
|
||||||
@@ -240,6 +260,7 @@ export const resolveGeneralAction = <
|
|||||||
let nextCity = context.city;
|
let nextCity = context.city;
|
||||||
let nextNation = context.nation ?? null;
|
let nextNation = context.nation ?? null;
|
||||||
let nextTurnAtOverride: Date | null = null;
|
let nextTurnAtOverride: Date | null = null;
|
||||||
|
const createdGenerals: General[] = [];
|
||||||
const patches: NonNullable<GeneralActionResolution['patches']> = {
|
const patches: NonNullable<GeneralActionResolution['patches']> = {
|
||||||
generals: [],
|
generals: [],
|
||||||
cities: [],
|
cities: [],
|
||||||
@@ -274,6 +295,9 @@ export const resolveGeneralAction = <
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'general:add':
|
||||||
|
createdGenerals.push(effect.general as General);
|
||||||
|
break;
|
||||||
case 'city:patch':
|
case 'city:patch':
|
||||||
if (
|
if (
|
||||||
effect.targetId === undefined ||
|
effect.targetId === undefined ||
|
||||||
@@ -371,6 +395,11 @@ export const resolveGeneralAction = <
|
|||||||
) {
|
) {
|
||||||
resolution.patches = patches;
|
resolution.patches = patches;
|
||||||
}
|
}
|
||||||
|
if (createdGenerals.length > 0) {
|
||||||
|
resolution.created = {
|
||||||
|
generals: createdGenerals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return resolution;
|
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 =
|
export type GeneralTurnCommandModule =
|
||||||
| typeof import('./che_상업투자.js')
|
| 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>;
|
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'),
|
||||||
|
che_인재탐색: async () => import('./che_인재탐색.js'),
|
||||||
|
che_의병모집: async () => import('./che_의병모집.js'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GeneralTurnCommandLoader {
|
export class GeneralTurnCommandLoader {
|
||||||
@@ -43,3 +51,13 @@ export {
|
|||||||
ActionResolver as FireAttackActionResolver,
|
ActionResolver as FireAttackActionResolver,
|
||||||
CommandResolver as FireAttackCommandResolver,
|
CommandResolver as FireAttackCommandResolver,
|
||||||
} from './che_화계.js';
|
} 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 {
|
import type {
|
||||||
Constraint,
|
Constraint,
|
||||||
ConstraintContext,
|
ConstraintContext,
|
||||||
@@ -78,6 +78,14 @@ const readNation = (
|
|||||||
return view.get(req) as Nation | null;
|
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 = (
|
const readDiplomacyState = (
|
||||||
view: StateView,
|
view: StateView,
|
||||||
srcNationId: number,
|
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 => ({
|
export const notWanderingNation = (): Constraint => ({
|
||||||
name: 'NotWanderingNation',
|
name: 'NotWanderingNation',
|
||||||
requires: (ctx) =>
|
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 = (
|
export const occupiedCity = (
|
||||||
options: { allowNeutral?: boolean } = {}
|
options: { allowNeutral?: boolean } = {}
|
||||||
): Constraint => ({
|
): Constraint => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user