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; 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 = { 유저장긴급포상: 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 => asRecord(raw); export class AutorunGeneralPolicy { public priority: string[]; public flags: PolicyFlags; constructor( general: TurnGeneral, aiOptions: Record | null, nationPolicy: Record | null, serverPolicy: Record | 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 = {}; 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 | null; nationPolicy: Record | null; serverPolicy: Record | 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; const next: Record = {}; 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; } } }