feat: Introduce action context builders for various turn actions

- Added `actionContextBuilder` to multiple turn action files to utilize the default action context builder.
- Implemented specific context builders for actions requiring additional context, such as war configurations, world summaries, and general statistics.
- Created new `actionContext.ts` and `actionContextHelpers.ts` files to define interfaces and helper functions for managing action contexts.
- Enhanced context handling for nation and general actions, ensuring proper data injection for turn execution.
This commit is contained in:
2026-01-03 06:28:50 +00:00
parent b628b32968
commit 4f90f50033
31 changed files with 613 additions and 429 deletions
@@ -0,0 +1,275 @@
import type { General } from '../../domain/entities.js';
import type { ScenarioConfig } from '../../scenario/types.js';
import type { ScenarioMeta } from '../../world/types.js';
import type { WarAftermathConfig, WarEngineConfig, WarTimeContext } from '../../war/types.js';
import type { UnitSetDefinition } from '../../world/types.js';
import type {
ActionContextWorldRef,
ActionContextWorldState,
} from './actionContext.js';
export interface WorldSummary {
totalGeneralCount: number;
totalNpcCount: number;
averageStats?: General['stats'];
}
export interface NationSummary {
averageStats?: General['stats'];
averageExperience?: number;
averageDedication?: number;
}
export const buildWorldSummary = (
world: ActionContextWorldRef | null
): WorldSummary => {
if (!world) {
return { totalGeneralCount: 0, totalNpcCount: 0 };
}
const generals = world.listGenerals();
if (generals.length === 0) {
return { totalGeneralCount: 0, totalNpcCount: 0 };
}
const total = generals.length;
const npcCount = generals.filter((general) => general.npcState > 0).length;
const statSum = generals.reduce(
(acc, general) => ({
leadership: acc.leadership + general.stats.leadership,
strength: acc.strength + general.stats.strength,
intelligence: acc.intelligence + general.stats.intelligence,
}),
{ leadership: 0, strength: 0, intelligence: 0 }
);
return {
totalGeneralCount: total,
totalNpcCount: npcCount,
averageStats: {
leadership: statSum.leadership / total,
strength: statSum.strength / total,
intelligence: statSum.intelligence / total,
},
};
};
export const buildNationSummary = (
world: ActionContextWorldRef | null,
nationId: number
): NationSummary => {
if (!world || nationId <= 0) {
return {};
}
const generals = world.listGenerals().filter(
(general) => general.nationId === nationId
);
if (generals.length === 0) {
return {};
}
const total = generals.length;
const statSum = generals.reduce(
(acc, general) => ({
leadership: acc.leadership + general.stats.leadership,
strength: acc.strength + general.stats.strength,
intelligence: acc.intelligence + general.stats.intelligence,
}),
{ leadership: 0, strength: 0, intelligence: 0 }
);
const expSum = generals.reduce((acc, general) => acc + general.experience, 0);
const dedSum = generals.reduce((acc, general) => acc + general.dedication, 0);
return {
averageStats: {
leadership: statSum.leadership / total,
strength: statSum.strength / total,
intelligence: statSum.intelligence / total,
},
averageExperience: expSum / total,
averageDedication: dedSum / total,
};
};
export const buildAverageNationGeneralCount = (
world: ActionContextWorldRef | null
): number => {
if (!world) {
return 0;
}
const generals = world.listGenerals();
const nations = world.listNations();
if (nations.length === 0) {
return generals.length;
}
return generals.length / nations.length;
};
export const resolveStartYear = (
world: ActionContextWorldState,
scenarioMeta?: ScenarioMeta
): number => {
if (typeof scenarioMeta?.startYear === 'number') {
return scenarioMeta.startYear;
}
return world.currentYear;
};
export const resolveTurnTermMinutes = (world: ActionContextWorldState): 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<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const resolveNumber = (
record: Record<string, unknown>,
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
);
};
export 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,
},
};
};
export 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,
};
};
export const buildWarTime = (
world: ActionContextWorldState,
scenarioMeta?: ScenarioMeta
): WarTimeContext => ({
year: world.currentYear,
month: world.currentMonth,
startYear: resolveStartYear(world, scenarioMeta),
});