From d599d94a7bea544c366ac9cdbecc3d755cd89135 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Tue, 30 Dec 2025 09:45:51 +0000 Subject: [PATCH] feat: add atmos property to General entity and update related logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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`. --- app/game-api/src/context.ts | 5 + app/game-api/src/turns/commandTable.ts | 20 +- .../src/scenario/scenarioSeeder.ts | 8 + app/game-engine/src/scenario/unitSetLoader.ts | 52 ++ app/game-engine/src/turn/databaseHooks.ts | 69 +- .../src/turn/reservedTurnHandler.ts | 41 +- app/game-engine/src/turn/turnDaemon.ts | 2 + app/game-engine/src/turn/worldLoader.ts | 22 +- .../src/actions/turn/general/che_징병.ts | 602 ++++++++++++++++++ .../logic/src/actions/turn/general/index.ts | 9 + .../src/actions/turn/general/recruitment.ts | 2 + packages/logic/src/constraints/presets.ts | 116 ++++ packages/logic/src/domain/entities.ts | 1 + packages/logic/src/world/bootstrap.ts | 1 + packages/logic/src/world/index.ts | 1 + packages/logic/src/world/types.ts | 35 + packages/logic/src/world/unitSet.ts | 468 ++++++++++++++ packages/logic/test/crewType.test.ts | 242 +++++++ 18 files changed, 1665 insertions(+), 31 deletions(-) create mode 100644 app/game-engine/src/scenario/unitSetLoader.ts create mode 100644 packages/logic/src/actions/turn/general/che_징병.ts create mode 100644 packages/logic/src/world/unitSet.ts create mode 100644 packages/logic/test/crewType.test.ts diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 966528d..2b89be7 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -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; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index fcf7312..3475182 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -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, { diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index fb02915..87d4306 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -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, diff --git a/app/game-engine/src/scenario/unitSetLoader.ts b/app/game-engine/src/scenario/unitSetLoader.ts new file mode 100644 index 0000000..a039a65 --- /dev/null +++ b/app/game-engine/src/scenario/unitSetLoader.ts @@ -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 => { + 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 => { + const raw = await readJsonFile(unitSetPath); + return parseUnitSetDefinition(raw); +}; + +export const loadUnitSetDefinitionByName = async ( + unitSetName: string, + options?: UnitSetLoaderOptions +): Promise => { + const unitSetPath = resolveUnitSetDefinitionPath(unitSetName, options); + return loadUnitSetDefinition(unitSetPath); +}; + diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index b89145e..fbc06e3 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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, + key: string +): number | null => { + const value = meta[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +}; + const buildGeneralUpdate = ( general: ReturnType['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['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; + 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['nations'][number] diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 12078d0..c5fe92e 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -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; 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 }>, @@ -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, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index faf8e8f..93e67c9 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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, diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 96a75e2..28307d2 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -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; @@ -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, 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..41a200f --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -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 { + 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 => + 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; + const trust = meta?.trust; + return typeof trust === 'number' ? trust : fallback; +}; + +const addMetaNumber = ( + meta: Record, + key: string, + delta: number +): Record => { + const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0; + return { ...meta, [key]: current + delta }; +}; + +const resolveCrewTypeId = (args: Record): 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): 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; + city?: City; + nation?: Nation | null; +}; + +const buildCalcContext = ( + ctx: ConstraintContext, + view: StateView +): RecruitCalcContext | 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 city = + ctx.cityId !== undefined + ? ((view.get({ kind: 'city', id: ctx.cityId }) as City | null) ?? + undefined) + : undefined; + const result: RecruitCalcContext = { 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; + private readonly env: RecruitEnvironment; + + constructor( + modules: Array | null | undefined>, + env: RecruitEnvironment + ) { + this.pipeline = new GeneralActionPipeline(modules); + this.env = env; + } + + resolveLeadership(context: RecruitCalcContext): number { + const base = context.general.stats.leadership; + return Math.round( + this.pipeline.onCalcStat(context, 'leadership', base) + ); + } + + resolveCrewPlan( + context: RecruitCalcContext, + 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, + 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, + 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, + 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, + 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; + + constructor( + modules: Array | null | undefined>, + env: RecruitEnvironment + ) { + this.env = env; + this.command = new CommandResolver(modules, env); + } + + resolve( + context: RecruitResolveContext, + args: RecruitArgs + ): GeneralActionOutcome { + 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> = [ + 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> { + public readonly key = 'che_징병'; + public readonly name = ACTION_NAME; + private readonly command: CommandResolver; + private readonly resolver: ActionResolver; + private readonly env: RecruitEnvironment; + + constructor( + modules: Array | 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(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(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, + args: RecruitArgs + ): 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 25eef47..9e4974d 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -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; @@ -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, diff --git a/packages/logic/src/actions/turn/general/recruitment.ts b/packages/logic/src/actions/turn/general/recruitment.ts index bac478a..dc0e648 100644 --- a/packages/logic/src/actions/turn/general/recruitment.ts +++ b/packages/logic/src/actions/turn/general/recruitment.ts @@ -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: diff --git a/packages/logic/src/constraints/presets.ts b/packages/logic/src/constraints/presets.ts index 2d0aaac..ac1eb29 100644 --- a/packages/logic/src/constraints/presets.ts +++ b/packages/logic/src/constraints/presets.ts @@ -139,6 +139,22 @@ const readDiplomacyState = ( return null; }; +const readMetaNumberFromUnknown = ( + meta: Record, + 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; + 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, + '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) => diff --git a/packages/logic/src/domain/entities.ts b/packages/logic/src/domain/entities.ts index 525b4a2..d3305f2 100644 --- a/packages/logic/src/domain/entities.ts +++ b/packages/logic/src/domain/entities.ts @@ -55,6 +55,7 @@ export interface General; + crewTypes?: CrewTypeDefinition[]; meta?: Record; } +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; + defenceCoef: Record; + info: string[]; + initSkillTrigger: string[] | null; + phaseSkillTrigger: string[] | null; + iActionList: string[] | null; +} + export interface NationSeed { id: number; name: string; diff --git a/packages/logic/src/world/unitSet.ts b/packages/logic/src/world/unitSet.ts new file mode 100644 index 0000000..94e8aa5 --- /dev/null +++ b/packages/logic/src/world/unitSet.ts @@ -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 = { + 하북: 1, + 중원: 2, + 서북: 3, + 서촉: 4, + 남중: 5, + 초: 6, + 오월: 7, + 동이: 8, +}; + +const DEFAULT_MAX_TECH_LEVEL = 12; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const asRecord = (value: unknown): Record => + 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 => { + if (!isRecord(value)) { + return {}; + } + const result: Record = {}; + 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>( + (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 => { + const index = new Map(); + 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 => { + 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 => { + const meta = asRecord(map.meta); + const raw = asRecord(meta.regionMap); + const extracted: Record = {}; + 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; + regionById: Map; +} => { + const nameToId = new Map(); + const regionById = new Map(); + 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(); + 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(); + 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).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).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; +}; diff --git a/packages/logic/test/crewType.test.ts b/packages/logic/test/crewType.test.ts new file mode 100644 index 0000000..bf44a76 --- /dev/null +++ b/packages/logic/test/crewType.test.ts @@ -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); + }); +}); +