feat: add 인재탐색 및 의병모집 기능 구현

This commit is contained in:
2025-12-29 11:59:00 +00:00
parent ee719df8fe
commit 0e0ae82e03
6 changed files with 1114 additions and 3 deletions
+29
View File
@@ -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 ?? {},
});
+85 -1
View File
@@ -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 => ({