427 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|