feat: add atmos property to General entity and update related logic

- Added `atmos` property to the `General` interface in `entities.ts`.
- Initialized `atmos` to 0 in the `buildGeneralSeeds` function in `bootstrap.ts`.
- Updated exports in `index.ts` to include `unitSet.js`.
- Introduced new types for crew types and their requirements in `types.ts`.
- Implemented `unitSetLoader.ts` for loading unit set definitions from JSON files.
- Created `che_징병.ts` action for recruiting crew types with cost and training calculations.
- Developed `unitSet.ts` for handling crew type availability and requirements.
- Added tests for crew type availability and unit set parsing in `crewType.test.ts`.
This commit is contained in:
2025-12-30 09:45:51 +00:00
parent e3bbbd3718
commit d599d94a7b
18 changed files with 1665 additions and 31 deletions
@@ -0,0 +1,602 @@
import type {
City,
General,
GeneralTriggerState,
Nation,
TriggerValue,
} from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
RequirementKey,
StateView,
} from '../../../constraints/types.js';
import {
notBeNeutral,
occupiedCity,
reqCityCapacity,
reqCityTrust,
reqGeneralCrewMargin,
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 {
createCityPatchEffect,
createGeneralPatchEffect,
createLogEffect,
} from '../../engine.js';
import type { MapDefinition, UnitSetDefinition } from '../../../world/types.js';
import {
type CrewTypeAvailabilityContext,
findCrewTypeById,
getTechCost,
isCrewTypeAvailable,
} from '../../../world/unitSet.js';
export interface RecruitArgs {
crewType: number;
amount: number;
}
export interface RecruitEnvironment {
costOffset?: number;
defaultTrain?: number;
defaultAtmos?: number;
minAvailableRecruitPop?: number;
defaultTrust?: number;
}
export interface RecruitResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
map: MapDefinition;
unitSet: UnitSetDefinition;
cities: City[];
currentYear?: number;
startYear?: number;
}
const ACTION_NAME = '징병';
const DEFAULT_COST_OFFSET = 1;
const DEFAULT_TRAIN = 40;
const DEFAULT_ATMOS = 40;
const DEFAULT_MIN_POP = 30000;
const DEFAULT_TRUST = 50;
const MIN_CREW = 100;
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const clamp = (value: number, min: number | null, max: number | null): number => {
if (max !== null && min !== null && max < min) {
return min;
}
if (min !== null && value < min) {
return min;
}
if (max !== null && value > max) {
return max;
}
return value;
};
const readNationTech = (nation: Nation | null | undefined): number => {
if (!nation) {
return 0;
}
const tech = nation.meta.tech;
return typeof tech === 'number' ? tech : 0;
};
const readCityTrust = (
city: City,
fallback: number
): number => {
const meta = city.meta as Record<string, unknown>;
const trust = meta?.trust;
return typeof trust === 'number' ? trust : fallback;
};
const addMetaNumber = (
meta: Record<string, TriggerValue>,
key: string,
delta: number
): Record<string, TriggerValue> => {
const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0;
return { ...meta, [key]: current + delta };
};
const resolveCrewTypeId = (args: Record<string, unknown>): number | null => {
const raw = args.crewType ?? args.crewTypeId;
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
}
const value = Math.floor(raw);
return value > 0 ? value : null;
};
const resolveCrewAmount = (args: Record<string, unknown>): number | null => {
const raw = args.amount;
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
}
const value = Math.floor(raw);
if (value < 0) {
return null;
}
return value;
};
const buildCrewTypeContext = (
ctx: ConstraintContext,
view: StateView
): CrewTypeAvailabilityContext | null => {
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
const general = view.get(generalReq) as General | null;
if (!general) {
return null;
}
const nationId = ctx.nationId ?? general.nationId;
const nationReq: RequirementKey = { kind: 'nation', id: nationId };
const nation =
nationId > 0 ? ((view.get(nationReq) as Nation | null) ?? null) : null;
const map = ctx.env.map;
const cities = ctx.env.cities;
if (!map || !cities || !Array.isArray(cities)) {
return null;
}
const currentYear =
typeof ctx.env.currentYear === 'number'
? ctx.env.currentYear
: typeof ctx.env.year === 'number'
? ctx.env.year
: undefined;
const startYear =
typeof ctx.env.startYear === 'number' ? ctx.env.startYear : undefined;
const result: CrewTypeAvailabilityContext = {
general,
nation,
map: map as MapDefinition,
cities: cities as City[],
};
if (currentYear !== undefined) {
result.currentYear = currentYear;
}
if (startYear !== undefined) {
result.startYear = startYear;
}
return result;
};
type RecruitCalcContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> = {
general: General<TriggerState>;
city?: City;
nation?: Nation | null;
};
const buildCalcContext = <TriggerState extends GeneralTriggerState>(
ctx: ConstraintContext,
view: StateView
): RecruitCalcContext<TriggerState> | null => {
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
const general = view.get(generalReq) as General<TriggerState> | null;
if (!general) {
return null;
}
const nationId = ctx.nationId ?? general.nationId;
const nationReq: RequirementKey = { kind: 'nation', id: nationId };
const nation =
nationId > 0 ? ((view.get(nationReq) as Nation | null) ?? null) : null;
const city =
ctx.cityId !== undefined
? ((view.get({ kind: 'city', id: ctx.cityId }) as City | null) ??
undefined)
: undefined;
const result: RecruitCalcContext<TriggerState> = { general };
if (city) {
result.city = city;
}
if (nation !== undefined) {
result.nation = nation;
}
return result;
};
export class CommandResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
// 징병 명령의 비용/훈련/사기 계산을 담당한다.
private readonly pipeline: GeneralActionPipeline<TriggerState>;
private readonly env: RecruitEnvironment;
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
env: RecruitEnvironment
) {
this.pipeline = new GeneralActionPipeline(modules);
this.env = env;
}
resolveLeadership(context: RecruitCalcContext<TriggerState>): number {
const base = context.general.stats.leadership;
return Math.round(
this.pipeline.onCalcStat(context, 'leadership', base)
);
}
resolveCrewPlan(
context: RecruitCalcContext<TriggerState>,
crewTypeId: number,
amount: number
): { requested: number; applied: number } {
const leadership = this.resolveLeadership(context);
let maxCrew = leadership * 100;
if (crewTypeId === context.general.crewTypeId) {
maxCrew -= context.general.crew;
}
const requested = clamp(amount, MIN_CREW, null);
const applied = clamp(amount, MIN_CREW, maxCrew);
return { requested, applied };
}
getCost(
context: RecruitCalcContext<TriggerState>,
crewTypeId: number,
amount: number,
crewType?: { armType: number; cost: number }
): { gold: number; rice: number; applied: number; requested: number } {
const plan = this.resolveCrewPlan(context, crewTypeId, amount);
const tech = readNationTech(context.nation ?? null);
const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET;
const baseGold = crewType
? crewType.cost * getTechCost(tech) * plan.applied / 100
: 0;
const adjustedGold = this.pipeline.onCalcDomestic(
context,
ACTION_NAME,
'cost',
baseGold,
crewType ? { armType: crewType.armType } : undefined
);
const baseRice = plan.applied / 100;
const adjustedRice = this.pipeline.onCalcDomestic(
context,
ACTION_NAME,
'rice',
baseRice,
crewType ? { armType: crewType.armType } : undefined
);
return {
gold: Math.round(adjustedGold * costOffset),
rice: Math.round(adjustedRice),
applied: plan.applied,
requested: plan.requested,
};
}
getTrain(
context: RecruitCalcContext<TriggerState>,
crewType?: { armType: number }
): number {
const base = this.env.defaultTrain ?? DEFAULT_TRAIN;
return this.pipeline.onCalcDomestic(
context,
ACTION_NAME,
'train',
base,
crewType ? { armType: crewType.armType } : undefined
);
}
getAtmos(
context: RecruitCalcContext<TriggerState>,
crewType?: { armType: number }
): number {
const base = this.env.defaultAtmos ?? DEFAULT_ATMOS;
return this.pipeline.onCalcDomestic(
context,
ACTION_NAME,
'atmos',
base,
crewType ? { armType: crewType.armType } : undefined
);
}
getRecruitPopulation(
context: RecruitCalcContext<TriggerState>,
amount: number
): number {
const base = this.pipeline.onCalcDomestic(
context,
'징집인구',
'score',
amount
);
return Math.round(base);
}
}
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
// 징병 실행 결과를 계산하고 효과로 변환한다.
private readonly env: RecruitEnvironment;
private readonly command: CommandResolver<TriggerState>;
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
env: RecruitEnvironment
) {
this.env = env;
this.command = new CommandResolver(modules, env);
}
resolve(
context: RecruitResolveContext<TriggerState>,
args: RecruitArgs
): GeneralActionOutcome<TriggerState> {
const { general, city } = context;
if (!city) {
return {
effects: [createLogEffect('도시 정보가 없습니다.')],
};
}
const crewType = findCrewTypeById(context.unitSet, args.crewType);
if (!crewType) {
return {
effects: [createLogEffect('병종 정보가 없습니다.')],
};
}
const availabilityContext: CrewTypeAvailabilityContext = {
general,
nation: context.nation ?? null,
map: context.map,
cities: context.cities,
};
if (context.currentYear !== undefined) {
availabilityContext.currentYear = context.currentYear;
}
if (context.startYear !== undefined) {
availabilityContext.startYear = context.startYear;
}
if (!isCrewTypeAvailable(context.unitSet, crewType.id, availabilityContext)) {
return {
effects: [createLogEffect('현재 선택할 수 없는 병종입니다.')],
};
}
const plan = this.command.getCost(
context,
crewType.id,
args.amount,
crewType
);
const setTrain = this.command.getTrain(context, crewType);
const setAtmos = this.command.getAtmos(context, crewType);
const appliedCrew = plan.applied;
const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET;
const recruitPop = this.command.getRecruitPopulation(
context,
appliedCrew
);
const nextPopulation = Math.max(city.population - recruitPop, 0);
const baseTrust = readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST);
const trustLoss =
city.population > 0
? (recruitPop / city.population) / costOffset * 100
: 0;
const nextTrust = Math.max(baseTrust - trustLoss, 0);
let nextCrewTypeId = general.crewTypeId;
let nextCrew = general.crew;
let nextTrain = general.train;
let nextAtmos = general.atmos;
let logMessage = '';
const crewLabel = `${crewType.name} ${appliedCrew}`;
if (crewType.id === general.crewTypeId && general.crew > 0) {
nextCrew = general.crew + appliedCrew;
nextTrain = Math.round(
(general.crew * general.train + appliedCrew * setTrain) /
(general.crew + appliedCrew)
);
nextAtmos = Math.round(
(general.crew * general.atmos + appliedCrew * setAtmos) /
(general.crew + appliedCrew)
);
logMessage = `${crewLabel} 추가 ${ACTION_NAME}했습니다.`;
} else {
nextCrewTypeId = crewType.id;
nextCrew = appliedCrew;
nextTrain = Math.round(setTrain);
nextAtmos = Math.round(setAtmos);
logMessage = `${crewLabel} ${ACTION_NAME}했습니다.`;
}
const nextGold = Math.max(0, general.gold - plan.gold);
const nextRice = Math.max(0, general.rice - plan.rice);
const expGain = Math.round(appliedCrew / 100);
const dedGain = Math.round(appliedCrew / 100);
const metaUpdated = addMetaNumber(general.meta, 'leadership_exp', 1);
const effects: Array<GeneralActionEffect<TriggerState>> = [
createCityPatchEffect({
population: nextPopulation,
meta: {
trust: nextTrust,
},
}),
createGeneralPatchEffect({
crewTypeId: nextCrewTypeId,
crew: nextCrew,
train: nextTrain,
atmos: nextAtmos,
gold: nextGold,
rice: nextRice,
experience: general.experience + expGain,
dedication: general.dedication + dedGain,
meta: metaUpdated,
}),
createLogEffect(logMessage),
];
return { effects };
}
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, RecruitArgs, RecruitResolveContext<TriggerState>> {
public readonly key = 'che_징병';
public readonly name = ACTION_NAME;
private readonly command: CommandResolver<TriggerState>;
private readonly resolver: ActionResolver<TriggerState>;
private readonly env: RecruitEnvironment;
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
env: RecruitEnvironment
) {
this.command = new CommandResolver(modules, env);
this.resolver = new ActionResolver(modules, env);
this.env = env;
}
parseArgs(raw: unknown): RecruitArgs | null {
const data = isRecord(raw) ? raw : {};
const crewTypeId = resolveCrewTypeId(data);
const amount = resolveCrewAmount(data);
if (crewTypeId === null || amount === null) {
return null;
}
return { crewType: crewTypeId, amount };
}
buildConstraints(
ctx: ConstraintContext,
_args: RecruitArgs
): Constraint[] {
const requirements: RequirementKey[] = [
{ kind: 'arg', key: 'crewType' },
{ kind: 'arg', key: 'amount' },
];
if (ctx.cityId !== undefined) {
requirements.push({ kind: 'city', id: ctx.cityId });
}
if (ctx.nationId !== undefined) {
requirements.push({ kind: 'nation', id: ctx.nationId });
}
const minPopBase = this.env.minAvailableRecruitPop ?? DEFAULT_MIN_POP;
const resolveRequestedCrew = (context: ConstraintContext): number => {
const amount = resolveCrewAmount(context.args);
return clamp(amount ?? MIN_CREW, MIN_CREW, null);
};
const getCost = (context: ConstraintContext, view: StateView): number => {
const crewTypeId = resolveCrewTypeId(context.args);
if (crewTypeId === null) {
return 0;
}
const calcContext = buildCalcContext<TriggerState>(context, view);
if (!calcContext) {
return 0;
}
const unitSet = context.env.unitSet as UnitSetDefinition | undefined;
const crewType = unitSet ? findCrewTypeById(unitSet, crewTypeId) : null;
return this.command.getCost(
calcContext,
crewTypeId,
resolveCrewAmount(context.args) ?? 0,
crewType ?? undefined
).gold;
};
const getRice = (context: ConstraintContext, view: StateView): number => {
const crewTypeId = resolveCrewTypeId(context.args);
if (crewTypeId === null) {
return 0;
}
const calcContext = buildCalcContext<TriggerState>(context, view);
if (!calcContext) {
return 0;
}
const unitSet = context.env.unitSet as UnitSetDefinition | undefined;
const crewType = unitSet ? findCrewTypeById(unitSet, crewTypeId) : null;
return this.command.getCost(
calcContext,
crewTypeId,
resolveCrewAmount(context.args) ?? 0,
crewType ?? undefined
).rice;
};
const checkCrewTypeAvailable = (): Constraint => ({
name: 'AvailableRecruitCrewType',
requires: () => requirements,
test: (context, view) => {
const crewTypeId = resolveCrewTypeId(context.args);
if (crewTypeId === null) {
return { kind: 'deny', reason: '병종 정보가 없습니다.' };
}
const unitSet = context.env.unitSet as UnitSetDefinition | undefined;
if (!unitSet) {
return { kind: 'deny', reason: '병종 정보가 없습니다.' };
}
const availabilityContext = buildCrewTypeContext(context, view);
if (!availabilityContext) {
return { kind: 'deny', reason: '병종 정보가 없습니다.' };
}
if (
isCrewTypeAvailable(unitSet, crewTypeId, availabilityContext)
) {
return { kind: 'allow' };
}
return { kind: 'deny', reason: '현재 선택할 수 없는 병종입니다.' };
},
});
const constraints: Constraint[] = [
notBeNeutral(),
occupiedCity(),
reqCityCapacity(
'population',
'주민',
minPopBase + resolveRequestedCrew(ctx)
),
reqCityTrust(20),
reqGeneralGold(getCost, requirements),
reqGeneralRice(getRice, requirements),
reqGeneralCrewMargin(
(context) => resolveCrewTypeId(context.args),
requirements
),
];
if (ctx.mode === 'full') {
constraints.push(checkCrewTypeAvailable());
}
return constraints;
}
resolve(
context: RecruitResolveContext<TriggerState>,
args: RecruitArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
@@ -3,12 +3,14 @@ export type GeneralTurnCommandKey =
| 'che_화계'
| 'che_인재탐색'
| 'che_의병모집'
| 'che_징병'
| '휴식';
import type * as CommerceInvestmentModule from './che_상업투자.js';
import type * as FireAttackModule from './che_화계.js';
import type * as TalentScoutModule from './che_인재탐색.js';
import type * as VolunteerRecruitModule from './che_의병모집.js';
import type * as RecruitModule from './che_징병.js';
import type * as RestModule from './휴식.js';
export type GeneralTurnCommandModule =
@@ -16,6 +18,7 @@ export type GeneralTurnCommandModule =
| typeof FireAttackModule
| typeof TalentScoutModule
| typeof VolunteerRecruitModule
| typeof RecruitModule
| typeof RestModule;
export type GeneralTurnCommandImporter = () => Promise<GeneralTurnCommandModule>;
@@ -28,6 +31,7 @@ const defaultImporters: Record<
che_화계: async () => import('./che_화계.js'),
che_인재탐색: async () => import('./che_인재탐색.js'),
che_의병모집: async () => import('./che_의병모집.js'),
che_징병: async () => import('./che_징병.js'),
휴식: async () => import('./휴식.js'),
};
@@ -70,6 +74,11 @@ export {
ActionResolver as VolunteerRecruitActionResolver,
CommandResolver as VolunteerRecruitCommandResolver,
} from './che_의병모집.js';
export {
ActionDefinition as RecruitActionDefinition,
ActionResolver as RecruitActionResolver,
CommandResolver as RecruitCommandResolver,
} from './che_징병.js';
export {
ActionDefinition as RestActionDefinition,
ActionResolver as RestActionResolver,
@@ -22,6 +22,7 @@ export interface GeneralRecruitmentInput<
experience: number;
dedication: number;
crewTypeId: number;
atmos?: number;
role: {
personality: string | null;
specialDomestic: string | null;
@@ -75,6 +76,7 @@ export const buildRecruitmentGeneral = <
crew: 0,
crewTypeId: input.crewTypeId,
train: 0,
atmos: input.atmos ?? 0,
age: input.age,
npcState: input.npcState,
triggerState: