864 lines
32 KiB
TypeScript
864 lines
32 KiB
TypeScript
import type {
|
|
City,
|
|
GeneralActionDefinition,
|
|
MapDefinition,
|
|
Nation,
|
|
ScenarioConfig,
|
|
ScenarioMeta,
|
|
TurnCommandEnv,
|
|
UnitSetDefinition,
|
|
} from '@sammo-ts/logic';
|
|
import { evaluateConstraints } from '@sammo-ts/logic';
|
|
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
|
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
|
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
|
import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
|
|
|
import type { ReservedTurnEntry } from '../reservedTurnStore.js';
|
|
import type { TurnGeneral, TurnWorldState } from '../types.js';
|
|
import type { AiCommandCandidate, AiReservedTurnProvider, AiWorldView } from './types.js';
|
|
import { AutorunGeneralPolicy, AutorunNationPolicy, AVAILABLE_INSTANT_TURN } from './policies.js';
|
|
import { asRecord, joinYearMonth, readMetaNumber, readNumber, roundTo, valueFit } from './aiUtils.js';
|
|
import { searchAllDistanceByNationList } from './distance.js';
|
|
import { generalActionHandlers } from './generalAiGeneralActions.js';
|
|
import { nationActionHandlers } from './generalAiNationActions.js';
|
|
|
|
const ACTION_REST = '휴식';
|
|
|
|
const t무장 = 1;
|
|
const t지장 = 2;
|
|
const t통솔장 = 4;
|
|
|
|
const d평화 = 0;
|
|
const d선포 = 1;
|
|
const d징병 = 2;
|
|
const d직전 = 3;
|
|
const d전쟁 = 4;
|
|
|
|
type ConstraintEnv = Record<string, unknown>;
|
|
|
|
const resolveConstraintEnv = (
|
|
world: TurnWorldState,
|
|
scenarioMeta: ScenarioMeta | undefined,
|
|
env: TurnCommandEnv
|
|
): ConstraintEnv => {
|
|
const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
|
const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined;
|
|
|
|
return {
|
|
currentYear: world.currentYear,
|
|
currentMonth: world.currentMonth,
|
|
year: world.currentYear,
|
|
month: world.currentMonth,
|
|
startYear,
|
|
relYear,
|
|
openingPartYear: env.openingPartYear,
|
|
minAvailableRecruitPop: env.minAvailableRecruitPop,
|
|
};
|
|
};
|
|
|
|
const buildSeedBase = (world: TurnWorldState): string => {
|
|
const meta = asRecord(world.meta);
|
|
const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id;
|
|
return String(rawSeed);
|
|
};
|
|
|
|
class WorldStateView implements StateView {
|
|
constructor(
|
|
private readonly world: AiWorldView | null,
|
|
private readonly env: ConstraintEnv,
|
|
private readonly args: Record<string, unknown>,
|
|
private readonly overrides?: {
|
|
general?: TurnGeneral;
|
|
city?: City;
|
|
nation?: Nation | null;
|
|
}
|
|
) {}
|
|
|
|
has(req: Parameters<StateView['has']>[0]): boolean {
|
|
return this.get(req) !== null;
|
|
}
|
|
|
|
get(req: Parameters<StateView['get']>[0]): unknown | null {
|
|
if (!this.world) {
|
|
return null;
|
|
}
|
|
switch (req.kind) {
|
|
case 'general':
|
|
if (this.overrides?.general && this.overrides.general.id === req.id) {
|
|
return this.overrides.general;
|
|
}
|
|
return this.world.getGeneralById(req.id);
|
|
case 'generalList':
|
|
return this.world.listGenerals();
|
|
case 'destGeneral':
|
|
return this.world.getGeneralById(req.id);
|
|
case 'city':
|
|
if (this.overrides?.city && this.overrides.city.id === req.id) {
|
|
return this.overrides.city;
|
|
}
|
|
return this.world.getCityById(req.id);
|
|
case 'destCity':
|
|
return this.world.getCityById(req.id);
|
|
case 'nation':
|
|
if (this.overrides?.nation && this.overrides.nation.id === req.id) {
|
|
return this.overrides.nation;
|
|
}
|
|
return this.world.getNationById(req.id);
|
|
case 'nationList':
|
|
return this.world.listNations();
|
|
case 'destNation':
|
|
return this.world.getNationById(req.id);
|
|
case 'diplomacy':
|
|
return this.world.getDiplomacyEntry(req.srcNationId, req.destNationId);
|
|
case 'diplomacyList':
|
|
return this.world.listDiplomacy();
|
|
case 'arg':
|
|
return this.args[req.key] ?? null;
|
|
case 'env':
|
|
return this.env[req.key] ?? null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface GeneralAIOptions {
|
|
general: TurnGeneral;
|
|
city?: City;
|
|
nation?: Nation | null;
|
|
world: TurnWorldState;
|
|
worldRef: AiWorldView | null;
|
|
reservedTurnProvider: AiReservedTurnProvider;
|
|
scenarioConfig: ScenarioConfig;
|
|
scenarioMeta?: ScenarioMeta;
|
|
map?: MapDefinition;
|
|
unitSet?: UnitSetDefinition;
|
|
commandEnv: TurnCommandEnv;
|
|
generalDefinitions: Map<string, GeneralActionDefinition>;
|
|
nationDefinitions: Map<string, GeneralActionDefinition>;
|
|
generalFallback: GeneralActionDefinition;
|
|
nationFallback: GeneralActionDefinition;
|
|
}
|
|
|
|
export class GeneralAI {
|
|
public readonly general: TurnGeneral;
|
|
public readonly city?: City;
|
|
public readonly nation?: Nation | null;
|
|
public readonly world: TurnWorldState;
|
|
public readonly worldRef: AiWorldView | null;
|
|
public readonly map?: MapDefinition;
|
|
public readonly unitSet?: UnitSetDefinition;
|
|
public readonly commandEnv: TurnCommandEnv;
|
|
public readonly scenarioConfig: ScenarioConfig;
|
|
public readonly scenarioMeta?: ScenarioMeta;
|
|
|
|
public readonly generalDefinitions: Map<string, GeneralActionDefinition>;
|
|
public readonly nationDefinitions: Map<string, GeneralActionDefinition>;
|
|
public readonly generalFallback: GeneralActionDefinition;
|
|
public readonly nationFallback: GeneralActionDefinition;
|
|
|
|
public readonly rng: RandUtil;
|
|
public readonly env: ConstraintEnv;
|
|
public readonly startYear: number;
|
|
public readonly turnTermMinutes: number;
|
|
|
|
public readonly aiConst: {
|
|
baseGold: number;
|
|
baseRice: number;
|
|
minAvailableRecruitPop: number;
|
|
maxResourceActionAmount: number;
|
|
minNationalGold: number;
|
|
minNationalRice: number;
|
|
defaultStatMax: number;
|
|
defaultStatNpcMax: number;
|
|
chiefStatMin: number;
|
|
npcMessageFreqByDay: number;
|
|
availableNationTypes: string[];
|
|
};
|
|
|
|
public generalPolicy: AutorunGeneralPolicy;
|
|
public nationPolicy: AutorunNationPolicy;
|
|
|
|
public genType = 0;
|
|
public dipState = d평화;
|
|
public warTargetNation: Record<number, number> = {};
|
|
public attackable = false;
|
|
public maxResourceActionAmount = 0;
|
|
|
|
public nationCities: Record<
|
|
number,
|
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
> = {};
|
|
public frontCities: Record<
|
|
number,
|
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
> = {};
|
|
public supplyCities: Record<
|
|
number,
|
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
> = {};
|
|
public backupCities: Record<
|
|
number,
|
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
> = {};
|
|
public warRoute: Record<number, Record<number, number>> | null = null;
|
|
|
|
public nationGenerals: TurnGeneral[] = [];
|
|
public npcCivilGenerals: Record<number, TurnGeneral> = {};
|
|
public npcWarGenerals: Record<number, TurnGeneral> = {};
|
|
public userGenerals: Record<number, TurnGeneral> = {};
|
|
public userWarGenerals: Record<number, TurnGeneral> = {};
|
|
public userCivilGenerals: Record<number, TurnGeneral> = {};
|
|
public chiefGenerals: Record<number, TurnGeneral> = {};
|
|
public lostGenerals: Record<number, TurnGeneral> = {};
|
|
public troopLeaders: Record<number, TurnGeneral> = {};
|
|
|
|
private reqUpdateInstance = true;
|
|
private devRate: Record<string, number> | null = null;
|
|
private categorizedCities = false;
|
|
private categorizedGenerals = false;
|
|
|
|
private readonly reservedTurnProvider: AiReservedTurnProvider;
|
|
|
|
constructor(options: GeneralAIOptions) {
|
|
this.general = options.general;
|
|
this.city = options.city;
|
|
this.nation = options.nation ?? null;
|
|
this.world = options.world;
|
|
this.worldRef = options.worldRef;
|
|
this.map = options.map;
|
|
this.unitSet = options.unitSet;
|
|
this.commandEnv = options.commandEnv;
|
|
this.scenarioConfig = options.scenarioConfig;
|
|
this.scenarioMeta = options.scenarioMeta;
|
|
this.reservedTurnProvider = options.reservedTurnProvider;
|
|
|
|
this.generalDefinitions = options.generalDefinitions;
|
|
this.nationDefinitions = options.nationDefinitions;
|
|
this.generalFallback = options.generalFallback;
|
|
this.nationFallback = options.nationFallback;
|
|
|
|
this.startYear = resolveStartYear(this.world, this.scenarioMeta);
|
|
this.turnTermMinutes = resolveTurnTermMinutes(this.world);
|
|
this.env = resolveConstraintEnv(this.world, this.scenarioMeta, this.commandEnv);
|
|
|
|
const seed = simpleSerialize(
|
|
buildSeedBase(this.world),
|
|
'GeneralAI',
|
|
this.world.currentYear,
|
|
this.world.currentMonth,
|
|
this.general.id
|
|
);
|
|
this.rng = new RandUtil(LiteHashDRBG.build(seed));
|
|
|
|
const constValues = asRecord(this.scenarioConfig.const);
|
|
this.aiConst = {
|
|
baseGold: this.commandEnv.baseGold,
|
|
baseRice: this.commandEnv.baseRice,
|
|
minAvailableRecruitPop: readNumber(constValues.minAvailableRecruitPop, 30000),
|
|
maxResourceActionAmount: this.commandEnv.maxResourceActionAmount || 10000,
|
|
minNationalGold: readNumber(constValues.minNationalGold, this.commandEnv.baseGold),
|
|
minNationalRice: readNumber(constValues.minNationalRice, this.commandEnv.baseRice),
|
|
defaultStatMax: this.scenarioConfig.stat.max,
|
|
defaultStatNpcMax: this.scenarioConfig.stat.npcMax,
|
|
chiefStatMin: this.scenarioConfig.stat.chiefMin,
|
|
npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0),
|
|
availableNationTypes: Array.isArray(constValues.availableNationType)
|
|
? constValues.availableNationType.filter((value) => typeof value === 'string')
|
|
: [],
|
|
};
|
|
|
|
const generalPolicy = new AutorunGeneralPolicy(
|
|
this.general,
|
|
asRecord((this.world.meta as Record<string, unknown>)?.autorun_user)?.options as Record<string, boolean>,
|
|
asRecord(this.nation?.meta)?.npc_general_policy as Record<string, unknown> | null,
|
|
asRecord(this.world.meta)?.npc_general_policy as Record<string, unknown> | null
|
|
);
|
|
const nationPolicy = new AutorunNationPolicy({
|
|
general: this.general,
|
|
aiOptions: asRecord((this.world.meta as Record<string, unknown>)?.autorun_user)?.options as Record<
|
|
string,
|
|
boolean
|
|
> | null,
|
|
nationPolicy: asRecord(this.nation?.meta)?.npc_nation_policy as Record<string, unknown> | null,
|
|
serverPolicy: asRecord(this.world.meta)?.npc_nation_policy as Record<string, unknown> | null,
|
|
nation: this.nation ?? {
|
|
id: 0,
|
|
name: '재야',
|
|
color: '#000000',
|
|
capitalCityId: null,
|
|
chiefGeneralId: null,
|
|
gold: 0,
|
|
rice: 0,
|
|
power: 0,
|
|
level: 0,
|
|
typeCode: 'neutral',
|
|
meta: {},
|
|
},
|
|
env: this.commandEnv,
|
|
scenarioConfig: this.scenarioConfig,
|
|
unitSet: this.unitSet,
|
|
});
|
|
|
|
this.generalPolicy = generalPolicy;
|
|
this.nationPolicy = nationPolicy;
|
|
}
|
|
|
|
chooseNationTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
|
this.updateInstance();
|
|
if (!this.nation || !this.worldRef) {
|
|
return null;
|
|
}
|
|
this.categorizeNationCities();
|
|
this.categorizeNationGeneral();
|
|
|
|
if (reservedTurn.action !== ACTION_REST) {
|
|
const reservedCandidate = this.buildNationCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
|
|
if (reservedCandidate) {
|
|
return reservedCandidate;
|
|
}
|
|
}
|
|
|
|
for (const actionName of this.nationPolicy.priority) {
|
|
if (!this.nationPolicy.can(actionName)) {
|
|
continue;
|
|
}
|
|
if (this.general.npcState < 2 && !AVAILABLE_INSTANT_TURN[actionName]) {
|
|
continue;
|
|
}
|
|
const handler = nationActionHandlers[actionName];
|
|
if (!handler) {
|
|
continue;
|
|
}
|
|
const result = handler(this);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
return this.buildNationCandidate(ACTION_REST, {}, 'neutral');
|
|
}
|
|
|
|
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
|
this.updateInstance();
|
|
if (!this.worldRef) {
|
|
return null;
|
|
}
|
|
|
|
if (this.general.npcState === 5) {
|
|
const result = generalActionHandlers['집합']?.(this);
|
|
return result ?? this.buildGeneralCandidate(ACTION_REST, {}, 'npc_troop');
|
|
}
|
|
|
|
if (reservedTurn.action !== ACTION_REST) {
|
|
const reservedCandidate = this.buildGeneralCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
|
|
if (reservedCandidate) {
|
|
return reservedCandidate;
|
|
}
|
|
}
|
|
|
|
if (
|
|
readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold
|
|
) {
|
|
const heal = this.buildGeneralCandidate('che_요양', {}, 'heal');
|
|
if (heal) {
|
|
return heal;
|
|
}
|
|
}
|
|
|
|
if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) {
|
|
const rebellion = generalActionHandlers['거병']?.(this);
|
|
if (rebellion) {
|
|
return rebellion;
|
|
}
|
|
}
|
|
|
|
if (this.general.nationId === 0 && this.generalPolicy.can('국가선택')) {
|
|
const pickNation = generalActionHandlers['국가선택']?.(this);
|
|
if (pickNation) {
|
|
return pickNation;
|
|
}
|
|
const neutral = generalActionHandlers['중립']?.(this);
|
|
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
|
|
}
|
|
|
|
if (this.general.npcState < 2 && this.general.nationId === 0 && !this.generalPolicy.can('국가선택')) {
|
|
return this.buildGeneralCandidate(ACTION_REST, {}, 'neutral_user');
|
|
}
|
|
|
|
if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) {
|
|
const worldMeta = asRecord(this.world.meta);
|
|
const initYear = readNumber(worldMeta.initYear, this.scenarioMeta?.startYear ?? this.startYear);
|
|
const initMonth = readNumber(worldMeta.initMonth, 1);
|
|
const relYearMonth =
|
|
joinYearMonth(this.world.currentYear, this.world.currentMonth) - joinYearMonth(initYear, initMonth);
|
|
if (relYearMonth > 1) {
|
|
const establish = generalActionHandlers['건국']?.(this);
|
|
if (establish) {
|
|
return establish;
|
|
}
|
|
}
|
|
const move = generalActionHandlers['방랑군이동']?.(this);
|
|
if (move) {
|
|
return move;
|
|
}
|
|
}
|
|
|
|
for (const actionName of this.generalPolicy.priority) {
|
|
if (!this.generalPolicy.can(actionName)) {
|
|
continue;
|
|
}
|
|
const handler = generalActionHandlers[actionName];
|
|
if (!handler) {
|
|
continue;
|
|
}
|
|
const result = handler(this);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
const neutral = generalActionHandlers['중립']?.(this);
|
|
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
|
|
}
|
|
|
|
buildGeneralCandidate(action: string, args: Record<string, unknown>, reason: string): AiCommandCandidate | null {
|
|
return this.buildCandidate(this.generalDefinitions, this.generalFallback, action, args, reason);
|
|
}
|
|
|
|
buildNationCandidate(action: string, args: Record<string, unknown>, reason: string): AiCommandCandidate | null {
|
|
return this.buildCandidate(this.nationDefinitions, this.nationFallback, action, args, reason);
|
|
}
|
|
|
|
getReservedTurn(generalId: number): ReservedTurnEntry {
|
|
return this.reservedTurnProvider.getGeneralTurn(generalId, 0);
|
|
}
|
|
|
|
calcNationDevelopedRate(): Record<string, number> {
|
|
if (this.devRate) {
|
|
return this.devRate;
|
|
}
|
|
this.categorizeNationCities();
|
|
const devRate: Record<string, number> = { all: 0 };
|
|
const cities = Object.values(this.supplyCities);
|
|
if (cities.length === 0) {
|
|
this.devRate = devRate;
|
|
return devRate;
|
|
}
|
|
|
|
for (const city of cities) {
|
|
const entries = this.calcCityDevelRate(city);
|
|
for (const [key, [score]] of Object.entries(entries)) {
|
|
if (key === 'trust') {
|
|
continue;
|
|
}
|
|
devRate[key] = (devRate[key] ?? 0) + score;
|
|
devRate.all += score;
|
|
}
|
|
}
|
|
for (const key of Object.keys(devRate)) {
|
|
devRate[key] /= cities.length;
|
|
}
|
|
devRate.all /= Math.max(1, Object.keys(devRate).length - 1);
|
|
this.devRate = devRate;
|
|
return devRate;
|
|
}
|
|
|
|
calcCityDevelRate(city: City): Record<string, [number, number]> {
|
|
const trust = readMetaNumber(asRecord(city.meta), 'trust', 0) / 100;
|
|
return {
|
|
trust: [trust, t통솔장],
|
|
pop: [city.populationMax > 0 ? city.population / city.populationMax : 0, t통솔장],
|
|
agri: [city.agricultureMax > 0 ? city.agriculture / city.agricultureMax : 0, t지장],
|
|
comm: [city.commerceMax > 0 ? city.commerce / city.commerceMax : 0, t지장],
|
|
secu: [city.securityMax > 0 ? city.security / city.securityMax : 0, t무장],
|
|
def: [city.defenceMax > 0 ? city.defence / city.defenceMax : 0, t무장],
|
|
wall: [city.wallMax > 0 ? city.wall / city.wallMax : 0, t무장],
|
|
};
|
|
}
|
|
|
|
calcWarRoute(): void {
|
|
if (this.warRoute || !this.map || !this.worldRef) {
|
|
return;
|
|
}
|
|
const target = Object.keys(this.warTargetNation).map((key) => Number(key));
|
|
if (this.nation) {
|
|
target.push(this.nation.id);
|
|
}
|
|
this.warRoute = searchAllDistanceByNationList(this.map, this.worldRef.listCities(), target, false);
|
|
}
|
|
|
|
categorizeNationCities(): void {
|
|
if (this.categorizedCities) {
|
|
return;
|
|
}
|
|
this.categorizedCities = true;
|
|
|
|
if (!this.nation || !this.worldRef) {
|
|
return;
|
|
}
|
|
|
|
const nationId = this.nation.id;
|
|
const nationCities: Record<
|
|
number,
|
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
> = {};
|
|
const frontCities: Record<
|
|
number,
|
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
> = {};
|
|
const supplyCities: Record<
|
|
number,
|
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
> = {};
|
|
const backupCities: Record<
|
|
number,
|
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
> = {};
|
|
|
|
for (const city of this.worldRef.listCities()) {
|
|
if (city.nationId !== nationId) {
|
|
continue;
|
|
}
|
|
const max = city.agricultureMax + city.commerceMax + city.securityMax + city.defenceMax + city.wallMax;
|
|
const dev =
|
|
max > 0 ? (city.agriculture + city.commerce + city.security + city.defence + city.wall) / max : 0;
|
|
const entry = { ...city, dev, important: 1, generals: {} as Record<number, TurnGeneral> };
|
|
nationCities[city.id] = entry;
|
|
if (city.supplyState > 0) {
|
|
supplyCities[city.id] = entry;
|
|
if (city.frontState <= 0) {
|
|
backupCities[city.id] = entry;
|
|
}
|
|
}
|
|
if (city.frontState > 0) {
|
|
frontCities[city.id] = entry;
|
|
}
|
|
}
|
|
|
|
this.nationCities = nationCities;
|
|
this.frontCities = frontCities;
|
|
this.supplyCities = supplyCities;
|
|
this.backupCities = backupCities;
|
|
}
|
|
|
|
categorizeNationGeneral(): void {
|
|
if (this.categorizedGenerals) {
|
|
return;
|
|
}
|
|
this.categorizedGenerals = true;
|
|
|
|
if (!this.nation || !this.worldRef) {
|
|
return;
|
|
}
|
|
|
|
this.categorizeNationCities();
|
|
|
|
const nationId = this.nation.id;
|
|
const nationGenerals = this.worldRef
|
|
.listGenerals()
|
|
.filter((general) => general.nationId === nationId && general.id !== this.general.id);
|
|
|
|
const userGenerals: Record<number, TurnGeneral> = {};
|
|
const userCivilGenerals: Record<number, TurnGeneral> = {};
|
|
const userWarGenerals: Record<number, TurnGeneral> = {};
|
|
const lostGenerals: Record<number, TurnGeneral> = {};
|
|
const npcCivilGenerals: Record<number, TurnGeneral> = {};
|
|
const npcWarGenerals: Record<number, TurnGeneral> = {};
|
|
const troopLeaders: Record<number, TurnGeneral> = {};
|
|
const chiefGenerals: Record<number, TurnGeneral> = {};
|
|
|
|
let lastWar = Number.MAX_SAFE_INTEGER;
|
|
for (const candidate of nationGenerals) {
|
|
const belong = readMetaNumber(asRecord(candidate.meta), 'belong', 0);
|
|
const recentWarTurn = this.calcRecentWarTurn(candidate);
|
|
if (belong > 0 && recentWarTurn >= (belong - 1) * 12) {
|
|
continue;
|
|
}
|
|
lastWar = Math.min(lastWar, recentWarTurn);
|
|
}
|
|
|
|
for (const candidate of nationGenerals) {
|
|
const officerLevel = candidate.officerLevel;
|
|
const npcType = candidate.npcState;
|
|
const officerCity = readMetaNumber(
|
|
asRecord(candidate.meta),
|
|
'officer_city',
|
|
readMetaNumber(asRecord(candidate.meta), 'officerCity', 0)
|
|
);
|
|
|
|
if (officerLevel > 4) {
|
|
chiefGenerals[officerLevel] = candidate;
|
|
} else if (officerLevel >= 2 && officerCity > 0 && this.nationCities[officerCity]) {
|
|
this.nationCities[officerCity].important += 1;
|
|
}
|
|
|
|
const cityId = candidate.cityId;
|
|
const city = this.nationCities[cityId];
|
|
if (city) {
|
|
city.generals ??= {};
|
|
city.generals[candidate.id] = candidate;
|
|
if (city.supplyState <= 0) {
|
|
lostGenerals[candidate.id] = candidate;
|
|
}
|
|
} else {
|
|
lostGenerals[candidate.id] = candidate;
|
|
}
|
|
|
|
const isTroopLeader =
|
|
npcType === 5 ||
|
|
(candidate.troopId === candidate.id && this.getReservedTurn(candidate.id).action === 'che_집합');
|
|
if (isTroopLeader) {
|
|
troopLeaders[candidate.id] = candidate;
|
|
continue;
|
|
}
|
|
|
|
const killturn = readMetaNumber(asRecord(candidate.meta), 'killturn', 999);
|
|
if (killturn <= 5) {
|
|
npcCivilGenerals[candidate.id] = candidate;
|
|
continue;
|
|
}
|
|
|
|
if (npcType < 2) {
|
|
userGenerals[candidate.id] = candidate;
|
|
const recentWarTurn = this.calcRecentWarTurn(candidate);
|
|
if (recentWarTurn <= lastWar + 12) {
|
|
userWarGenerals[candidate.id] = candidate;
|
|
} else if (this.dipState !== d평화 && candidate.crew >= this.nationPolicy.minWarCrew) {
|
|
userWarGenerals[candidate.id] = candidate;
|
|
} else {
|
|
userCivilGenerals[candidate.id] = candidate;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (candidate.stats.leadership >= this.nationPolicy.minNpcWarLeadership) {
|
|
npcWarGenerals[candidate.id] = candidate;
|
|
} else {
|
|
npcCivilGenerals[candidate.id] = candidate;
|
|
}
|
|
}
|
|
|
|
this.nationGenerals = nationGenerals;
|
|
this.userGenerals = userGenerals;
|
|
this.userCivilGenerals = userCivilGenerals;
|
|
this.userWarGenerals = userWarGenerals;
|
|
this.lostGenerals = lostGenerals;
|
|
this.npcCivilGenerals = npcCivilGenerals;
|
|
this.npcWarGenerals = npcWarGenerals;
|
|
this.troopLeaders = troopLeaders;
|
|
this.chiefGenerals = chiefGenerals;
|
|
}
|
|
|
|
private updateInstance(): void {
|
|
if (!this.reqUpdateInstance) {
|
|
return;
|
|
}
|
|
this.reqUpdateInstance = false;
|
|
|
|
const nation = this.nation;
|
|
if (!nation) {
|
|
return;
|
|
}
|
|
|
|
const baseDevelCost = this.commandEnv.develCost * 12;
|
|
const nationMeta = asRecord(nation.meta);
|
|
const prevIncomeGold = readMetaNumber(nationMeta, 'prev_income_gold', 1000);
|
|
const prevIncomeRice = readMetaNumber(nationMeta, 'prev_income_rice', 1000);
|
|
const elapsedYears = this.world.currentYear - this.startYear - 3;
|
|
const maxCandidate = Math.max(
|
|
this.nationPolicy.minimumResourceActionAmount,
|
|
prevIncomeGold / 10,
|
|
prevIncomeRice / 10,
|
|
nation.gold / 5,
|
|
nation.rice / 5,
|
|
elapsedYears * 1000
|
|
);
|
|
this.maxResourceActionAmount = valueFit(
|
|
roundTo(maxCandidate, -2),
|
|
null,
|
|
this.nationPolicy.maximumResourceActionAmount
|
|
);
|
|
if (this.maxResourceActionAmount > this.aiConst.maxResourceActionAmount) {
|
|
this.maxResourceActionAmount = this.aiConst.maxResourceActionAmount;
|
|
}
|
|
|
|
this.calcDiplomacyState();
|
|
this.genType = this.calcGenType();
|
|
|
|
void baseDevelCost;
|
|
}
|
|
|
|
private buildCandidate(
|
|
definitions: Map<string, GeneralActionDefinition>,
|
|
fallback: GeneralActionDefinition,
|
|
action: string,
|
|
args: Record<string, unknown>,
|
|
reason: string
|
|
): AiCommandCandidate | null {
|
|
const definition = definitions.get(action) ?? fallback;
|
|
const parsedArgs = definition.parseArgs(args);
|
|
if (parsedArgs === null) {
|
|
return null;
|
|
}
|
|
const constraintEnv = this.buildConstraintEnv();
|
|
const ctx: ConstraintContext = {
|
|
actorId: this.general.id,
|
|
cityId: this.city?.id,
|
|
nationId: this.general.nationId,
|
|
args: parsedArgs as Record<string, unknown>,
|
|
env: constraintEnv,
|
|
mode: 'full',
|
|
};
|
|
const view = new WorldStateView(this.worldRef, constraintEnv, parsedArgs as Record<string, unknown>, {
|
|
general: this.general,
|
|
city: this.city,
|
|
nation: this.nation ?? null,
|
|
});
|
|
const constraints = definition.buildConstraints(ctx, parsedArgs as never);
|
|
const result = evaluateConstraints(constraints, ctx, view);
|
|
if (result.kind !== 'allow') {
|
|
return null;
|
|
}
|
|
return {
|
|
action: definition.key,
|
|
args: parsedArgs as Record<string, unknown>,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
private buildConstraintEnv(): ConstraintEnv {
|
|
return {
|
|
...this.env,
|
|
cities: this.worldRef?.listCities() ?? [],
|
|
nations: this.worldRef?.listNations() ?? [],
|
|
map: this.map,
|
|
unitSet: this.unitSet,
|
|
};
|
|
}
|
|
|
|
private calcGenType(): number {
|
|
const leadership = this.general.stats.leadership;
|
|
const strength = Math.max(this.general.stats.strength, 1);
|
|
const intel = Math.max(this.general.stats.intelligence, 1);
|
|
let genType = 0;
|
|
|
|
if (strength >= intel) {
|
|
genType = t무장;
|
|
if (intel >= strength * 0.8) {
|
|
if (this.rng.nextBool(intel / strength / 2)) {
|
|
genType |= t지장;
|
|
}
|
|
}
|
|
} else {
|
|
genType = t지장;
|
|
if (strength >= intel * 0.8) {
|
|
if (this.rng.nextBool(strength / intel / 2)) {
|
|
genType |= t무장;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (leadership >= this.nationPolicy.minNpcWarLeadership) {
|
|
genType |= t통솔장;
|
|
}
|
|
|
|
return genType;
|
|
}
|
|
|
|
private calcDiplomacyState(): void {
|
|
if (!this.nation || !this.worldRef) {
|
|
return;
|
|
}
|
|
const nationId = this.nation.id;
|
|
const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth);
|
|
const startYearMonth = joinYearMonth(this.startYear + 2, 5);
|
|
|
|
const warTargets = this.worldRef
|
|
.listDiplomacy()
|
|
.filter((entry) => entry.fromNationId === nationId && (entry.state === 0 || entry.state === 1));
|
|
|
|
if (yearMonth <= startYearMonth) {
|
|
this.dipState = warTargets.length === 0 ? d평화 : d선포;
|
|
this.attackable = false;
|
|
return;
|
|
}
|
|
|
|
const frontStatus = this.worldRef
|
|
.listCities()
|
|
.some((city) => city.nationId === nationId && city.supplyState > 0 && city.frontState > 0);
|
|
this.attackable = frontStatus;
|
|
|
|
let onWar = 0;
|
|
let onWarReady = 0;
|
|
const warTargetNation: Record<number, number> = {};
|
|
for (const entry of warTargets) {
|
|
if (entry.state === 0) {
|
|
onWar += 1;
|
|
warTargetNation[entry.toNationId] = 2;
|
|
} else if (entry.state === 1 && entry.term < 5) {
|
|
onWarReady += 1;
|
|
warTargetNation[entry.toNationId] = 1;
|
|
}
|
|
}
|
|
if (onWar === 0 && onWarReady === 0) {
|
|
warTargetNation[0] = 1;
|
|
}
|
|
this.warTargetNation = warTargetNation;
|
|
|
|
const declareTerms = warTargets.filter((entry) => entry.state === 1).map((entry) => entry.term);
|
|
const minWarTerm = declareTerms.length > 0 ? Math.min(...declareTerms) : null;
|
|
if (minWarTerm === null) {
|
|
this.dipState = d평화;
|
|
} else if (minWarTerm > 8) {
|
|
this.dipState = d선포;
|
|
} else if (minWarTerm > 5) {
|
|
this.dipState = d징병;
|
|
} else {
|
|
this.dipState = d직전;
|
|
}
|
|
|
|
const meta = asRecord(this.nation.meta);
|
|
const lastAttackable = readMetaNumber(meta, 'last_attackable', 0);
|
|
if (Object.prototype.hasOwnProperty.call(warTargetNation, 0) && this.attackable) {
|
|
this.dipState = d전쟁;
|
|
} else if (onWar > 0) {
|
|
if (this.attackable) {
|
|
this.dipState = d전쟁;
|
|
} else if (lastAttackable >= yearMonth - 5) {
|
|
this.dipState = d전쟁;
|
|
}
|
|
}
|
|
}
|
|
|
|
private calcRecentWarTurn(general: TurnGeneral): number {
|
|
const recent = general.recentWarTime;
|
|
if (!recent) {
|
|
return 12000;
|
|
}
|
|
const diffMs = general.turnTime.getTime() - recent.getTime();
|
|
if (diffMs <= 0) {
|
|
return 0;
|
|
}
|
|
const turnMs = this.turnTermMinutes * 60 * 1000;
|
|
if (turnMs <= 0) {
|
|
return 12000;
|
|
}
|
|
return Math.floor(diffMs / turnMs);
|
|
}
|
|
}
|
|
|
|
export const shouldUseAi = (general: TurnGeneral, world: TurnWorldState): boolean => {
|
|
if (general.npcState >= 2) {
|
|
return true;
|
|
}
|
|
const meta = asRecord(general.meta);
|
|
const limit = readMetaNumber(meta, 'autorun_limit', 0);
|
|
if (limit <= 0) {
|
|
return false;
|
|
}
|
|
const current = joinYearMonth(world.currentYear, world.currentMonth);
|
|
return current < limit;
|
|
};
|