Files
core2026/app/game-engine/src/turn/ai/policies.ts
T

427 lines
16 KiB
TypeScript

import type { ScenarioConfig, TurnCommandEnv, UnitSetDefinition } from '@sammo-ts/logic';
import type { TurnGeneral } from '../types.js';
import type { Nation } from '@sammo-ts/logic';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import { asRecord, readMetaNumber, readNumber, roundTo } from './aiUtils.js';
export type PolicyFlags = Record<string, boolean>;
const DEFAULT_GENERAL_PRIORITY = [
'NPC사망대비',
'귀환',
'금쌀구매',
'출병',
'긴급내정',
'전투준비',
'전방워프',
'NPC헌납',
'징병',
'후방워프',
'전쟁내정',
'소집해제',
'일반내정',
'내정워프',
] as const;
const DEFAULT_NATION_PRIORITY = [
'불가침제의',
'선전포고',
'천도',
'유저장긴급포상',
'부대전방발령',
'유저장구출발령',
'유저장후방발령',
'부대유저장후방발령',
'유저장전방발령',
'유저장포상',
'부대구출발령',
'부대후방발령',
'NPC긴급포상',
'NPC구출발령',
'NPC후방발령',
'NPC포상',
'NPC전방발령',
'유저장내정발령',
'NPC내정발령',
'NPC몰수',
] as const;
export const AVAILABLE_INSTANT_TURN: Record<string, boolean> = {
유저장긴급포상: true,
유저장구출발령: true,
유저장후방발령: true,
유저장전방발령: true,
유저장내정발령: true,
유저장포상: true,
NPC긴급포상: true,
NPC구출발령: true,
NPC후방발령: true,
NPC내정발령: true,
NPC포상: true,
NPC전방발령: true,
};
const buildFlags = (entries: Array<[string, boolean]>): PolicyFlags => Object.fromEntries(entries);
const applyPriorityOverride = (priority: string[], override: unknown, flags: PolicyFlags): string[] => {
if (!Array.isArray(override)) {
return priority;
}
const filtered = override.filter((item) => typeof item === 'string' && flags[item] !== undefined);
return filtered.length > 0 ? (filtered as string[]) : priority;
};
const resolvePolicyRecord = (raw: unknown): Record<string, unknown> => asRecord(raw);
export class AutorunGeneralPolicy {
public priority: string[];
public flags: PolicyFlags;
constructor(
general: TurnGeneral,
aiOptions: Record<string, boolean> | null,
nationPolicy: Record<string, unknown> | null,
serverPolicy: Record<string, unknown> | null
) {
this.priority = [...DEFAULT_GENERAL_PRIORITY];
this.flags = buildFlags([
['NPC사망대비', true],
['일반내정', true],
['긴급내정', true],
['전쟁내정', true],
['금쌀구매', true],
['상인무시', true],
['징병', true],
['모병', false],
['한계징병', false],
['고급병종', false],
['전투준비', true],
['소집해제', true],
['출병', true],
['NPC헌납', true],
['후방워프', true],
['전방워프', true],
['내정워프', true],
['귀환', true],
['국가선택', true],
['집합', false],
['건국', true],
['선양', false],
]);
this.priority = applyPriorityOverride(this.priority, serverPolicy?.priority, this.flags);
this.priority = applyPriorityOverride(this.priority, nationPolicy?.priority, this.flags);
if (general.npcState >= 2) {
this.applyNpcState(general);
return;
}
// 유저장 기본값
this.flags['일반내정'] = false;
this.flags['긴급내정'] = false;
this.flags['전쟁내정'] = false;
this.flags['금쌀구매'] = false;
this.flags['상인무시'] = false;
this.flags['징병'] = false;
this.flags['모병'] = false;
this.flags['한계징병'] = true;
this.flags['고급병종'] = true;
this.flags['전투준비'] = false;
this.flags['출병'] = false;
this.flags['NPC헌납'] = false;
this.flags['후방워프'] = false;
this.flags['전방워프'] = false;
this.flags['내정워프'] = false;
this.flags['국가선택'] = false;
this.flags['집합'] = false;
this.flags['건국'] = false;
this.flags['선양'] = false;
const options = aiOptions ?? {};
for (const [key, enabled] of Object.entries(options)) {
if (!enabled) {
continue;
}
switch (key) {
case 'develop':
this.flags['일반내정'] = true;
this.flags['전쟁내정'] = true;
this.flags['금쌀구매'] = true;
break;
case 'warp':
this.flags['후방워프'] = true;
this.flags['전방워프'] = true;
this.flags['내정워프'] = true;
this.flags['금쌀구매'] = true;
this.flags['상인무시'] = true;
break;
case 'recruit_high': {
this.flags['모병'] = true;
this.flags['징병'] = true;
this.flags['소집해제'] = true;
this.flags['금쌀구매'] = true;
break;
}
case 'recruit':
this.flags['징병'] = true;
this.flags['소집해제'] = true;
this.flags['금쌀구매'] = true;
break;
case 'train':
this.flags['전투준비'] = true;
this.flags['금쌀구매'] = true;
break;
case 'battle':
this.flags['출병'] = true;
this.flags['금쌀구매'] = true;
break;
default:
break;
}
}
}
can(actionName: string): boolean {
return this.flags[actionName] ?? false;
}
private applyNpcState(general: TurnGeneral): void {
if (general.npcState === 5) {
this.flags['집합'] = true;
this.flags['선양'] = true;
this.flags['국가선택'] = false;
return;
}
if (general.npcState === 1) {
this.flags['NPC사망대비'] = false;
}
if (general.nationId !== 0) {
this.flags['국가선택'] = false;
this.flags['건국'] = false;
}
}
}
export class AutorunNationPolicy {
public priority: string[];
public flags: PolicyFlags;
public reqNationGold = 10000;
public reqNationRice = 12000;
public combatForce: Record<number, [number, number]> = {};
public supportForce: number[] = [];
public developForce: number[] = [];
public reqHumanWarUrgentGold = 0;
public reqHumanWarUrgentRice = 0;
public reqHumanWarRecommandGold = 0;
public reqHumanWarRecommandRice = 0;
public reqHumanDevelGold = 10000;
public reqHumanDevelRice = 10000;
public reqNpcWarGold = 0;
public reqNpcWarRice = 0;
public reqNpcDevelGold = 0;
public reqNpcDevelRice = 500;
public minimumResourceActionAmount = 1000;
public maximumResourceActionAmount = 10000;
public minNpcWarLeadership = 40;
public minWarCrew = 1500;
public minNpcRecruitCityPopulation = 50000;
public safeRecruitCityPopulationRatio = 0.5;
public properWarTrainAtmos = 90;
public cureThreshold = 10;
constructor(options: {
general: TurnGeneral;
aiOptions: Record<string, boolean> | null;
nationPolicy: Record<string, unknown> | null;
serverPolicy: Record<string, unknown> | null;
nation: Nation;
env: TurnCommandEnv;
scenarioConfig: ScenarioConfig;
unitSet?: UnitSetDefinition;
}) {
const { general, aiOptions, nationPolicy, serverPolicy, env, scenarioConfig, unitSet, nation } = options;
this.priority = [...DEFAULT_NATION_PRIORITY];
this.flags = buildFlags(DEFAULT_NATION_PRIORITY.map((key) => [key, true]));
this.flags['부대구출발령'] = true;
this.flags['부대후방발령'] = true;
this.flags['부대전방발령'] = true;
this.flags['NPC몰수'] = true;
this.flags['부대유저장후방발령'] = true;
const serverValues = resolvePolicyRecord(serverPolicy?.values);
for (const [key, value] of Object.entries(serverValues)) {
this.applyValueOverride(key, value);
}
if (serverPolicy?.priority) {
this.priority = applyPriorityOverride(this.priority, serverPolicy.priority, this.flags);
}
const nationValues = resolvePolicyRecord(nationPolicy?.values);
for (const [key, value] of Object.entries(nationValues)) {
this.applyValueOverride(key, value);
}
if (nationPolicy?.priority) {
this.priority = applyPriorityOverride(this.priority, nationPolicy.priority, this.flags);
}
if (this.priority.length === 0) {
this.priority = [...DEFAULT_NATION_PRIORITY];
}
if (this.reqNpcDevelGold === 0) {
this.reqNpcDevelGold = env.develCost * 30;
}
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const stat = scenarioConfig.stat;
if (this.reqNpcWarGold === 0 || this.reqNpcWarRice === 0) {
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.npcMax : 0;
const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.npcMax : 0;
if (this.reqNpcWarGold === 0) {
this.reqNpcWarGold = roundTo(baseGold * 4, -2);
}
if (this.reqNpcWarRice === 0) {
this.reqNpcWarRice = roundTo(baseRice * 4, -2);
}
}
if (this.reqHumanWarUrgentGold === 0 || this.reqHumanWarUrgentRice === 0) {
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.max : 0;
const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.max : 0;
if (this.reqHumanWarUrgentGold === 0) {
this.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
}
if (this.reqHumanWarUrgentRice === 0) {
this.reqHumanWarUrgentRice = roundTo(baseRice * 6, -2);
}
}
if (this.reqHumanWarRecommandGold === 0) {
this.reqHumanWarRecommandGold = roundTo(this.reqHumanWarUrgentGold * 2, -2);
}
if (this.reqHumanWarRecommandRice === 0) {
this.reqHumanWarRecommandRice = roundTo(this.reqHumanWarUrgentRice * 2, -2);
}
if (general.npcState >= 2) {
return;
}
if (!aiOptions?.chief) {
for (const key of Object.keys(this.flags)) {
this.flags[key] = false;
}
this.flags['선전포고'] = false;
this.flags['천도'] = false;
}
}
can(actionName: string): boolean {
return this.flags[actionName] ?? false;
}
private applyValueOverride(key: string, value: unknown): void {
switch (key) {
case 'reqNationGold':
this.reqNationGold = readNumber(value, this.reqNationGold);
return;
case 'reqNationRice':
this.reqNationRice = readNumber(value, this.reqNationRice);
return;
case 'CombatForce':
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
const next: Record<number, [number, number]> = {};
for (const [rawKey, rawValue] of Object.entries(record)) {
const leaderId = Number(rawKey);
if (!Number.isFinite(leaderId)) {
continue;
}
if (!Array.isArray(rawValue) || rawValue.length < 2) {
continue;
}
const fromCity = Number(rawValue[0]);
const toCity = Number(rawValue[1]);
if (!Number.isFinite(fromCity) || !Number.isFinite(toCity)) {
continue;
}
next[leaderId] = [fromCity, toCity];
}
this.combatForce = next;
}
return;
case 'SupportForce':
if (Array.isArray(value)) {
this.supportForce = value.filter((v) => typeof v === 'number') as number[];
}
return;
case 'DevelopForce':
if (Array.isArray(value)) {
this.developForce = value.filter((v) => typeof v === 'number') as number[];
}
return;
case 'reqHumanWarUrgentGold':
this.reqHumanWarUrgentGold = readNumber(value, this.reqHumanWarUrgentGold);
return;
case 'reqHumanWarUrgentRice':
this.reqHumanWarUrgentRice = readNumber(value, this.reqHumanWarUrgentRice);
return;
case 'reqHumanWarRecommandGold':
this.reqHumanWarRecommandGold = readNumber(value, this.reqHumanWarRecommandGold);
return;
case 'reqHumanWarRecommandRice':
this.reqHumanWarRecommandRice = readNumber(value, this.reqHumanWarRecommandRice);
return;
case 'reqHumanDevelGold':
this.reqHumanDevelGold = readNumber(value, this.reqHumanDevelGold);
return;
case 'reqHumanDevelRice':
this.reqHumanDevelRice = readNumber(value, this.reqHumanDevelRice);
return;
case 'reqNPCWarGold':
this.reqNpcWarGold = readNumber(value, this.reqNpcWarGold);
return;
case 'reqNPCWarRice':
this.reqNpcWarRice = readNumber(value, this.reqNpcWarRice);
return;
case 'reqNPCDevelGold':
this.reqNpcDevelGold = readNumber(value, this.reqNpcDevelGold);
return;
case 'reqNPCDevelRice':
this.reqNpcDevelRice = readNumber(value, this.reqNpcDevelRice);
return;
case 'minimumResourceActionAmount':
this.minimumResourceActionAmount = readNumber(value, this.minimumResourceActionAmount);
return;
case 'maximumResourceActionAmount':
this.maximumResourceActionAmount = readNumber(value, this.maximumResourceActionAmount);
return;
case 'minNPCWarLeadership':
this.minNpcWarLeadership = readNumber(value, this.minNpcWarLeadership);
return;
case 'minWarCrew':
this.minWarCrew = readNumber(value, this.minWarCrew);
return;
case 'minNPCRecruitCityPopulation':
this.minNpcRecruitCityPopulation = readNumber(value, this.minNpcRecruitCityPopulation);
return;
case 'safeRecruitCityPopulationRatio':
this.safeRecruitCityPopulationRatio = readNumber(value, this.safeRecruitCityPopulationRatio);
return;
case 'properWarTrainAtmos':
this.properWarTrainAtmos = readNumber(value, this.properWarTrainAtmos);
return;
case 'cureThreshold':
this.cureThreshold = readNumber(value, this.cureThreshold);
return;
default:
return;
}
}
}