Ref처럼 여러 턴이 필요한 국가 명령의 소요 턴을 명령명 아래에 표시한다. 실행 getPreReqTurn을 기준으로 삼고 천도는 거리 기반 공식을 노출한다.
883 lines
30 KiB
TypeScript
883 lines
30 KiB
TypeScript
import type {
|
|
City,
|
|
Constraint,
|
|
ConstraintContext,
|
|
ConstraintResult,
|
|
General,
|
|
GeneralItemSlots,
|
|
GeneralActionDefinition,
|
|
GeneralTurnCommandSpec,
|
|
GeneralTurnCommandKey,
|
|
MapDefinition,
|
|
Nation,
|
|
NationTurnCommandKey,
|
|
NationTurnCommandSpec,
|
|
RequirementKey,
|
|
StateView,
|
|
TurnCommandEnv,
|
|
TriggerValue,
|
|
UnitSetDefinition,
|
|
} from '@sammo-ts/logic';
|
|
import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic';
|
|
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
|
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
|
|
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
|
import { getTechAbility, getTechLevel, isCrewTypeAvailable } from '@sammo-ts/logic/world/unitSet.js';
|
|
import { asRecord, isRecord } from '@sammo-ts/common';
|
|
|
|
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
|
|
import {
|
|
buildTurnCommandInputFields,
|
|
loadTurnCommandSpecs,
|
|
type TurnCommandInputField,
|
|
type TurnCommandInputOptions,
|
|
type TurnCommandRecruitmentInfo,
|
|
} from './commandInput.js';
|
|
|
|
type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown';
|
|
|
|
export interface TurnCommandAvailability {
|
|
key: string;
|
|
name: string;
|
|
turnDurationText?: string;
|
|
reqArg: boolean;
|
|
possible: boolean;
|
|
status: AvailabilityStatus;
|
|
reason?: string;
|
|
inputFields: TurnCommandInputField[];
|
|
}
|
|
|
|
export interface TurnCommandGroup {
|
|
category: string;
|
|
values: TurnCommandAvailability[];
|
|
}
|
|
|
|
export interface TurnCommandTable {
|
|
general: TurnCommandGroup[];
|
|
nation: TurnCommandGroup[];
|
|
inputOptions: TurnCommandInputOptions;
|
|
}
|
|
|
|
type CommandEnv = TurnCommandEnv;
|
|
|
|
interface AvailabilityCore {
|
|
possible: boolean;
|
|
status: AvailabilityStatus;
|
|
reason?: string;
|
|
}
|
|
|
|
interface CommandEntry {
|
|
category: string;
|
|
definition: GeneralActionDefinition;
|
|
reqArg: boolean;
|
|
availabilityArgs: Readonly<Record<string, unknown>>;
|
|
inputFields: TurnCommandInputField[];
|
|
evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore;
|
|
}
|
|
|
|
const REF_GENERAL_COMMAND_GROUPS = [
|
|
{
|
|
category: '개인',
|
|
commands: [
|
|
'휴식',
|
|
'che_요양',
|
|
'che_단련',
|
|
'che_숙련전환',
|
|
'che_견문',
|
|
'che_은퇴',
|
|
'che_장비매매',
|
|
'che_군량매매',
|
|
'che_내정특기초기화',
|
|
'che_전투특기초기화',
|
|
],
|
|
},
|
|
{
|
|
category: '내정',
|
|
commands: [
|
|
'che_농지개간',
|
|
'che_상업투자',
|
|
'che_기술연구',
|
|
'che_수비강화',
|
|
'che_성벽보수',
|
|
'che_치안강화',
|
|
'che_정착장려',
|
|
'che_주민선정',
|
|
],
|
|
},
|
|
{
|
|
category: '군사',
|
|
commands: [
|
|
'che_징병',
|
|
'che_모병',
|
|
'che_훈련',
|
|
'che_사기진작',
|
|
'che_출병',
|
|
'che_집합',
|
|
'che_소집해제',
|
|
'che_첩보',
|
|
],
|
|
},
|
|
{
|
|
category: '인사',
|
|
commands: [
|
|
'che_이동',
|
|
'che_강행',
|
|
'che_인재탐색',
|
|
'che_등용',
|
|
'che_귀환',
|
|
'che_임관',
|
|
'che_랜덤임관',
|
|
'che_장수대상임관',
|
|
],
|
|
},
|
|
{
|
|
category: '계략',
|
|
commands: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'],
|
|
},
|
|
{
|
|
category: '국가',
|
|
commands: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
|
},
|
|
] as const satisfies ReadonlyArray<{
|
|
category: string;
|
|
commands: ReadonlyArray<GeneralTurnCommandKey>;
|
|
}>;
|
|
|
|
const REF_NATION_COMMAND_GROUPS = [
|
|
{
|
|
category: '휴식',
|
|
commands: ['휴식'],
|
|
},
|
|
{
|
|
category: '인사',
|
|
commands: ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'],
|
|
},
|
|
{
|
|
category: '외교',
|
|
commands: ['che_물자원조', 'che_불가침제의', 'che_선전포고', 'che_종전제의', 'che_불가침파기제의'],
|
|
},
|
|
{
|
|
category: '특수',
|
|
commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'],
|
|
},
|
|
{
|
|
category: '전략',
|
|
commands: [
|
|
'che_필사즉생',
|
|
'che_백성동원',
|
|
'che_수몰',
|
|
'che_허보',
|
|
'che_의병모집',
|
|
'che_이호경식',
|
|
'che_급습',
|
|
'che_피장파장',
|
|
],
|
|
},
|
|
{
|
|
category: '기타',
|
|
commands: ['che_국기변경', 'che_국호변경'],
|
|
},
|
|
] as const satisfies ReadonlyArray<{
|
|
category: string;
|
|
commands: ReadonlyArray<NationTurnCommandKey>;
|
|
}>;
|
|
|
|
const INPUT_REQUIREMENT_KINDS = new Set<RequirementKey['kind']>([
|
|
'destGeneral',
|
|
'destCity',
|
|
'destNation',
|
|
'diplomacy',
|
|
'arg',
|
|
]);
|
|
|
|
const AVAILABILITY_PRIORITY: Record<AvailabilityStatus, number> = {
|
|
available: 3,
|
|
needsInput: 2,
|
|
unknown: 1,
|
|
blocked: 0,
|
|
};
|
|
|
|
const DEFAULT_GENERAL_GOLD = 1000;
|
|
const DEFAULT_GENERAL_RICE = 1000;
|
|
const DEFAULT_CREW_TYPE_ID = 1100;
|
|
|
|
const asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
|
|
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
|
|
|
|
const readMetaNumber = (meta: Record<string, TriggerValue>, key: string): number | null => {
|
|
const value = meta[key];
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === 'string') {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const ensureGeneralMeta = (meta: Record<string, TriggerValue>, generalId: number): General['meta'] => {
|
|
const killturn = readMetaNumber(meta, 'killturn');
|
|
if (killturn === null) {
|
|
throw new Error(`general.meta.killturn is required (generalId=${generalId}).`);
|
|
}
|
|
return { ...meta, killturn } as General['meta'];
|
|
};
|
|
|
|
const normalizeCode = (value: string | null | undefined): string | null => {
|
|
if (!value || value === 'None') {
|
|
return null;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
class MemoryStateView implements StateView {
|
|
private readonly store = new Map<string, unknown>();
|
|
|
|
has(req: RequirementKey): boolean {
|
|
return this.store.has(this.getKey(req));
|
|
}
|
|
|
|
get(req: RequirementKey): unknown | null {
|
|
return this.store.get(this.getKey(req)) ?? null;
|
|
}
|
|
|
|
set(req: RequirementKey, value: unknown): void {
|
|
this.store.set(this.getKey(req), value);
|
|
}
|
|
|
|
private getKey(req: RequirementKey): string {
|
|
switch (req.kind) {
|
|
case 'general':
|
|
return `general:${req.id}`;
|
|
case 'generalList':
|
|
return 'general:list';
|
|
case 'city':
|
|
return `city:${req.id}`;
|
|
case 'nation':
|
|
return `nation:${req.id}`;
|
|
case 'destGeneral':
|
|
return `destGeneral:${req.id}`;
|
|
case 'destCity':
|
|
return `destCity:${req.id}`;
|
|
case 'destNation':
|
|
return `destNation:${req.id}`;
|
|
case 'diplomacy':
|
|
return `diplomacy:${req.srcNationId}:${req.destNationId}`;
|
|
case 'diplomacyList':
|
|
return 'diplomacy:list';
|
|
case 'arg':
|
|
return `arg:${req.key}`;
|
|
case 'env':
|
|
return `env:${req.key}`;
|
|
default:
|
|
return 'unknown';
|
|
}
|
|
}
|
|
}
|
|
|
|
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
|
|
for (const key of keys) {
|
|
const value = source[key];
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const resolveOptionalString = (source: Record<string, unknown>, keys: string[]): string | null => {
|
|
for (const key of keys) {
|
|
const value = source[key];
|
|
if (typeof value === 'string') {
|
|
return normalizeCode(value);
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const DEFAULT_MAX_NATION = 55;
|
|
|
|
const resolveMaxNation = (worldState: WorldStateRow): number => {
|
|
const config = asRecord(worldState.config);
|
|
const constValues = asRecord(config.const);
|
|
const meta = asRecord(worldState.meta);
|
|
const refGameEnv = asRecord(meta.refGameEnv);
|
|
const candidates = [
|
|
refGameEnv.maxnation,
|
|
refGameEnv.maxNation,
|
|
config.maxnation,
|
|
config.maxNation,
|
|
constValues.defaultMaxNation,
|
|
constValues.maxNation,
|
|
];
|
|
for (const candidate of candidates) {
|
|
const parsed =
|
|
typeof candidate === 'number' ? candidate : typeof candidate === 'string' ? Number(candidate) : Number.NaN;
|
|
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
return Math.floor(parsed);
|
|
}
|
|
}
|
|
return DEFAULT_MAX_NATION;
|
|
};
|
|
|
|
const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
|
const config = asRecord(worldState.config);
|
|
const constValues = asRecord(config.const);
|
|
|
|
return {
|
|
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
|
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
|
|
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
|
|
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
|
|
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0),
|
|
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], 0),
|
|
sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], 0),
|
|
sabotageProbCoefByStat: resolveNumber(constValues, ['sabotageProbCoefByStat'], 0),
|
|
sabotageDefenceCoefByGeneralCount: resolveNumber(constValues, ['sabotageDefenceCoefByGeneralCount'], 0),
|
|
sabotageDamageMin: resolveNumber(constValues, ['sabotageDamageMin'], 0),
|
|
sabotageDamageMax: resolveNumber(constValues, ['sabotageDamageMax'], 0),
|
|
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
|
|
maxGeneral: resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0),
|
|
defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD),
|
|
defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE),
|
|
defaultCrewTypeId: resolveNumber(constValues, ['defaultCrewTypeId'], DEFAULT_CREW_TYPE_ID),
|
|
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']),
|
|
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
|
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
|
|
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
|
|
maxStatLevel: resolveNumber(constValues, ['maxLevel'], LEGACY_DEFAULT_MAX_LEVEL),
|
|
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
|
|
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
|
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
|
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
|
|
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
|
|
npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], 0.01),
|
|
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
|
};
|
|
};
|
|
|
|
const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown> => {
|
|
const config = asRecord(worldState.config);
|
|
const constValues = asRecord(config.const);
|
|
const meta = asRecord(worldState.meta);
|
|
const scenarioMeta = asRecord(meta.scenarioMeta);
|
|
const startYear = typeof scenarioMeta.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
|
const relYear = typeof startYear === 'number' ? worldState.currentYear - startYear : undefined;
|
|
const joinModeRaw = config.join_mode ?? config.joinMode;
|
|
const joinMode = typeof joinModeRaw === 'string' ? joinModeRaw : 'full';
|
|
|
|
return {
|
|
currentYear: worldState.currentYear,
|
|
currentMonth: worldState.currentMonth,
|
|
year: worldState.currentYear,
|
|
month: worldState.currentMonth,
|
|
startYear,
|
|
relYear,
|
|
join_mode: joinMode,
|
|
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
|
|
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
|
|
};
|
|
};
|
|
|
|
const mapGeneralRow = (row: GeneralRow, missingKillturnFallback?: number): General => {
|
|
const legacySlots: GeneralItemSlots = {
|
|
horse: normalizeCode(row.horseCode),
|
|
weapon: normalizeCode(row.weaponCode),
|
|
book: normalizeCode(row.bookCode),
|
|
item: normalizeCode(row.itemCode),
|
|
};
|
|
const rawMeta = asTriggerRecord(row.meta);
|
|
const meta =
|
|
readMetaNumber(rawMeta, 'killturn') === null && missingKillturnFallback !== undefined
|
|
? ({ ...rawMeta, killturn: missingKillturnFallback } as General['meta'])
|
|
: ensureGeneralMeta(rawMeta, row.id);
|
|
const itemInventory = readItemInventoryFromMeta(meta, legacySlots);
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
nationId: row.nationId,
|
|
cityId: row.cityId,
|
|
troopId: row.troopId,
|
|
stats: {
|
|
leadership: row.leadership,
|
|
strength: row.strength,
|
|
intelligence: row.intel,
|
|
},
|
|
experience: row.experience,
|
|
dedication: row.dedication,
|
|
officerLevel: row.officerLevel,
|
|
role: {
|
|
personality: normalizeCode(row.personalCode),
|
|
specialDomestic: normalizeCode(row.specialCode),
|
|
specialWar: normalizeCode(row.special2Code),
|
|
items: projectItemSlots(itemInventory),
|
|
},
|
|
injury: row.injury,
|
|
gold: row.gold,
|
|
rice: row.rice,
|
|
crew: row.crew,
|
|
crewTypeId: row.crewTypeId,
|
|
train: row.train,
|
|
atmos: row.atmos,
|
|
age: row.age,
|
|
npcState: row.npcState,
|
|
triggerState: {
|
|
flags: {},
|
|
counters: {},
|
|
modifiers: {},
|
|
meta: {},
|
|
},
|
|
itemInventory,
|
|
meta,
|
|
};
|
|
};
|
|
|
|
const mapCityRow = (row: CityRow): City => {
|
|
const meta = asTriggerRecord(row.meta);
|
|
const state = typeof meta.state === 'number' && Number.isFinite(meta.state) ? Math.floor(meta.state) : 0;
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
nationId: row.nationId,
|
|
level: row.level,
|
|
state,
|
|
population: row.population,
|
|
populationMax: row.populationMax,
|
|
agriculture: row.agriculture,
|
|
agricultureMax: row.agricultureMax,
|
|
commerce: row.commerce,
|
|
commerceMax: row.commerceMax,
|
|
security: row.security,
|
|
securityMax: row.securityMax,
|
|
supplyState: row.supplyState,
|
|
frontState: row.frontState,
|
|
defence: row.defence,
|
|
defenceMax: row.defenceMax,
|
|
wall: row.wall,
|
|
wallMax: row.wallMax,
|
|
meta: {
|
|
...meta,
|
|
trust: row.trust,
|
|
...(row.trade === null ? {} : { trade: row.trade }),
|
|
region: row.region,
|
|
},
|
|
};
|
|
};
|
|
|
|
const mapNationRow = (row: NationRow): Nation => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
color: row.color,
|
|
capitalCityId: row.capitalCityId,
|
|
chiefGeneralId: null,
|
|
gold: row.gold,
|
|
rice: row.rice,
|
|
power: 0,
|
|
level: row.level,
|
|
typeCode: row.typeCode,
|
|
meta: {
|
|
...asTriggerRecord(row.meta),
|
|
tech: row.tech,
|
|
},
|
|
});
|
|
|
|
export const buildRecruitmentCommandInfo = (options: {
|
|
worldState: WorldStateRow;
|
|
general: GeneralRow;
|
|
city: CityRow | null;
|
|
nation: NationRow | null;
|
|
cities: CityRow[];
|
|
map: MapDefinition;
|
|
unitSet: UnitSetDefinition;
|
|
generalActionModules?: ReadonlyArray<GeneralActionModule | null | undefined>;
|
|
}): TurnCommandRecruitmentInfo => {
|
|
const general = mapGeneralRow(options.general);
|
|
const city = options.city ? mapCityRow(options.city) : undefined;
|
|
const nation = options.nation ? mapNationRow(options.nation) : null;
|
|
const cities = options.cities.map(mapCityRow);
|
|
const commandEnv = buildCommandEnv(options.worldState);
|
|
const constraintEnv = buildConstraintEnv(options.worldState);
|
|
const configuredStartYear = typeof constraintEnv.startYear === 'number' ? constraintEnv.startYear : undefined;
|
|
const startYear = configuredStartYear ?? options.worldState.currentYear;
|
|
const context = {
|
|
general,
|
|
...(city ? { city } : {}),
|
|
nation,
|
|
time: {
|
|
year: options.worldState.currentYear,
|
|
month: options.worldState.currentMonth,
|
|
startYear,
|
|
},
|
|
maxTechLevel: commandEnv.maxTechLevel,
|
|
};
|
|
const command = new RecruitmentCommandResolver(options.generalActionModules ?? [], commandEnv);
|
|
const tech = options.nation?.tech ?? 0;
|
|
const techAbility = getTechAbility(tech, commandEnv.maxTechLevel);
|
|
const availabilityContext = {
|
|
general,
|
|
nation,
|
|
map: options.map,
|
|
cities,
|
|
currentYear: options.worldState.currentYear,
|
|
...(configuredStartYear === undefined ? {} : { startYear: configuredStartYear }),
|
|
};
|
|
const crewTypes = options.unitSet.crewTypes ?? [];
|
|
const armTypes = Object.entries(options.unitSet.armTypes ?? {})
|
|
.map(([armType, armName]) => ({ armType: Number(armType), armName }))
|
|
.filter((entry) => Number.isFinite(entry.armType))
|
|
.sort((left, right) => left.armType - right.armType);
|
|
|
|
const groups = armTypes.map(({ armType, armName }) => ({
|
|
armType,
|
|
armName,
|
|
values: crewTypes
|
|
.filter((crewType) => crewType.armType === armType)
|
|
.map((crewType) => {
|
|
const displayCost = command.getDisplayUnitCost(context, crewType);
|
|
const requiredTech = crewType.requirements.find((requirement) => requirement.type === 'ReqTech');
|
|
return {
|
|
id: crewType.id,
|
|
armType,
|
|
name: crewType.name,
|
|
available: isCrewTypeAvailable(options.unitSet, crewType.id, availabilityContext),
|
|
special:
|
|
requiredTech?.type === 'ReqTech' &&
|
|
typeof requiredTech.tech === 'number' &&
|
|
requiredTech.tech > 0,
|
|
attack: crewType.attack + techAbility,
|
|
defence: crewType.defence + techAbility,
|
|
speed: crewType.speed,
|
|
avoid: crewType.avoid,
|
|
baseCost: displayCost.gold,
|
|
baseRice: displayCost.rice,
|
|
info: [...crewType.info],
|
|
};
|
|
}),
|
|
}));
|
|
const currentCrewTypeName = crewTypes.find((crewType) => crewType.id === general.crewTypeId)?.name ?? '-';
|
|
|
|
return {
|
|
techLevel: getTechLevel(tech, commandEnv.maxTechLevel),
|
|
leadership: command.resolveLeadership(context),
|
|
fullLeadership: command.resolveFullLeadership(context),
|
|
currentCrewTypeId: general.crewTypeId,
|
|
currentCrewTypeName,
|
|
crew: general.crew,
|
|
gold: general.gold,
|
|
groups,
|
|
};
|
|
};
|
|
|
|
const buildStateView = (
|
|
general: General,
|
|
city: City | null,
|
|
nation: Nation | null,
|
|
generalList: General[] | null,
|
|
env: Record<string, unknown>
|
|
): StateView => {
|
|
const view = new MemoryStateView();
|
|
view.set({ kind: 'general', id: general.id }, general);
|
|
for (const [key, value] of Object.entries(env)) {
|
|
if (value !== undefined) {
|
|
view.set({ kind: 'env', key }, value);
|
|
}
|
|
}
|
|
if (city) {
|
|
view.set({ kind: 'city', id: city.id }, city);
|
|
}
|
|
if (nation) {
|
|
view.set({ kind: 'nation', id: nation.id }, nation);
|
|
}
|
|
if (generalList) {
|
|
view.set({ kind: 'generalList' }, generalList);
|
|
}
|
|
return view;
|
|
};
|
|
|
|
const evaluateAvailability = (
|
|
constraints: Constraint[],
|
|
ctx: ConstraintContext,
|
|
view: StateView,
|
|
reqArg: boolean
|
|
): AvailabilityCore => {
|
|
const result = evaluateConstraints(constraints, ctx, view);
|
|
if (result.kind === 'allow') {
|
|
return { possible: true, status: 'available' };
|
|
}
|
|
if (result.kind === 'deny') {
|
|
return {
|
|
possible: false,
|
|
status: 'blocked',
|
|
reason: result.reason,
|
|
};
|
|
}
|
|
const missingKinds = new Set(result.missing.map((req: RequirementKey) => req.kind));
|
|
const inputOnlyMissing =
|
|
missingKinds.size === 0 ? reqArg : Array.from(missingKinds).every((kind) => INPUT_REQUIREMENT_KINDS.has(kind));
|
|
if (inputOnlyMissing) {
|
|
return {
|
|
possible: true,
|
|
status: 'needsInput',
|
|
reason: '대상 선택 필요',
|
|
};
|
|
}
|
|
return {
|
|
possible: false,
|
|
status: 'unknown',
|
|
reason: '정보 부족',
|
|
};
|
|
};
|
|
|
|
const evaluateDefinition = (
|
|
definition: GeneralActionDefinition,
|
|
ctx: ConstraintContext,
|
|
view: StateView,
|
|
reqArg: boolean,
|
|
args: unknown
|
|
): AvailabilityCore => {
|
|
const constraints =
|
|
ctx.mode === 'precheck' && definition.buildMinConstraints
|
|
? definition.buildMinConstraints(ctx, args)
|
|
: definition.buildConstraints(ctx, args);
|
|
return evaluateAvailability(constraints, ctx, view, reqArg);
|
|
};
|
|
|
|
const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore =>
|
|
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs;
|
|
|
|
type TurnCommandSpec = GeneralTurnCommandSpec | NationTurnCommandSpec;
|
|
|
|
const FOUNDING_COMMAND_KEYS = new Set(['che_건국', 'cr_건국', 'che_무작위건국']);
|
|
|
|
const buildEntries = (
|
|
env: CommandEnv,
|
|
specs: TurnCommandSpec[],
|
|
options: { foundingAvailable?: boolean } = {}
|
|
): CommandEntry[] => {
|
|
const entries: CommandEntry[] = [];
|
|
|
|
for (const spec of specs) {
|
|
const definition = spec.createDefinition(env);
|
|
const entry: CommandEntry = {
|
|
category: spec.category,
|
|
definition,
|
|
reqArg: spec.reqArg,
|
|
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
|
|
inputFields: buildTurnCommandInputFields(spec),
|
|
};
|
|
|
|
if (spec.key === 'che_포상') {
|
|
entry.evaluate = (ctx, view) => {
|
|
const gold = evaluateDefinition(definition, ctx, view, true, {
|
|
isGold: true,
|
|
amount: 1,
|
|
destGeneralId: 0,
|
|
});
|
|
const rice = evaluateDefinition(definition, ctx, view, true, {
|
|
isGold: false,
|
|
amount: 1,
|
|
destGeneralId: 0,
|
|
});
|
|
return pickAvailability(gold, rice);
|
|
};
|
|
}
|
|
|
|
if (FOUNDING_COMMAND_KEYS.has(spec.key) && options.foundingAvailable === false) {
|
|
entry.evaluate = () => ({
|
|
possible: false,
|
|
status: 'blocked',
|
|
reason: '더 이상 건국은 불가능합니다.',
|
|
});
|
|
}
|
|
|
|
entries.push(entry);
|
|
}
|
|
|
|
return entries;
|
|
};
|
|
|
|
const getTurnDurationText = (definition: GeneralActionDefinition): string | undefined => {
|
|
const hint = definition.getTurnDurationHint?.();
|
|
if (hint) return hint;
|
|
|
|
const getPreReqTurn = definition.getPreReqTurn;
|
|
// 인자를 선언한 구현은 천도처럼 대상에 따라 달라지므로 임의 context로 실행하지 않는다.
|
|
if (!getPreReqTurn || getPreReqTurn.length > 0) return undefined;
|
|
const preReqTurn = Math.max(0, Math.floor(getPreReqTurn.call(definition, undefined as never, {})));
|
|
return preReqTurn > 0 ? `${preReqTurn + 1}턴` : undefined;
|
|
};
|
|
|
|
const buildGroups = (
|
|
entries: CommandEntry[],
|
|
ctx: ConstraintContext,
|
|
view: StateView,
|
|
includeTurnDuration = false
|
|
): TurnCommandGroup[] => {
|
|
const groups = new Map<string, TurnCommandAvailability[]>();
|
|
|
|
for (const entry of entries) {
|
|
const availability = entry.evaluate
|
|
? entry.evaluate(ctx, view)
|
|
: evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs);
|
|
const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined;
|
|
const value: TurnCommandAvailability = {
|
|
key: entry.definition.key,
|
|
name: entry.definition.name,
|
|
...(turnDurationText ? { turnDurationText } : {}),
|
|
reqArg: entry.reqArg,
|
|
inputFields: entry.inputFields,
|
|
...availability,
|
|
};
|
|
|
|
const list = groups.get(entry.category);
|
|
if (list) {
|
|
list.push(value);
|
|
} else {
|
|
groups.set(entry.category, [value]);
|
|
}
|
|
}
|
|
|
|
return Array.from(groups.entries()).map(([category, values]) => ({
|
|
category,
|
|
values,
|
|
}));
|
|
};
|
|
|
|
type CommandGroupLayout = ReadonlyArray<{ category: string; commands: ReadonlyArray<string> }>;
|
|
|
|
const projectCommandGroups = (entries: CommandEntry[], layout: CommandGroupLayout): CommandEntry[] => {
|
|
const categoryOrder = new Map<string, number>(layout.map(({ category }, index) => [category, index] as const));
|
|
const commandPosition = new Map<string, { category: string; index: number }>(
|
|
layout.flatMap(({ category, commands }) =>
|
|
commands.map((command, index) => [command, { category, index }] as const)
|
|
)
|
|
);
|
|
|
|
return entries
|
|
.map((entry, profileIndex) => {
|
|
const refPosition = commandPosition.get(entry.definition.key);
|
|
return {
|
|
entry: refPosition ? { ...entry, category: refPosition.category } : entry,
|
|
categoryIndex: categoryOrder.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
|
|
commandIndex: refPosition?.index ?? profileIndex,
|
|
profileIndex,
|
|
};
|
|
})
|
|
.sort(
|
|
(left, right) =>
|
|
left.categoryIndex - right.categoryIndex ||
|
|
left.commandIndex - right.commandIndex ||
|
|
left.profileIndex - right.profileIndex
|
|
)
|
|
.map(({ entry }) => entry);
|
|
};
|
|
|
|
export const buildTurnCommandTable = async (options: {
|
|
worldState: WorldStateRow;
|
|
general: GeneralRow;
|
|
city: CityRow | null;
|
|
nation: NationRow | null;
|
|
nationGenerals: GeneralRow[] | null;
|
|
/** Ref's nation-table row count. Core callers must exclude synthetic id=0. */
|
|
realNationCount?: number;
|
|
inputOptions?: TurnCommandInputOptions;
|
|
}): Promise<TurnCommandTable> => {
|
|
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
|
|
const general = mapGeneralRow(options.general);
|
|
const city = options.city ? mapCityRow(options.city) : null;
|
|
const nation = options.nation ? mapNationRow(options.nation) : null;
|
|
const generalList = options.nationGenerals ? options.nationGenerals.map(mapGeneralRow) : null;
|
|
const constraintEnv = buildConstraintEnv(options.worldState);
|
|
const view = buildStateView(general, city, nation, generalList, constraintEnv);
|
|
|
|
const ctx: ConstraintContext = {
|
|
actorId: general.id,
|
|
cityId: city?.id,
|
|
nationId: nation?.id,
|
|
args: {},
|
|
env: constraintEnv,
|
|
mode: 'precheck',
|
|
};
|
|
|
|
const env = buildCommandEnv(options.worldState);
|
|
const scenarioConst = asRecord(options.worldState.config).const;
|
|
const {
|
|
general: generalSpecs,
|
|
nation: nationSpecs,
|
|
generalGroups,
|
|
nationGroups,
|
|
} = await loadTurnCommandSpecs(scenarioConst);
|
|
const generalEntries = buildEntries(env, generalSpecs, {
|
|
foundingAvailable:
|
|
options.realNationCount === undefined
|
|
? undefined
|
|
: options.realNationCount < resolveMaxNation(options.worldState),
|
|
});
|
|
const nationEntries = buildEntries(env, nationSpecs);
|
|
|
|
return {
|
|
general: buildGroups(
|
|
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
|
|
ctx,
|
|
view
|
|
),
|
|
nation: buildGroups(
|
|
projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS),
|
|
ctx,
|
|
view,
|
|
true
|
|
),
|
|
inputOptions: options.inputOptions ?? {
|
|
cities: [],
|
|
nations: [],
|
|
nationTargets: {},
|
|
generals: [],
|
|
generalTargets: {},
|
|
crewTypes: [],
|
|
armTypes: [],
|
|
nationTypes: [],
|
|
colors: [],
|
|
items: {},
|
|
recruitment: null,
|
|
amountPresets: {},
|
|
},
|
|
};
|
|
};
|
|
|
|
export const evaluateReservedTurnPermission = async (options: {
|
|
worldState: WorldStateRow;
|
|
general: GeneralRow;
|
|
scope: 'general' | 'nation';
|
|
action: string;
|
|
args: Record<string, unknown>;
|
|
}): Promise<ConstraintResult> => {
|
|
const scenarioConst = asRecord(options.worldState.config).const;
|
|
const specs = await loadTurnCommandSpecs(scenarioConst);
|
|
const spec = specs[options.scope].find((entry) => entry.key === options.action);
|
|
if (!spec) {
|
|
throw new Error(`Unknown ${options.scope} turn command: ${options.action}`);
|
|
}
|
|
|
|
const definition = spec.createDefinition(buildCommandEnv(options.worldState));
|
|
if (!definition.buildPermissionConstraints) {
|
|
return { kind: 'allow' };
|
|
}
|
|
|
|
const general = mapGeneralRow(options.general, 0);
|
|
const env = buildConstraintEnv(options.worldState);
|
|
const ctx: ConstraintContext = {
|
|
actorId: general.id,
|
|
cityId: general.cityId > 0 ? general.cityId : undefined,
|
|
nationId: general.nationId > 0 ? general.nationId : undefined,
|
|
args: options.args,
|
|
env,
|
|
mode: 'full',
|
|
};
|
|
const view = buildStateView(general, null, null, null, env);
|
|
return evaluateConstraints(definition.buildPermissionConstraints(ctx, options.args), ctx, view);
|
|
};
|