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
+5
View File
@@ -44,6 +44,7 @@ export interface GeneralRow {
crew: number;
crewTypeId: number;
train: number;
atmos: number;
age: number;
npcState: number;
meta: unknown;
@@ -79,12 +80,15 @@ export interface CityRow {
commerceMax: number;
security: number;
securityMax: number;
trust: number;
trade: number;
supplyState: number;
frontState: number;
defence: number;
defenceMax: number;
wall: number;
wallMax: number;
region: number;
meta: unknown;
}
@@ -95,6 +99,7 @@ export interface NationRow {
capitalCityId: number | null;
gold: number;
rice: number;
tech: number;
level: number;
typeCode: string;
meta: unknown;
+18 -2
View File
@@ -16,6 +16,7 @@ import {
evaluateConstraints,
FireAttackActionDefinition,
NationRestActionDefinition,
RecruitActionDefinition,
RestActionDefinition,
TalentScoutActionDefinition,
VolunteerRecruitActionDefinition,
@@ -324,6 +325,7 @@ const mapGeneralRow = (row: GeneralRow): General => ({
crew: row.crew,
crewTypeId: row.crewTypeId,
train: row.train,
atmos: row.atmos,
age: row.age,
npcState: row.npcState,
triggerState: {
@@ -354,7 +356,12 @@ const mapCityRow = (row: CityRow): City => ({
defenceMax: row.defenceMax,
wall: row.wall,
wallMax: row.wallMax,
meta: asTriggerRecord(row.meta),
meta: {
...asTriggerRecord(row.meta),
trust: row.trust,
trade: row.trade,
region: row.region,
},
});
const mapNationRow = (row: NationRow): Nation => ({
@@ -368,7 +375,10 @@ const mapNationRow = (row: NationRow): Nation => ({
power: 0,
level: row.level,
typeCode: row.typeCode,
meta: asTriggerRecord(row.meta),
meta: {
...asTriggerRecord(row.meta),
tech: row.tech,
},
});
const buildStateView = (
@@ -465,6 +475,12 @@ const buildEntries = (env: CommandEnv): {
reqArg: false,
args: {},
},
{
category: '내정',
definition: new RecruitActionDefinition(emptyModules, {}),
reqArg: true,
args: {},
},
{
category: '계략',
definition: new FireAttackActionDefinition(emptyModules, {
@@ -11,6 +11,8 @@ import type { MapLoaderOptions } from './mapLoader.js';
import { loadMapDefinitionByName } from './mapLoader.js';
import type { ScenarioLoaderOptions } from './scenarioLoader.js';
import { loadScenarioDefinitionById } from './scenarioLoader.js';
import type { UnitSetLoaderOptions } from './unitSetLoader.js';
import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
const DEFAULT_TICK_SECONDS = 120 * 60;
const DEFAULT_GENERAL_GOLD = 1000;
@@ -21,6 +23,7 @@ export interface ScenarioSeedOptions {
databaseUrl: string;
scenarioOptions?: ScenarioLoaderOptions;
mapOptions?: MapLoaderOptions;
unitSetOptions?: UnitSetLoaderOptions;
resetTables?: boolean;
now?: Date;
tickSeconds?: number;
@@ -97,10 +100,15 @@ export const seedScenarioToDatabase = async (
scenario.config.environment.mapName,
options.mapOptions
);
const unitSet = await loadUnitSetDefinitionByName(
scenario.config.environment.unitSet,
options.unitSetOptions
);
const { seed, warnings } = buildScenarioBootstrap({
scenario,
map,
unitSet,
options: {
includeNeutralNationInSeed:
options.includeNeutralNationInSeed ?? true,
@@ -0,0 +1,52 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_UNIT_SET_ROOT = path.resolve(
__dirname,
'..',
'..',
'resources',
'unitset'
);
export interface UnitSetLoaderOptions {
unitSetRoot?: string;
filePrefix?: string;
}
const readJsonFile = async (filePath: string): Promise<unknown> => {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw) as unknown;
};
const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string =>
options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT;
export const resolveUnitSetDefinitionPath = (
unitSetName: string,
options?: UnitSetLoaderOptions
): string => {
const prefix = options?.filePrefix ?? 'unitset_';
return path.resolve(resolveUnitSetRoot(options), `${prefix}${unitSetName}.json`);
};
export const loadUnitSetDefinition = async (
unitSetPath: string
): Promise<UnitSetDefinition> => {
const raw = await readJsonFile(unitSetPath);
return parseUnitSetDefinition(raw);
};
export const loadUnitSetDefinitionByName = async (
unitSetName: string,
options?: UnitSetLoaderOptions
): Promise<UnitSetDefinition> => {
const unitSetPath = resolveUnitSetDefinitionPath(unitSetName, options);
return loadUnitSetDefinition(unitSetPath);
};
+49 -20
View File
@@ -18,6 +18,14 @@ const asJson = (value: unknown): Prisma.InputJsonValue =>
const toCode = (value: string | null | undefined): string =>
value && value !== 'None' ? value : 'None';
const readMetaNumber = (
meta: Record<string, unknown>,
key: string
): number | null => {
const value = meta[key];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
};
const buildGeneralUpdate = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Prisma.GeneralUpdateInput => ({
@@ -37,6 +45,7 @@ const buildGeneralUpdate = (
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
age: general.age,
npcState: general.npcState,
horseCode: toCode(general.role.items.horse),
@@ -72,6 +81,7 @@ const buildGeneralCreate = (
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
age: general.age,
horseCode: toCode(general.role.items.horse),
weaponCode: toCode(general.role.items.weapon),
@@ -87,26 +97,45 @@ const buildGeneralCreate = (
const buildCityUpdate = (
city: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['cities'][number]
): Prisma.CityUpdateInput => ({
name: city.name,
nationId: city.nationId,
level: city.level,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
supplyState: city.supplyState,
frontState: city.frontState,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
meta: asJson(city.meta),
});
): Prisma.CityUpdateInput => {
const meta = city.meta as Record<string, unknown>;
const trust = readMetaNumber(meta, 'trust');
const trade = readMetaNumber(meta, 'trade');
const region = readMetaNumber(meta, 'region');
const data: Prisma.CityUpdateInput = {
name: city.name,
nationId: city.nationId,
level: city.level,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
supplyState: city.supplyState,
frontState: city.frontState,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
meta: asJson(city.meta),
};
if (trust !== null) {
data.trust = trust;
}
if (trade !== null) {
data.trade = trade;
}
if (region !== null) {
data.region = region;
}
return data;
};
const buildNationUpdate = (
nation: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['nations'][number]
@@ -4,11 +4,13 @@ import type {
GeneralActionDefinition,
GeneralActionResolveContext,
LogEntryDraft,
MapDefinition,
Nation,
ScenarioConfig,
ScenarioDiplomacy,
ScenarioMeta,
Troop,
UnitSetDefinition,
} from '@sammo-ts/logic';
import {
AssignmentActionDefinition,
@@ -17,6 +19,7 @@ import {
evaluateConstraints,
FireAttackActionDefinition,
NationRestActionDefinition,
RecruitActionDefinition,
resolveGeneralAction,
RestActionDefinition,
TalentScoutActionDefinition,
@@ -110,7 +113,10 @@ const resolveOptionalString = (
return null;
};
const buildCommandEnv = (config: ScenarioConfig): CommandEnv => {
const buildCommandEnv = (
config: ScenarioConfig,
unitSet?: UnitSetDefinition
): CommandEnv => {
const constValues = asRecord(config.const);
return {
@@ -167,7 +173,7 @@ const buildCommandEnv = (config: ScenarioConfig): CommandEnv => {
defaultCrewTypeId: resolveNumber(
constValues,
['defaultCrewTypeId'],
DEFAULT_CREW_TYPE_ID
unitSet?.defaultCrewTypeId ?? DEFAULT_CREW_TYPE_ID
),
defaultSpecialDomestic: resolveOptionalString(
constValues,
@@ -422,6 +428,7 @@ const buildGeneralDefinitions = (
definitions.set('che_화계', new FireAttackActionDefinition([], env));
definitions.set('che_인재탐색', new TalentScoutActionDefinition([], env));
definitions.set('che_의병모집', new VolunteerRecruitActionDefinition([], env));
definitions.set('che_징병', new RecruitActionDefinition([], {}));
definitions.set('휴식', new RestActionDefinition());
return definitions;
};
@@ -468,6 +475,8 @@ const buildActionContext = (
options: {
world: TurnWorldState;
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
unitSet?: UnitSetDefinition;
worldRef: InMemoryTurnWorld | null;
actionArgs: Record<string, unknown>;
createGeneralId: () => number;
@@ -535,6 +544,18 @@ const buildActionContext = (
destGeneralTurnTime: destGeneral.turnTime,
};
}
case 'che_징병':
if (!options.map || !options.unitSet) {
return null;
}
return {
...base,
map: options.map,
unitSet: options.unitSet,
cities: options.worldRef?.listCities() ?? [],
currentYear: options.world.currentYear,
startYear: resolveStartYear(options.world, options.scenarioMeta),
};
default:
return base;
}
@@ -573,9 +594,11 @@ export const createReservedTurnHandler = (options: {
scenarioConfig: ScenarioConfig;
scenarioMeta?: ScenarioMeta;
diplomacy: ScenarioDiplomacy[];
map?: MapDefinition;
unitSet?: UnitSetDefinition;
getWorld: () => InMemoryTurnWorld | null;
}): GeneralTurnHandler => {
const env = buildCommandEnv(options.scenarioConfig);
const env = buildCommandEnv(options.scenarioConfig, options.unitSet);
const generalDefinitions = buildGeneralDefinitions(env);
const nationDefinitions = buildNationDefinitions(env);
const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!;
@@ -597,10 +620,12 @@ export const createReservedTurnHandler = (options: {
return {
execute(context): GeneralTurnResult {
const worldRef = options.getWorld();
const constraintEnv = resolveConstraintEnv(
context.world,
options.scenarioMeta
);
const constraintEnv = {
...resolveConstraintEnv(context.world, options.scenarioMeta),
...(options.map ? { map: options.map } : {}),
...(options.unitSet ? { unitSet: options.unitSet } : {}),
cities: worldRef?.listCities() ?? [],
};
const logs: LogEntryDraft[] = [];
const patches = {
generals: [] as Array<{ id: number; patch: Partial<TurnGeneral> }>,
@@ -694,6 +719,8 @@ export const createReservedTurnHandler = (options: {
let specificContext = buildActionContext(actionKey, baseContext, {
world: context.world,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
worldRef,
actionArgs: actionArgsRecord,
createGeneralId,
+2
View File
@@ -88,6 +88,8 @@ export const createTurnDaemonRuntime = async (
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
diplomacy: snapshot.diplomacy,
map: snapshot.map,
unitSet: snapshot.unitSet,
getWorld: () => worldRef,
}),
calendarHandler: options.calendarHandler,
+20 -2
View File
@@ -22,11 +22,14 @@ import { z } from 'zod';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
import type { TurnGeneral, TurnWorldLoadResult } from './types.js';
interface TurnWorldLoaderOptions {
databaseUrl: string;
mapOptions?: MapLoaderOptions;
unitSetOptions?: UnitSetLoaderOptions;
}
type JsonRecord = Record<string, unknown>;
@@ -162,6 +165,7 @@ const mapGeneralRow = (row: PrismaGeneral): TurnGeneral => ({
crew: row.crew,
crewTypeId: row.crewTypeId,
train: row.train,
atmos: row.atmos,
age: row.age,
npcState: row.npcState,
triggerState: {
@@ -194,7 +198,12 @@ const mapCityRow = (row: PrismaCity): City => ({
defenceMax: row.defenceMax,
wall: row.wall,
wallMax: row.wallMax,
meta: asTriggerRecord(row.meta),
meta: {
...asTriggerRecord(row.meta),
trust: row.trust,
trade: row.trade,
region: row.region,
},
});
const mapNationRow = (row: PrismaNation): Nation => ({
@@ -208,7 +217,10 @@ const mapNationRow = (row: PrismaNation): Nation => ({
power: 0,
level: row.level,
typeCode: row.typeCode,
meta: asTriggerRecord(row.meta),
meta: {
...asTriggerRecord(row.meta),
tech: row.tech,
},
});
const mapDiplomacyRow = (row: PrismaDiplomacy): ScenarioDiplomacy => ({
@@ -263,6 +275,11 @@ export const loadTurnWorldFromDatabase = async (
const scenarioConfig = mapScenarioConfig(worldState.config);
const mapName = scenarioConfig.environment?.mapName ?? 'che';
const map = await loadMapDefinitionByName(mapName, options.mapOptions);
const unitSetName = scenarioConfig.environment?.unitSet ?? 'che';
const unitSet = await loadUnitSetDefinitionByName(
unitSetName,
options.unitSetOptions
);
const meta = asRecord(worldState.meta);
const scenarioMeta = parseScenarioMeta(meta);
@@ -313,6 +330,7 @@ export const loadTurnWorldFromDatabase = async (
scenarioConfig,
...(scenarioMeta ? { scenarioMeta } : {}),
map,
unitSet,
nations,
cities,
generals,
@@ -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:
+116
View File
@@ -139,6 +139,22 @@ const readDiplomacyState = (
return null;
};
const readMetaNumberFromUnknown = (
meta: Record<string, unknown>,
key: string
): number | null => {
const value = meta[key];
return typeof value === 'number' ? value : null;
};
const parsePercent = (value: string): number | null => {
const match = /^(\d+(?:\.\d+)?)%$/.exec(value);
if (!match) {
return null;
}
return Number(match[1]) / 100;
};
export const notBeNeutral = (): Constraint => ({
name: 'NotBeNeutral',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
@@ -545,6 +561,106 @@ export const remainCityCapacity = (
},
});
export const reqCityCapacity = (
key: string,
label: string,
required: number | string
): Constraint => ({
name: 'ReqCityCapacity',
requires: (ctx) =>
ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : [],
test: (ctx, view) => {
const city = readCity(view, ctx.cityId);
if (!city) {
if (ctx.cityId === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
}
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
}
const record = city as unknown as Record<string, number | undefined>;
const current = record[key];
if (current === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
}
if (typeof required === 'string') {
const ratio = parsePercent(required);
const maxKey = `${key}Max`;
const max = record[maxKey];
if (ratio === null || max === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
}
if (current >= max * ratio) {
return allow();
}
} else if (current >= required) {
return allow();
}
return { kind: 'deny', reason: `${label}이 부족합니다.` };
},
});
export const reqCityTrust = (minTrust: number): Constraint => ({
name: 'ReqCityTrust',
requires: (ctx) =>
ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : [],
test: (ctx, view) => {
const city = readCity(view, ctx.cityId);
if (!city) {
if (ctx.cityId === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
}
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
}
const trust =
readMetaNumberFromUnknown(
city.meta as Record<string, unknown>,
'trust'
) ?? null;
if (trust === null) {
return unknownOrDeny(ctx, [], '민심 정보가 없습니다.');
}
if (trust >= minTrust) {
return allow();
}
return { kind: 'deny', reason: '민심이 낮아 주민들이 도망갑니다.' };
},
});
export const reqGeneralCrewMargin = (
getCrewTypeId: (ctx: ConstraintContext, view: StateView) => number | null,
requirements: RequirementKey[] = []
): Constraint => ({
name: 'ReqGeneralCrewMargin',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }, ...requirements],
test: (ctx, view) => {
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
const missing = [generalReq, ...requirements].filter(
(req) => !view.has(req)
);
if (missing.length > 0) {
return unknownOrDeny(ctx, missing, '장수 정보가 없습니다.');
}
const general = view.get(generalReq) as General | null;
if (!general) {
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
}
const crewTypeId = getCrewTypeId(ctx, view);
if (crewTypeId === null) {
return unknownOrDeny(ctx, requirements, '병종 정보가 없습니다.');
}
if (crewTypeId !== general.crewTypeId) {
return allow();
}
const maxCrew = general.stats.leadership * 100;
if (maxCrew > general.crew) {
return allow();
}
return { kind: 'deny', reason: '이미 많은 병력을 보유하고 있습니다.' };
},
});
export const existsDestCity = (): Constraint => ({
name: 'ExistsDestCity',
requires: (ctx) =>
+1
View File
@@ -55,6 +55,7 @@ export interface General<TriggerState extends GeneralTriggerState = GeneralTrigg
crew: number;
crewTypeId: number;
train: number;
atmos: number;
age: number;
npcState: number;
triggerState: TriggerState;
+1
View File
@@ -386,6 +386,7 @@ const buildGeneralSeeds = (
crew: 0,
crewTypeId: defaultCrewTypeId,
train: 0,
atmos: 0,
age,
npcState: npcType,
triggerState: createEmptyTriggerState(),
+1
View File
@@ -1,3 +1,4 @@
export * from './types.js';
export * from './bootstrap.js';
export * from './loader.js';
export * from './unitSet.js';
+35
View File
@@ -62,9 +62,44 @@ export interface UnitSetDefinition {
id: string;
name: string;
defaultCrewTypeId?: number;
armTypes?: Record<string, string>;
crewTypes?: CrewTypeDefinition[];
meta?: Record<string, unknown>;
}
export type CrewTypeRequirement =
| { type: 'ReqTech'; tech: number }
| { type: 'ReqRegions'; regions: string[] }
| { type: 'ReqCities'; cities: string[] }
| { type: 'ReqCitiesWithCityLevel'; level: number; cities: string[] }
| { type: 'ReqHighLevelCities'; level: number; count: number }
| { type: 'ReqNationAux'; key: string; op: string; value: number | string }
| { type: 'ReqMinRelYear'; year: number }
| { type: 'ReqChief' }
| { type: 'ReqNotChief' }
| { type: 'Impossible' }
| { type: string; [key: string]: unknown };
export interface CrewTypeDefinition {
id: number;
armType: number;
name: string;
attack: number;
defence: number;
speed: number;
avoid: number;
magicCoef: number;
cost: number;
rice: number;
requirements: CrewTypeRequirement[];
attackCoef: Record<string, number>;
defenceCoef: Record<string, number>;
info: string[];
initSkillTrigger: string[] | null;
phaseSkillTrigger: string[] | null;
iActionList: string[] | null;
}
export interface NationSeed {
id: number;
name: string;
+468
View File
@@ -0,0 +1,468 @@
import type { City, General, Nation } from '../domain/entities.js';
import type {
CrewTypeDefinition,
CrewTypeRequirement,
MapDefinition,
UnitSetDefinition,
} from './types.js';
const DEFAULT_REGION_MAP: Record<string, number> = {
하북: 1,
중원: 2,
서북: 3,
서촉: 4,
남중: 5,
: 6,
오월: 7,
동이: 8,
};
const DEFAULT_MAX_TECH_LEVEL = 12;
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> =>
isRecord(value) ? value : {};
const asNumber = (value: unknown, fallback: number): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
const asString = (value: unknown, fallback: string): string =>
typeof value === 'string' ? value : fallback;
const asStringArray = (value: unknown): string[] =>
Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
const asNullableStringArray = (value: unknown): string[] | null => {
if (value === null || value === undefined) {
return null;
}
return asStringArray(value);
};
const normalizeCoef = (value: unknown): Record<string, number> => {
if (!isRecord(value)) {
return {};
}
const result: Record<string, number> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === 'number' && Number.isFinite(entry)) {
result[key] = entry;
}
}
return result;
};
const parseRequirement = (value: unknown): CrewTypeRequirement | null => {
if (!isRecord(value)) {
return null;
}
const type = asString(value.type, '');
if (!type) {
return null;
}
switch (type) {
case 'ReqTech':
return { type, tech: asNumber(value.tech, 0) };
case 'ReqRegions':
return { type, regions: asStringArray(value.regions) };
case 'ReqCities':
return { type, cities: asStringArray(value.cities) };
case 'ReqCitiesWithCityLevel':
return {
type,
level: asNumber(value.level, 0),
cities: asStringArray(value.cities),
};
case 'ReqHighLevelCities':
return {
type,
level: asNumber(value.level, 0),
count: asNumber(value.count, 0),
};
case 'ReqNationAux':
return {
type,
key: asString(value.key, ''),
op: asString(value.op, '=='),
value:
typeof value.value === 'number' ||
typeof value.value === 'string'
? value.value
: 0,
};
case 'ReqMinRelYear':
return { type, year: asNumber(value.year, 0) };
case 'ReqChief':
case 'ReqNotChief':
case 'Impossible':
return { type };
default:
return { type, ...value };
}
};
const parseCrewType = (value: unknown): CrewTypeDefinition | null => {
if (!isRecord(value)) {
return null;
}
const id = asNumber(value.id, 0);
if (id <= 0) {
return null;
}
const requirements = Array.isArray(value.requirements)
? value.requirements
.map(parseRequirement)
.filter((entry): entry is CrewTypeRequirement => entry !== null)
: [];
return {
id,
armType: asNumber(value.armType, 0),
name: asString(value.name, ''),
attack: asNumber(value.attack, 0),
defence: asNumber(value.defence, 0),
speed: asNumber(value.speed, 0),
avoid: asNumber(value.avoid, 0),
magicCoef: asNumber(value.magicCoef, 0),
cost: asNumber(value.cost, 0),
rice: asNumber(value.rice, 0),
requirements,
attackCoef: normalizeCoef(value.attackCoef),
defenceCoef: normalizeCoef(value.defenceCoef),
info: asStringArray(value.info),
initSkillTrigger: asNullableStringArray(value.initSkillTrigger),
phaseSkillTrigger: asNullableStringArray(value.phaseSkillTrigger),
iActionList: asNullableStringArray(value.iActionList),
};
};
export const parseUnitSetDefinition = (value: unknown): UnitSetDefinition => {
const data = asRecord(value);
const id = asString(data.id, 'unknown');
const name = asString(data.name, id);
const defaultCrewTypeId =
typeof data.defaultCrewTypeId === 'number' && Number.isFinite(data.defaultCrewTypeId)
? data.defaultCrewTypeId
: null;
const armTypes = isRecord(data.armTypes)
? Object.entries(data.armTypes).reduce<Record<string, string>>(
(acc, [key, entry]) => {
if (typeof entry === 'string') {
acc[key] = entry;
}
return acc;
},
{}
)
: undefined;
const crewTypes = Array.isArray(data.crewTypes)
? data.crewTypes
.map(parseCrewType)
.filter((entry): entry is CrewTypeDefinition => entry !== null)
: [];
const result: UnitSetDefinition = {
id,
name,
crewTypes,
};
if (defaultCrewTypeId !== null) {
result.defaultCrewTypeId = defaultCrewTypeId;
}
if (armTypes) {
result.armTypes = armTypes;
}
if (isRecord(data.meta)) {
result.meta = data.meta;
}
return result;
};
export const buildCrewTypeIndex = (
unitSet: UnitSetDefinition | null | undefined
): Map<number, CrewTypeDefinition> => {
const index = new Map<number, CrewTypeDefinition>();
for (const crewType of unitSet?.crewTypes ?? []) {
index.set(crewType.id, crewType);
}
return index;
};
export const findCrewTypeById = (
unitSet: UnitSetDefinition | null | undefined,
crewTypeId: number
): CrewTypeDefinition | null => {
if (!unitSet?.crewTypes) {
return null;
}
return unitSet.crewTypes.find((crewType) => crewType.id === crewTypeId) ?? null;
};
export const getTechLevel = (
tech: number,
maxLevel = DEFAULT_MAX_TECH_LEVEL
): number => {
if (!Number.isFinite(tech)) {
return 0;
}
const level = Math.floor(tech / 1000);
return Math.max(0, Math.min(level, maxLevel));
};
export const getTechAbility = (tech: number): number => getTechLevel(tech) * 25;
export const getTechCost = (tech: number): number => 1 + getTechLevel(tech) * 0.15;
export interface CrewTypeAvailabilityContext {
general: General;
nation: Nation | null;
map: MapDefinition;
cities: City[];
currentYear?: number;
startYear?: number;
}
const resolveNationTech = (nation: Nation | null): number => {
if (!nation) {
return 0;
}
const tech = nation.meta.tech;
return typeof tech === 'number' ? tech : 0;
};
const resolveNationAux = (nation: Nation | null): Record<string, unknown> => {
if (!nation) {
return {};
}
const meta = asRecord(nation.meta);
const aux = asRecord(meta.aux);
return Object.keys(aux).length > 0 ? aux : meta;
};
const resolveRelativeYear = (context: CrewTypeAvailabilityContext): number => {
const { currentYear, startYear } = context;
if (typeof currentYear === 'number' && typeof startYear === 'number') {
return Math.max(currentYear - startYear, 0);
}
return 0;
};
const resolveRegionMap = (map: MapDefinition): Record<string, number> => {
const meta = asRecord(map.meta);
const raw = asRecord(meta.regionMap);
const extracted: Record<string, number> = {};
for (const [key, value] of Object.entries(raw)) {
if (typeof value === 'number' && Number.isFinite(value)) {
extracted[key] = value;
}
}
return Object.keys(extracted).length > 0 ? extracted : DEFAULT_REGION_MAP;
};
const buildCityIndex = (map: MapDefinition): {
nameToId: Map<string, number>;
regionById: Map<number, number>;
} => {
const nameToId = new Map<string, number>();
const regionById = new Map<number, number>();
for (const city of map.cities) {
nameToId.set(city.name, city.id);
regionById.set(city.id, city.region);
}
return { nameToId, regionById };
};
const compareAuxValue = (
actual: unknown,
op: string,
expected: number | string
): boolean => {
const actualNum =
typeof actual === 'number'
? actual
: typeof actual === 'string'
? Number(actual)
: Number.NaN;
const expectedNum =
typeof expected === 'number'
? expected
: typeof expected === 'string'
? Number(expected)
: Number.NaN;
if (!Number.isNaN(actualNum) && !Number.isNaN(expectedNum)) {
switch (op) {
case '==':
return actualNum === expectedNum;
case '!=':
return actualNum !== expectedNum;
case '>=':
return actualNum >= expectedNum;
case '<=':
return actualNum <= expectedNum;
case '>':
return actualNum > expectedNum;
case '<':
return actualNum < expectedNum;
default:
return actualNum === expectedNum;
}
}
if (op === '!=' || op === '==') {
return op === '!='
? String(actual ?? '') !== String(expected)
: String(actual ?? '') === String(expected);
}
return false;
};
// 병종 요구 조건을 평가해 현재 상황에서 선택 가능한지 계산한다.
export const isCrewTypeAvailable = (
unitSet: UnitSetDefinition,
crewTypeId: number,
context: CrewTypeAvailabilityContext
): boolean => {
const crewType = findCrewTypeById(unitSet, crewTypeId);
if (!crewType) {
return false;
}
const nationId = context.nation?.id ?? context.general.nationId;
const ownedCities =
nationId !== undefined
? context.cities.filter((city) => city.nationId === nationId)
: [];
const ownedCityMap = new Map<number, City>();
for (const city of ownedCities) {
ownedCityMap.set(city.id, city);
}
const { nameToId, regionById } = buildCityIndex(context.map);
const regionMap = resolveRegionMap(context.map);
const ownedRegions = new Set<number>();
for (const city of ownedCities) {
const region = regionById.get(city.id);
if (region !== undefined) {
ownedRegions.add(region);
}
}
const tech = resolveNationTech(context.nation ?? null);
const relYear = resolveRelativeYear(context);
const aux = resolveNationAux(context.nation ?? null);
for (const requirement of crewType.requirements) {
switch (requirement.type) {
case 'ReqTech':
if (
tech <
(requirement as Extract<CrewTypeRequirement, { type: 'ReqTech' }>).tech
) {
return false;
}
break;
case 'ReqRegions': {
const req =
requirement as Extract<
CrewTypeRequirement,
{ type: 'ReqRegions' }
>;
const resolved = req.regions
.map((name: string) => regionMap[name])
.filter((id): id is number => typeof id === 'number');
if (!resolved.some((id) => ownedRegions.has(id))) {
return false;
}
break;
}
case 'ReqCities': {
const req =
requirement as Extract<
CrewTypeRequirement,
{ type: 'ReqCities' }
>;
const resolved = req.cities
.map((name: string) => nameToId.get(name))
.filter((id): id is number => typeof id === 'number');
if (!resolved.some((id) => ownedCityMap.has(id))) {
return false;
}
break;
}
case 'ReqCitiesWithCityLevel': {
const req =
requirement as Extract<
CrewTypeRequirement,
{ type: 'ReqCitiesWithCityLevel' }
>;
const resolved = req.cities
.map((name: string) => nameToId.get(name))
.filter((id): id is number => typeof id === 'number');
if (
!resolved.some((id) => {
const city = ownedCityMap.get(id);
return city ? city.level >= req.level : false;
})
) {
return false;
}
break;
}
case 'ReqHighLevelCities': {
const req =
requirement as Extract<
CrewTypeRequirement,
{ type: 'ReqHighLevelCities' }
>;
const count = ownedCities.filter(
(city) => city.level >= req.level
).length;
if (count < req.count) {
return false;
}
break;
}
case 'ReqNationAux': {
const req =
requirement as Extract<
CrewTypeRequirement,
{ type: 'ReqNationAux' }
>;
const value = aux[req.key];
if (!compareAuxValue(value, req.op, req.value)) {
return false;
}
break;
}
case 'ReqMinRelYear':
if (
relYear <
(requirement as Extract<CrewTypeRequirement, { type: 'ReqMinRelYear' }>).year
) {
return false;
}
break;
case 'ReqChief':
if (context.general.officerLevel <= 4) {
return false;
}
break;
case 'ReqNotChief':
if (context.general.officerLevel > 4) {
return false;
}
break;
case 'Impossible':
return false;
default:
return false;
}
}
return true;
};
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '../src/domain/entities.js';
import type { MapDefinition } from '../src/world/types.js';
import {
isCrewTypeAvailable,
parseUnitSetDefinition,
} from '../src/world/unitSet.js';
const buildMap = (): MapDefinition => ({
id: 'test',
name: 'test',
cities: [
{
id: 1,
name: 'A',
level: 6,
region: 1,
position: { x: 0, y: 0 },
connections: [],
max: {
population: 100000,
agriculture: 1000,
commerce: 1000,
security: 1000,
defence: 1000,
wall: 1000,
},
initial: {
population: 50000,
agriculture: 500,
commerce: 500,
security: 500,
defence: 500,
wall: 500,
},
},
],
meta: {
regionMap: {
A: 1,
},
},
});
const buildNation = (): Nation => ({
id: 1,
name: 'TestNation',
color: '#000000',
capitalCityId: 1,
chiefGeneralId: null,
gold: 1000,
rice: 1000,
power: 0,
level: 1,
typeCode: 'test',
meta: {
tech: 3000,
},
});
const buildGeneral = (): General => ({
id: 1,
name: 'TestGeneral',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
experience: 0,
dedication: 0,
officerLevel: 3,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: {
horse: null,
weapon: null,
book: null,
item: null,
},
},
injury: 0,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 1100,
train: 0,
atmos: 0,
age: 20,
npcState: 0,
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: {},
},
meta: {},
});
const buildCities = (): City[] => [
{
id: 1,
name: 'A',
nationId: 1,
level: 6,
population: 50000,
populationMax: 100000,
agriculture: 500,
agricultureMax: 1000,
commerce: 500,
commerceMax: 1000,
security: 500,
securityMax: 1000,
supplyState: 1,
frontState: 0,
defence: 500,
defenceMax: 1000,
wall: 500,
wallMax: 1000,
meta: {},
},
];
describe('crew type availability', () => {
it('parses unit set data and normalizes coefficients', () => {
const parsed = parseUnitSetDefinition({
id: 'test',
name: 'test',
defaultCrewTypeId: 1100,
crewTypes: [
{
id: 1100,
armType: 1,
name: '보병',
attack: 100,
defence: 100,
speed: 7,
avoid: 10,
magicCoef: 0,
cost: 9,
rice: 9,
requirements: [],
attackCoef: [],
defenceCoef: { 1: 1.2 },
info: ['테스트'],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
],
});
expect(parsed.defaultCrewTypeId).toBe(1100);
expect(parsed.crewTypes?.[0]?.attackCoef).toEqual({});
});
it('checks tech/region/city requirements', () => {
const unitSet = parseUnitSetDefinition({
id: 'test',
name: 'test',
crewTypes: [
{
id: 1100,
armType: 1,
name: '기본',
attack: 100,
defence: 100,
speed: 7,
avoid: 10,
magicCoef: 0,
cost: 9,
rice: 9,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
{
id: 1200,
armType: 1,
name: '기술병',
attack: 100,
defence: 100,
speed: 7,
avoid: 10,
magicCoef: 0,
cost: 9,
rice: 9,
requirements: [
{ type: 'ReqTech', tech: 2000 },
{ type: 'ReqRegions', regions: ['A'] },
],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
{
id: 1300,
armType: 1,
name: '도시병',
attack: 100,
defence: 100,
speed: 7,
avoid: 10,
magicCoef: 0,
cost: 9,
rice: 9,
requirements: [
{ type: 'ReqCitiesWithCityLevel', level: 7, cities: ['A'] },
],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
],
});
const context = {
general: buildGeneral(),
nation: buildNation(),
map: buildMap(),
cities: buildCities(),
currentYear: 10,
startYear: 1,
};
expect(isCrewTypeAvailable(unitSet, 1100, context)).toBe(true);
expect(isCrewTypeAvailable(unitSet, 1200, context)).toBe(true);
expect(isCrewTypeAvailable(unitSet, 1300, context)).toBe(false);
});
});