diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index 6d6b159..1860405 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -40,6 +40,13 @@ export interface GeneralPatchEffect< targetId?: GeneralId; } +export interface GeneralAddEffect< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + type: 'general:add'; + general: General; +} + export interface CityPatchEffect { type: 'city:patch'; patch: Partial; @@ -66,6 +73,7 @@ export type GeneralActionEffect< TriggerState extends GeneralTriggerState = GeneralTriggerState > = | GeneralPatchEffect + | GeneralAddEffect | 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 }>; cities: Array<{ id: CityId; patch: Partial }>; @@ -177,6 +188,15 @@ export const createGeneralPatchEffect = < ...(targetId !== undefined ? { targetId } : {}), }); +export const createGeneralAddEffect = < + TriggerState extends GeneralTriggerState = GeneralTriggerState +>( + general: General +): GeneralAddEffect => ({ + type: 'general:add', + general, +}); + export const createCityPatchEffect = ( patch: Partial, 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 = { 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; }; diff --git a/packages/logic/src/actions/turn/general/che_의병모집.ts b/packages/logic/src/actions/turn/general/che_의병모집.ts new file mode 100644 index 0000000..842e91f --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_의병모집.ts @@ -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; + 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 { + 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, + key: string, + value: TriggerValue | null | undefined +): void => { + if (value === null || value === undefined) { + return; + } + meta[key] = value; +}; + +const readMetaNumber = ( + meta: Record, + 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; + private readonly env: VolunteerRecruitEnvironment; + + constructor( + modules: Array | null | undefined>, + env: VolunteerRecruitEnvironment + ) { + this.pipeline = new GeneralActionPipeline(modules); + this.env = env; + } + + getPostDelay( + context: VolunteerRecruitResolveContext, + 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 + ): 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; + + constructor( + modules: Array | null | undefined>, + env: VolunteerRecruitEnvironment + ) { + this.env = env; + this.command = new CommandResolver(modules, env); + } + + resolve( + context: VolunteerRecruitResolveContext, + _args: VolunteerRecruitArgs + ): GeneralActionOutcome { + void _args; + const effects: Array> = []; + 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( + `${context.general.name}이 ${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 = { + 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({ + 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 + > { + public readonly key = 'che_의병모집'; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + private readonly env: VolunteerRecruitEnvironment; + + constructor( + modules: Array | 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, + args: VolunteerRecruitArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} diff --git a/packages/logic/src/actions/turn/general/che_인재탐색.ts b/packages/logic/src/actions/turn/general/che_인재탐색.ts new file mode 100644 index 0000000..5d64aff --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_인재탐색.ts @@ -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; + 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 { + 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, + key: string, + value: TriggerValue | null | undefined +): void => { + if (value === null || value === undefined) { + return; + } + meta[key] = value; +}; + +const addMetaNumber = ( + meta: Record, + key: StatExpKey, + delta: number +): Record => { + const current = + typeof meta[key] === 'number' ? (meta[key] as number) : 0; + return { ...meta, [key]: current + delta }; +}; + +const pickByWeight = ( + rng: RandomGenerator, + weights: Record +): 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; + private readonly env: TalentScoutEnvironment; + + constructor( + modules: Array | 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): 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; + + constructor( + modules: Array | null | undefined>, + env: TalentScoutEnvironment + ) { + this.env = env; + this.command = new CommandResolver(modules, env); + } + + resolve( + context: TalentScoutResolveContext, + _args: TalentScoutArgs + ): GeneralActionOutcome { + 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> = [ + 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 = { + 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({ + 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(`인재 ${name}를 발견했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }) + ); + effects.push( + createLogEffect(`인재 ${name}가 등장했습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + }) + ); + effects.push( + createLogEffect(`인재 ${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 + > { + public readonly key = 'che_인재탐색'; + public readonly name = ACTION_NAME; + private readonly command: CommandResolver; + private readonly resolver: ActionResolver; + + constructor( + modules: Array | 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, + args: TalentScoutArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index 9267377..a6bd0f1 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -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; @@ -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'; diff --git a/packages/logic/src/actions/turn/general/recruitment.ts b/packages/logic/src/actions/turn/general/recruitment.ts new file mode 100644 index 0000000..bac478a --- /dev/null +++ b/packages/logic/src/actions/turn/general/recruitment.ts @@ -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; + 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 +): General => ({ + 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 ?? {}, +}); diff --git a/packages/logic/src/constraints/presets.ts b/packages/logic/src/constraints/presets.ts index 8d0dbf4..3377faf 100644 --- a/packages/logic/src/constraints/presets.ts +++ b/packages/logic/src/constraints/presets.ts @@ -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, + 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 => ({