diff --git a/app/game-engine/src/turn/reservedTurnActionContext.ts b/app/game-engine/src/turn/reservedTurnActionContext.ts index f7a1657..ec0fa04 100644 --- a/app/game-engine/src/turn/reservedTurnActionContext.ts +++ b/app/game-engine/src/turn/reservedTurnActionContext.ts @@ -3,7 +3,11 @@ import type { General, MapDefinition, Nation, + ScenarioConfig, ScenarioMeta, + WarAftermathConfig, + WarEngineConfig, + WarTimeContext, UnitSetDefinition, } from '@sammo-ts/logic'; @@ -39,12 +43,14 @@ export type ActionResolveContext = ActionContextBase & Record; export interface ActionContextOptions { world: TurnWorldState; + scenarioConfig: ScenarioConfig; scenarioMeta?: ScenarioMeta; map?: MapDefinition; unitSet?: UnitSetDefinition; worldRef: InMemoryTurnWorld | null; actionArgs: Record; createGeneralId: () => number; + seedBase: string; } type ActionContextBuilder = ( @@ -141,6 +147,166 @@ const resolveStartYear = ( const resolveTurnTermMinutes = (world: TurnWorldState): number => Math.max(1, Math.round(world.tickSeconds / 60)); +const DEFAULT_WAR_CONFIG = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, +}; + +const DEFAULT_AFTER_CONFIG = { + techLevelIncYear: 5, + initialAllowedTechLevel: 1, + defaultCityWall: 1000, +}; + +const asRecord = (value: unknown): Record => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + +const resolveNumber = ( + record: Record, + keys: string[], + fallback: number +): number => { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return fallback; +}; + +const resolveCastleCrewTypeId = ( + unitSet: UnitSetDefinition, + fallback: number +): number => { + const crewTypes = unitSet.crewTypes ?? []; + const byName = crewTypes.find((crewType) => crewType.name.includes('성벽')); + if (byName) { + return byName.id; + } + const byRequirement = crewTypes.find((crewType) => + crewType.requirements.some( + (requirement) => requirement.type === 'Impossible' + ) + ); + if (byRequirement) { + return byRequirement.id; + } + if (typeof unitSet.defaultCrewTypeId === 'number') { + return unitSet.defaultCrewTypeId; + } + return crewTypes[0]?.id ?? fallback; +}; + +const resolveCastleArmType = ( + unitSet: UnitSetDefinition, + castleCrewTypeId: number +): number => { + const crewTypes = unitSet.crewTypes ?? []; + return ( + crewTypes.find((crewType) => crewType.id === castleCrewTypeId)?.armType ?? + 0 + ); +}; + +const buildWarConfig = ( + scenarioConfig: ScenarioConfig, + unitSet: UnitSetDefinition +): WarEngineConfig => { + const constValues = asRecord(scenarioConfig.const); + const castleCrewTypeId = resolveNumber( + constValues, + ['castleCrewTypeId'], + resolveCastleCrewTypeId(unitSet, 0) + ); + const castleArmType = resolveCastleArmType(unitSet, castleCrewTypeId); + + return { + armPerPhase: resolveNumber( + constValues, + ['armPerPhase', 'armperphase'], + DEFAULT_WAR_CONFIG.armPerPhase + ), + maxTrainByCommand: resolveNumber( + constValues, + ['maxTrainByCommand'], + DEFAULT_WAR_CONFIG.maxTrainByCommand + ), + maxAtmosByCommand: resolveNumber( + constValues, + ['maxAtmosByCommand'], + DEFAULT_WAR_CONFIG.maxAtmosByCommand + ), + maxTrainByWar: resolveNumber( + constValues, + ['maxTrainByWar'], + DEFAULT_WAR_CONFIG.maxTrainByWar + ), + maxAtmosByWar: resolveNumber( + constValues, + ['maxAtmosByWar'], + DEFAULT_WAR_CONFIG.maxAtmosByWar + ), + castleCrewTypeId, + armTypes: { + footman: 1, + archer: 2, + cavalry: 3, + wizard: 4, + siege: 5, + misc: 6, + castle: castleArmType, + }, + }; +}; + +const buildWarAftermathConfig = ( + scenarioConfig: ScenarioConfig, + castleCrewTypeId: number +): WarAftermathConfig => { + const constValues = asRecord(scenarioConfig.const); + return { + initialNationGenLimit: resolveNumber( + constValues, + ['initialNationGenLimit'], + 0 + ), + techLevelIncYear: resolveNumber( + constValues, + ['techLevelIncYear'], + DEFAULT_AFTER_CONFIG.techLevelIncYear + ), + initialAllowedTechLevel: resolveNumber( + constValues, + ['initialAllowedTechLevel'], + DEFAULT_AFTER_CONFIG.initialAllowedTechLevel + ), + maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0), + defaultCityWall: resolveNumber( + constValues, + ['defaultCityWall'], + DEFAULT_AFTER_CONFIG.defaultCityWall + ), + baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0), + baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), + castleCrewTypeId, + }; +}; + +const buildWarTime = ( + world: TurnWorldState, + scenarioMeta?: ScenarioMeta +): WarTimeContext => ({ + year: world.currentYear, + month: world.currentMonth, + startYear: resolveStartYear(world, scenarioMeta), +}); + // 커맨드별로 필요한 컨텍스트 확장 데이터를 구성한다. const ACTION_CONTEXT_BUILDERS: Record = { che_인재탐색: (base, options) => ({ @@ -221,6 +387,41 @@ const ACTION_CONTEXT_BUILDERS: Record = { currentYear: options.world.currentYear, currentMonth: options.world.currentMonth, }), + che_출병: (base, options) => { + if (!options.unitSet || !options.worldRef) { + return null; + } + const destCityId = options.actionArgs.destCityId; + if (typeof destCityId !== 'number') { + return null; + } + const destCity = options.worldRef.getCityById(destCityId); + if (!destCity) { + return null; + } + const destNation = + destCity.nationId > 0 + ? options.worldRef.getNationById(destCity.nationId) + : null; + const warConfig = buildWarConfig(options.scenarioConfig, options.unitSet); + const aftermathConfig = buildWarAftermathConfig( + options.scenarioConfig, + warConfig.castleCrewTypeId + ); + return { + ...base, + destCity, + destNation, + cities: options.worldRef.listCities(), + nations: options.worldRef.listNations(), + generals: options.worldRef.listGenerals(), + unitSet: options.unitSet, + time: buildWarTime(options.world, options.scenarioMeta), + seedBase: options.seedBase, + warConfig, + aftermathConfig, + }; + }, }; export const buildActionContext = ( diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 30ec3c6..3a29c63 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -329,12 +329,14 @@ export const createReservedTurnHandler = async (options: { }; let specificContext = buildActionContext(actionKey, baseContext, { world: context.world, + scenarioConfig: options.scenarioConfig, scenarioMeta: options.scenarioMeta, map: options.map, unitSet: options.unitSet, worldRef, actionArgs: actionArgsRecord, createGeneralId, + seedBase, }); if (!specificContext && actionKey !== fallbackDefinition.key) { definition = fallbackDefinition; diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index f93eba3..2332687 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -1,4 +1,9 @@ -import type { GeneralTriggerState } from '../../../domain/entities.js'; +import type { + City, + General, + GeneralTriggerState, + Nation, +} from '../../../domain/entities.js'; import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; import { existsDestCity, @@ -9,17 +14,47 @@ import { } from '../../../constraints/presets.js'; import type { GeneralActionDefinition } from '../../definition.js'; import type { + GeneralActionEffect, GeneralActionOutcome, GeneralActionResolveContext, } from '../../engine.js'; -import { LogCategory, LogFormat } from '../../../logging/types.js'; +import { + createCityPatchEffect, + createDiplomacyPatchEffect, + createGeneralPatchEffect, + createNationPatchEffect, +} from '../../engine.js'; import type { TurnCommandEnv } from '../commandEnv.js'; import type { GeneralTurnCommandSpec } from './index.js'; +import type { + WarAftermathConfig, + WarEngineConfig, + WarTimeContext, +} from '../../../war/types.js'; +import { resolveWarAftermath } from '../../../war/aftermath.js'; +import { resolveWarBattle } from '../../../war/engine.js'; +import { simpleSerialize } from '../../../war/utils.js'; +import type { UnitSetDefinition } from '../../../world/types.js'; export interface DispatchArgs { destCityId: number; } +export interface DispatchResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + destCity: City; + destNation?: Nation | null; + cities: City[]; + nations: Nation[]; + generals: General[]; + unitSet: UnitSetDefinition; + time: WarTimeContext; + seedBase: string; + warConfig: WarEngineConfig; + aftermathConfig: WarAftermathConfig; +} + const ACTION_NAME = '출병'; const parseCityId = (raw: unknown): number | null => { @@ -29,9 +64,45 @@ const parseCityId = (raw: unknown): number | null => { return raw > 0 ? Math.floor(raw) : null; }; +const cloneGeneral = ( + general: General +): General => ({ + ...general, + stats: { ...general.stats }, + role: { + ...general.role, + items: { + ...general.role.items, + }, + }, + triggerState: { + ...general.triggerState, + flags: { ...general.triggerState.flags }, + counters: { ...general.triggerState.counters }, + modifiers: { ...general.triggerState.modifiers }, + meta: { ...general.triggerState.meta }, + }, + meta: { ...general.meta }, +}); + +const cloneCity = (city: City): City => ({ + ...city, + meta: { ...city.meta }, +}); + +const cloneNation = (nation: Nation): Nation => ({ + ...nation, + meta: { ...nation.meta }, +}); + export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState -> implements GeneralActionDefinition { +> implements + GeneralActionDefinition< + TriggerState, + DispatchArgs, + DispatchResolveContext + > { public readonly key = 'che_출병'; public readonly name = ACTION_NAME; @@ -55,14 +126,152 @@ export class ActionDefinition< } resolve( - context: GeneralActionResolveContext, + context: DispatchResolveContext, args: DispatchArgs ): GeneralActionOutcome { - context.addLog(`${ACTION_NAME}을 준비했습니다. (목표 도시 ${args.destCityId})`, { - category: LogCategory.ACTION, - format: LogFormat.MONTH, + void args; + const attackerCity = context.city; + if (!attackerCity) { + throw new Error('Dispatch requires a city context.'); + } + const attackerNation = context.nation; + if (!attackerNation) { + throw new Error('Dispatch requires a nation context.'); + } + + const destCity = context.destCity; + const unitSet = context.unitSet; + const time = context.time; + const seed = simpleSerialize( + context.seedBase, + this.key, + time.year, + time.month, + context.general.id, + destCity.id + ); + + const cities = context.cities.map(cloneCity); + const nations = context.nations.map(cloneNation); + const generals = context.generals.map(cloneGeneral); + + const cityMap = new Map(cities.map((city) => [city.id, city])); + const nationMap = new Map(nations.map((nation) => [nation.id, nation])); + + const defenderCity = cityMap.get(destCity.id) ?? cloneCity(destCity); + const defenderNation = + defenderCity.nationId > 0 + ? nationMap.get(defenderCity.nationId) ?? null + : null; + + const defenderGenerals = generals.filter( + (general) => + general.cityId === defenderCity.id && + general.nationId === defenderCity.nationId + ); + + const battle = resolveWarBattle({ + seed, + unitSet, + config: context.warConfig, + time, + attacker: { + general: context.general, + city: attackerCity, + nation: attackerNation, + }, + defenders: defenderGenerals.map((general) => ({ + general, + city: defenderCity, + nation: defenderNation, + })), + defenderCity, + defenderNation, }); - return { effects: [] }; + + const aftermath = resolveWarAftermath({ + battle, + attackerNation, + defenderNation, + attackerCity, + defenderCity, + nations, + cities, + generals, + unitSet, + config: context.aftermathConfig, + time, + hiddenSeed: context.seedBase, + }); + + const effects: Array> = []; + + for (const entry of battle.logs) { + effects.push({ type: 'log', entry }); + } + for (const entry of aftermath.logs) { + effects.push({ type: 'log', entry }); + } + + const generalPatches = new Map>(); + const cityPatches = new Map(); + const nationPatches = new Map(); + + const addGeneralPatch = (general: General): void => { + if (general.id === context.general.id) { + return; + } + generalPatches.set(general.id, cloneGeneral(general)); + }; + const addCityPatch = (city: City): void => { + if (context.city && city.id === context.city.id) { + return; + } + cityPatches.set(city.id, cloneCity(city)); + }; + const addNationPatch = (nation: Nation): void => { + if (context.nation && nation.id === context.nation.id) { + return; + } + nationPatches.set(nation.id, cloneNation(nation)); + }; + + for (const defender of battle.defenders) { + addGeneralPatch(defender); + } + for (const general of aftermath.generals) { + addGeneralPatch(general); + } + addCityPatch(defenderCity); + for (const city of aftermath.cities) { + addCityPatch(city); + } + if (defenderNation) { + addNationPatch(defenderNation); + } + for (const nation of aftermath.nations) { + addNationPatch(nation); + } + + for (const [id, patch] of generalPatches) { + effects.push(createGeneralPatchEffect(patch, id)); + } + for (const [id, patch] of cityPatches) { + effects.push(createCityPatchEffect(patch, id)); + } + for (const [id, patch] of nationPatches) { + effects.push(createNationPatchEffect(patch, id)); + } + + for (const delta of aftermath.diplomacyDeltas) { + effects.push( + createDiplomacyPatchEffect(delta.fromNationId, delta.toNationId, { + deadDelta: delta.deadDelta, + }) + ); + } + + return { effects }; } } diff --git a/packages/logic/test/dispatchWarAction.test.ts b/packages/logic/test/dispatchWarAction.test.ts new file mode 100644 index 0000000..094012e --- /dev/null +++ b/packages/logic/test/dispatchWarAction.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest'; + +import type { City, General, Nation } from '../src/domain/entities.js'; +import { resolveGeneralAction } from '../src/actions/engine.js'; +import { ActionDefinition } from '../src/actions/turn/general/che_출병.js'; +import type { TurnSchedule } from '../src/turn/calendar.js'; +import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js'; +import type { UnitSetDefinition } from '../src/world/types.js'; + +const buildGeneral = (id: number, nationId: number, cityId: number): General => ({ + id, + name: `General${id}`, + nationId, + cityId, + troopId: 0, + stats: { + leadership: 70, + strength: 70, + intelligence: 70, + }, + experience: 100, + dedication: 100, + officerLevel: 3, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { + horse: null, + weapon: null, + book: null, + item: null, + }, + }, + injury: 0, + gold: 1000, + rice: 2000, + crew: 1500, + crewTypeId: 100, + train: 80, + atmos: 80, + age: 25, + npcState: 0, + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + meta: {}, +}); + +const buildCity = (id: number, nationId: number): City => ({ + id, + name: `City${id}`, + nationId, + level: 2, + population: 10000, + populationMax: 10000, + agriculture: 1000, + agricultureMax: 1000, + commerce: 1000, + commerceMax: 1000, + security: 1000, + securityMax: 1000, + supplyState: 1, + frontState: 0, + defence: 200, + defenceMax: 400, + wall: 200, + wallMax: 400, + meta: {}, +}); + +const buildNation = (id: number): Nation => ({ + id, + name: `Nation${id}`, + color: '#000000', + capitalCityId: id, + chiefGeneralId: null, + gold: 5000, + rice: 5000, + power: 0, + level: 1, + typeCode: 'test', + meta: { + tech: 1000, + }, +}); + +const unitSet: UnitSetDefinition = { + id: 'test', + name: 'test', + defaultCrewTypeId: 100, + crewTypes: [ + { + id: 100, + armType: 1, + name: 'Infantry', + attack: 10, + defence: 10, + speed: 3, + avoid: 5, + magicCoef: 0, + cost: 0, + rice: 1, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + { + id: 999, + armType: 5, + name: 'Castle', + attack: 0, + defence: 0, + speed: 1, + avoid: 0, + magicCoef: 0, + cost: 0, + rice: 10, + requirements: [{ type: 'Impossible' }], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + ], +}; + +const warConfig: WarEngineConfig = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 999, + armTypes: { + footman: 1, + archer: 2, + cavalry: 3, + wizard: 4, + siege: 5, + misc: 6, + castle: 5, + }, +}; + +const aftermathConfig: WarAftermathConfig = { + initialNationGenLimit: 1, + techLevelIncYear: 5, + initialAllowedTechLevel: 1, + maxTechLevel: 12, + defaultCityWall: 1000, + baseGold: 0, + baseRice: 0, + castleCrewTypeId: 999, +}; + +const rng = { + nextFloat: () => 0.1, + nextBool: (probability: number) => probability >= 0.1, + nextInt: (minInclusive: number, _maxExclusive: number) => minInclusive, +}; + +const schedule: TurnSchedule = { + entries: [{ startMinute: 0, tickMinutes: 60 }], +}; + +describe('che_출병', () => { + it('runs war battle and emits patches/logs', () => { + const attackerNation = buildNation(1); + const defenderNation = buildNation(2); + const attackerCity = buildCity(1, attackerNation.id); + const defenderCity = buildCity(2, defenderNation.id); + const attacker = buildGeneral(1, attackerNation.id, attackerCity.id); + const defender = buildGeneral(2, defenderNation.id, defenderCity.id); + + const definition = new ActionDefinition(); + const resolution = resolveGeneralAction( + definition, + { + general: attacker, + city: attackerCity, + nation: attackerNation, + rng, + destCity: defenderCity, + destNation: defenderNation, + cities: [attackerCity, defenderCity], + nations: [attackerNation, defenderNation], + generals: [attacker, defender], + unitSet, + time: { + year: 200, + month: 1, + startYear: 180, + }, + seedBase: 'test-seed', + warConfig, + aftermathConfig, + }, + { + now: new Date('2000-01-01T00:00:00Z'), + schedule, + }, + { + destCityId: defenderCity.id, + } + ); + + expect(resolution.logs.length).toBeGreaterThan(0); + expect(resolution.patches?.generals.some((patch) => patch.id === defender.id)).toBe( + true + ); + expect(resolution.patches?.cities.some((patch) => patch.id === defenderCity.id)).toBe( + true + ); + expect( + resolution.effects.some( + (effect) => + effect.type === 'diplomacy:patch' && + effect.srcNationId === attackerNation.id && + effect.destNationId === defenderNation.id + ) + ).toBe(true); + }); +});