feat(ai): add autorun policies for general and nation management
- Introduced `policies.ts` to define AI policies for generals and nations, including priority actions and flags. - Implemented `AutorunGeneralPolicy` class to manage general-specific actions based on AI options and server/nation policies. - Implemented `AutorunNationPolicy` class to handle nation-specific actions, including resource requirements and combat strategies. - Added types for AI context and world view in `types.ts` to facilitate interaction with game state and entities.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import type { City, Nation } from '@sammo-ts/logic';
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
export const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
||||
|
||||
export const readNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const readBoolean = (value: unknown, fallback = false): boolean => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const readString = (value: unknown, fallback = ''): string => (typeof value === 'string' ? value : fallback);
|
||||
|
||||
export const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback = 0): number =>
|
||||
readNumber(meta[key], fallback);
|
||||
|
||||
export const readMetaString = (meta: Record<string, unknown>, key: string, fallback = ''): string =>
|
||||
readString(meta[key], fallback);
|
||||
|
||||
export const readMetaBoolean = (meta: Record<string, unknown>, key: string, fallback = false): boolean =>
|
||||
readBoolean(meta[key], fallback);
|
||||
|
||||
export const valueFit = (value: number, min?: number | null, max?: number | null): number => {
|
||||
let next = value;
|
||||
if (min !== null && min !== undefined && next < min) {
|
||||
next = min;
|
||||
}
|
||||
if (max !== null && max !== undefined && next > max) {
|
||||
next = max;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const roundTo = (value: number, digits = 0): number => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
const factor = Math.pow(10, Math.abs(digits));
|
||||
if (digits >= 0) {
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
return Math.round(value / factor) * factor;
|
||||
};
|
||||
|
||||
export const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
||||
|
||||
export const parseYearMonth = (value: number): [number, number] => {
|
||||
const year = Math.floor(value / 12);
|
||||
const month = (value % 12) + 1;
|
||||
return [year, month];
|
||||
};
|
||||
|
||||
export const calcCityDevRatio = (city: City): number => {
|
||||
const total = city.agriculture + city.commerce + city.security + city.defence + city.wall;
|
||||
const max = city.agricultureMax + city.commerceMax + city.securityMax + city.defenceMax + city.wallMax;
|
||||
if (max <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return total / max;
|
||||
};
|
||||
|
||||
export const readNationTech = (nation: Nation | null | undefined): number => {
|
||||
if (!nation) {
|
||||
return 0;
|
||||
}
|
||||
return readMetaNumber(asRecord(nation.meta), 'tech', 0);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { City, MapDefinition } from '@sammo-ts/logic';
|
||||
|
||||
const buildConnectionMap = (map: MapDefinition): Map<number, number[]> => {
|
||||
const result = new Map<number, number[]>();
|
||||
for (const city of map.cities) {
|
||||
result.set(city.id, city.connections ?? []);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const searchAllDistanceByCityList = (
|
||||
map: MapDefinition,
|
||||
cityIds: number[]
|
||||
): Record<number, Record<number, number>> => {
|
||||
if (cityIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const connectionMap = buildConnectionMap(map);
|
||||
const citySet = new Set(cityIds);
|
||||
const result: Record<number, Record<number, number>> = {};
|
||||
|
||||
for (const startId of citySet) {
|
||||
const distances: Record<number, number> = { [startId]: 0 };
|
||||
const queue: Array<[number, number]> = [[startId, 0]];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const [currentId, dist] = queue.shift()!;
|
||||
const connections = connectionMap.get(currentId) ?? [];
|
||||
for (const nextId of connections) {
|
||||
if (!citySet.has(nextId)) {
|
||||
continue;
|
||||
}
|
||||
if (distances[nextId] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
distances[nextId] = dist + 1;
|
||||
queue.push([nextId, dist + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
result[startId] = distances;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const searchAllDistanceByNationList = (
|
||||
map: MapDefinition,
|
||||
cities: City[],
|
||||
nationIds: number[],
|
||||
suppliedCityOnly: boolean
|
||||
): Record<number, Record<number, number>> => {
|
||||
if (nationIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const cityIds = cities
|
||||
.filter((city) => nationIds.includes(city.nationId))
|
||||
.filter((city) => !suppliedCityOnly || city.supplyState > 0)
|
||||
.map((city) => city.id);
|
||||
return searchAllDistanceByCityList(map, cityIds);
|
||||
};
|
||||
|
||||
export const isNeighbor = (
|
||||
map: MapDefinition,
|
||||
cities: City[],
|
||||
nationA: number,
|
||||
nationB: number,
|
||||
includeNoSupply = true
|
||||
): boolean => {
|
||||
if (nationA === nationB) {
|
||||
return false;
|
||||
}
|
||||
const connectionMap = buildConnectionMap(map);
|
||||
const nationACities = new Set(
|
||||
cities
|
||||
.filter((city) => city.nationId === nationA)
|
||||
.filter((city) => includeNoSupply || city.supplyState > 0)
|
||||
.map((city) => city.id)
|
||||
);
|
||||
|
||||
const nationBCities = cities
|
||||
.filter((city) => city.nationId === nationB)
|
||||
.filter((city) => includeNoSupply || city.supplyState > 0)
|
||||
.map((city) => city.id);
|
||||
|
||||
for (const cityId of nationBCities) {
|
||||
const connections = connectionMap.get(cityId) ?? [];
|
||||
for (const adjId of connections) {
|
||||
if (nationACities.has(adjId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,860 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
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 relYearMonth =
|
||||
joinYearMonth(this.world.currentYear, this.world.currentMonth) -
|
||||
joinYearMonth(this.scenarioMeta?.startYear ?? this.startYear, 1);
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,943 @@
|
||||
import type { City } from '@sammo-ts/logic';
|
||||
import { findCrewTypeById, getTechCost, isCrewTypeAvailable } from '@sammo-ts/logic/world/unitSet.js';
|
||||
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
|
||||
|
||||
import type { GeneralAI } from './generalAi.js';
|
||||
import { asRecord, readMetaNumber, roundTo, valueFit } from './aiUtils.js';
|
||||
|
||||
const ACTION_REST = '휴식';
|
||||
|
||||
const t무장 = 1;
|
||||
const t지장 = 2;
|
||||
const t통솔장 = 4;
|
||||
|
||||
const resolveCityTrust = (city: City): number => readMetaNumber(asRecord(city.meta), 'trust', 0);
|
||||
|
||||
const pickWeightedCandidate = (
|
||||
ai: GeneralAI,
|
||||
list: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]>
|
||||
) => {
|
||||
const items = list.filter(([item]) => Boolean(item)) as Array<
|
||||
[ReturnType<GeneralAI['buildGeneralCandidate']>, number]
|
||||
>;
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const picked = ai.rng.choiceUsingWeightPair(items);
|
||||
return picked ?? null;
|
||||
};
|
||||
|
||||
export const do일반내정 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
const nation = ai.nation;
|
||||
if (!city || !nation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nation.rice < ai.aiConst.baseRice && ai.rng.nextBool(0.3)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const develRate = ai.calcCityDevelRate(city);
|
||||
const isSpringSummer = ai.world.currentMonth <= 6;
|
||||
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
|
||||
|
||||
if (ai.genType & t통솔장) {
|
||||
if (develRate.trust[0] < 0.98) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_주민선정', {}, '일반내정'),
|
||||
(ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
|
||||
]);
|
||||
}
|
||||
if (develRate.pop[0] < 0.8) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'),
|
||||
ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001),
|
||||
]);
|
||||
} else if (develRate.pop[0] < 0.99) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'),
|
||||
ai.general.stats.leadership / valueFit(develRate.pop[0] / 4, 0.001),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (ai.genType & t무장) {
|
||||
if (develRate.def[0] < 1) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_수비강화', {}, '일반내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.def[0], 0.001),
|
||||
]);
|
||||
}
|
||||
if (develRate.wall[0] < 1) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_성벽보수', {}, '일반내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.wall[0], 0.001),
|
||||
]);
|
||||
}
|
||||
if (develRate.secu[0] < 0.9) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1),
|
||||
]);
|
||||
} else if (develRate.secu[0] < 1) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'),
|
||||
ai.general.stats.strength / 2 / valueFit(develRate.secu[0], 0.001),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (ai.genType & t지장) {
|
||||
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), ai.general.stats.intelligence]);
|
||||
if (develRate.agri[0] < 1) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'),
|
||||
((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / valueFit(develRate.agri[0], 0.001, 1),
|
||||
]);
|
||||
}
|
||||
if (develRate.comm[0] < 1) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_상업투자', {}, '일반내정'),
|
||||
((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / valueFit(develRate.comm[0], 0.001, 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, cmdList);
|
||||
};
|
||||
|
||||
export const do긴급내정 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
if (!city) {
|
||||
return null;
|
||||
}
|
||||
if (ai.dipState === 0) {
|
||||
return null;
|
||||
}
|
||||
const trust = resolveCityTrust(city);
|
||||
if (trust < 70 && ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin)) {
|
||||
return ai.buildGeneralCandidate('che_주민선정', {}, '긴급내정');
|
||||
}
|
||||
if (
|
||||
city.population < ai.nationPolicy.minNpcRecruitCityPopulation &&
|
||||
ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin / 2)
|
||||
) {
|
||||
return ai.buildGeneralCandidate('che_정착장려', {}, '긴급내정');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const do전쟁내정 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
const nation = ai.nation;
|
||||
if (!city || !nation) {
|
||||
return null;
|
||||
}
|
||||
if (ai.dipState === 0) {
|
||||
return null;
|
||||
}
|
||||
if (nation.rice < ai.aiConst.baseRice && ai.rng.nextBool(0.3)) {
|
||||
return null;
|
||||
}
|
||||
if (ai.rng.nextBool(0.3)) {
|
||||
return null;
|
||||
}
|
||||
const develRate = ai.calcCityDevelRate(city);
|
||||
const isSpringSummer = ai.world.currentMonth <= 6;
|
||||
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
|
||||
|
||||
if (ai.genType & t통솔장) {
|
||||
if (develRate.trust[0] < 0.98) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_주민선정', {}, '전쟁내정'),
|
||||
(ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
|
||||
]);
|
||||
}
|
||||
if (develRate.pop[0] < 0.8) {
|
||||
const weight =
|
||||
city.frontState > 0
|
||||
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
|
||||
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
|
||||
cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]);
|
||||
}
|
||||
}
|
||||
|
||||
if (ai.genType & t무장) {
|
||||
if (develRate.def[0] < 0.5) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_수비강화', {}, '전쟁내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.def[0], 0.001) / 2,
|
||||
]);
|
||||
}
|
||||
if (develRate.wall[0] < 0.5) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_성벽보수', {}, '전쟁내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.wall[0], 0.001) / 2,
|
||||
]);
|
||||
}
|
||||
if (develRate.secu[0] < 0.5) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_치안강화', {}, '전쟁내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (ai.genType & t지장) {
|
||||
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), ai.general.stats.intelligence]);
|
||||
if (develRate.agri[0] < 0.5) {
|
||||
const weight =
|
||||
city.frontState > 0
|
||||
? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
|
||||
4 /
|
||||
valueFit(develRate.agri[0], 0.001, 1)
|
||||
: ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
|
||||
2 /
|
||||
valueFit(develRate.agri[0], 0.001, 1);
|
||||
cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]);
|
||||
}
|
||||
if (develRate.comm[0] < 0.5) {
|
||||
const weight =
|
||||
city.frontState > 0
|
||||
? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
|
||||
4 /
|
||||
valueFit(develRate.comm[0], 0.001, 1)
|
||||
: ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
|
||||
2 /
|
||||
valueFit(develRate.comm[0], 0.001, 1);
|
||||
cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, cmdList);
|
||||
};
|
||||
|
||||
export const do금쌀구매 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
if (!city) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trade = readMetaNumber(asRecord(city.meta), 'trade', 0);
|
||||
if (trade === 0 && !ai.generalPolicy.can('상인무시')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kill = readMetaNumber(asRecord(ai.general.meta), 'killcrew', 50000) + 50000;
|
||||
const death = readMetaNumber(asRecord(ai.general.meta), 'deathcrew', 50000) + 50000;
|
||||
const deathRate = death / kill;
|
||||
|
||||
const absGold = ai.general.gold;
|
||||
const absRice = ai.general.rice;
|
||||
const relGold = absGold;
|
||||
const relRice = absRice * deathRate;
|
||||
|
||||
const baseDevelCost = ai.commandEnv.develCost * 12;
|
||||
if (absGold + absRice < baseDevelCost * 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const crewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
|
||||
const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0);
|
||||
const fullLeadership = ai.general.stats.leadership;
|
||||
const crewAmount = fullLeadership * 100;
|
||||
const goldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0;
|
||||
const riceCost = crewAmount / 100;
|
||||
|
||||
if ((relGold + relRice) * 1.5 <= goldCost + riceCost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ai.general.npcState < 2 && relGold >= goldCost * 3 && relRice >= riceCost * 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tryBuying = false;
|
||||
if (ai.generalPolicy.can('상인무시')) {
|
||||
if (relRice * 1.5 < relGold && relRice < riceCost * 2) {
|
||||
tryBuying = true;
|
||||
} else if (relRice * 2 < relGold) {
|
||||
tryBuying = true;
|
||||
}
|
||||
} else if (relRice * 2 < relGold && relRice < riceCost * 3) {
|
||||
tryBuying = true;
|
||||
}
|
||||
|
||||
if (tryBuying) {
|
||||
const amount = valueFit(Math.floor((relGold - relRice) / (1 + deathRate)), 100, ai.maxResourceActionAmount);
|
||||
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
|
||||
return ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매');
|
||||
}
|
||||
}
|
||||
|
||||
let trySelling = false;
|
||||
if (ai.generalPolicy.can('상인무시')) {
|
||||
if (relGold * 1.5 < relRice && relGold < goldCost * 2) {
|
||||
trySelling = true;
|
||||
} else if (relGold * 2 < relRice) {
|
||||
trySelling = true;
|
||||
}
|
||||
} else if (relGold * 2 < relRice && relGold < goldCost * 3) {
|
||||
trySelling = true;
|
||||
}
|
||||
|
||||
if (trySelling) {
|
||||
const amount = valueFit(Math.floor((relRice - relGold) / (1 + deathRate)), 100, ai.maxResourceActionAmount);
|
||||
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
|
||||
return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const do징병 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
const nation = ai.nation;
|
||||
if (!city || !nation || !ai.unitSet || !ai.map) {
|
||||
return null;
|
||||
}
|
||||
if ([0, 1].includes(ai.dipState)) {
|
||||
return null;
|
||||
}
|
||||
if (!(ai.genType & t통솔장)) {
|
||||
return null;
|
||||
}
|
||||
if (ai.general.crew >= ai.nationPolicy.minWarCrew) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ai.generalPolicy.can('한계징병')) {
|
||||
const remainPop =
|
||||
city.population - ai.nationPolicy.minNpcRecruitCityPopulation - ai.general.stats.leadership * 100;
|
||||
if (remainPop <= 0) {
|
||||
return null;
|
||||
}
|
||||
const maxPop = city.populationMax - ai.nationPolicy.minNpcRecruitCityPopulation;
|
||||
if (
|
||||
city.population / city.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio &&
|
||||
ai.rng.nextBool(remainPop / Math.max(1, maxPop))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
|
||||
const crewAmountBase = ai.general.stats.leadership * 100;
|
||||
const armType =
|
||||
readMetaNumber(asRecord(ai.general.meta), 'armType', 0) ||
|
||||
(ai.general.stats.strength >= ai.general.stats.intelligence * 0.9 ? 1 : 4);
|
||||
|
||||
const candidates = (ai.unitSet?.crewTypes ?? [])
|
||||
.filter((crew) => crew.armType === armType)
|
||||
.filter((crew) =>
|
||||
isCrewTypeAvailable(ai.unitSet!, crew.id, {
|
||||
general: ai.general,
|
||||
nation,
|
||||
map: ai.map!,
|
||||
cities: ai.worldRef?.listCities() ?? [],
|
||||
currentYear: ai.world.currentYear,
|
||||
startYear: ai.startYear,
|
||||
})
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const picked = ai.rng.choiceUsingWeightPair(candidates.map((crew) => [crew, Math.max(1, crew.cost)]));
|
||||
const crewTypeId = picked.id;
|
||||
|
||||
let crewAmount = crewAmountBase;
|
||||
const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
|
||||
const riceCost = crewAmount / 100;
|
||||
|
||||
if (ai.generalPolicy.can('모병') && ai.general.gold >= goldCost * 6) {
|
||||
const hire = ai.buildGeneralCandidate('che_모병', { crewType: crewTypeId, amount: crewAmount }, '징병');
|
||||
if (hire) {
|
||||
return hire;
|
||||
}
|
||||
}
|
||||
|
||||
if (ai.general.gold < goldCost && ai.general.gold * 2 >= goldCost) {
|
||||
crewAmount *= 0.5;
|
||||
crewAmount = roundTo(crewAmount - 49, -2);
|
||||
}
|
||||
|
||||
if (!ai.generalPolicy.can('한계징병') && ai.general.rice * 1.1 <= riceCost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병');
|
||||
};
|
||||
|
||||
export const do전투준비 = (ai: GeneralAI) => {
|
||||
if ([0, 1].includes(ai.dipState)) {
|
||||
return null;
|
||||
}
|
||||
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
|
||||
if (ai.general.train < ai.nationPolicy.properWarTrainAtmos) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_훈련', {}, '전투준비'),
|
||||
ai.commandEnv.maxTrainByCommand / valueFit(ai.general.train, 1),
|
||||
]);
|
||||
}
|
||||
if (ai.general.atmos < ai.nationPolicy.properWarTrainAtmos) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_사기진작', {}, '전투준비'),
|
||||
ai.commandEnv.maxAtmosByCommand / valueFit(ai.general.atmos, 1),
|
||||
]);
|
||||
}
|
||||
return pickWeightedCandidate(ai, cmdList);
|
||||
};
|
||||
|
||||
export const do소집해제 = (ai: GeneralAI) => {
|
||||
if (ai.attackable) {
|
||||
return null;
|
||||
}
|
||||
if (ai.dipState !== 0) {
|
||||
return null;
|
||||
}
|
||||
if (ai.general.crew === 0) {
|
||||
return null;
|
||||
}
|
||||
if (ai.rng.nextBool(0.75)) {
|
||||
return null;
|
||||
}
|
||||
return ai.buildGeneralCandidate('che_소집해제', {}, '소집해제');
|
||||
};
|
||||
|
||||
export const do출병 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
const nation = ai.nation;
|
||||
if (!city || !nation || !ai.map || !ai.worldRef) {
|
||||
return null;
|
||||
}
|
||||
if (!ai.attackable || ai.dipState !== 4) {
|
||||
return null;
|
||||
}
|
||||
if (nation.rice < ai.aiConst.baseRice && ai.general.npcState >= 2 && ai.rng.nextBool(0.7)) {
|
||||
return null;
|
||||
}
|
||||
if (ai.general.train < Math.min(100, ai.nationPolicy.properWarTrainAtmos)) {
|
||||
return null;
|
||||
}
|
||||
if (ai.general.atmos < Math.min(100, ai.nationPolicy.properWarTrainAtmos)) {
|
||||
return null;
|
||||
}
|
||||
if (ai.general.crew < Math.min((ai.general.stats.leadership - 2) * 100, ai.nationPolicy.minWarCrew)) {
|
||||
return null;
|
||||
}
|
||||
if (city.frontState <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attackableNations = Object.entries(ai.warTargetNation)
|
||||
.filter(([, state]) => state !== 1)
|
||||
.map(([id]) => Number(id));
|
||||
if (attackableNations.length === 0) {
|
||||
attackableNations.push(0);
|
||||
}
|
||||
const neighbors = ai.map.cities.find((c) => c.id === city.id)?.connections ?? [];
|
||||
const attackableCities = neighbors.filter((cityId) => {
|
||||
const destCity = ai.worldRef?.getCityById(cityId);
|
||||
return destCity ? attackableNations.includes(destCity.nationId) : false;
|
||||
});
|
||||
if (attackableCities.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return ai.buildGeneralCandidate('che_출병', { destCityId: ai.rng.choice(attackableCities) }, '출병');
|
||||
};
|
||||
|
||||
export const doNPC헌납 = (ai: GeneralAI) => {
|
||||
const nation = ai.nation;
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const resourceMap: Array<['rice' | 'gold', number, number, number]> = [
|
||||
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
||||
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
||||
];
|
||||
const args: Array<[Record<string, unknown>, number]> = [];
|
||||
|
||||
for (const [resKey, reqNation, reqNpcWar, reqNpcDevel] of resourceMap) {
|
||||
const genRes = ai.general[resKey];
|
||||
let reqRes = reqNpcDevel;
|
||||
|
||||
if (ai.genType & t통솔장) {
|
||||
reqRes = reqNpcWar;
|
||||
} else {
|
||||
if (genRes >= reqNpcWar && reqNpcWar > reqNpcDevel + 1000) {
|
||||
const amount = genRes - reqNpcDevel;
|
||||
args.push([{ isGold: resKey === 'gold', amount }, amount]);
|
||||
continue;
|
||||
}
|
||||
if (genRes >= reqNpcDevel * 5 && genRes >= 5000) {
|
||||
const amount = genRes - reqNpcDevel;
|
||||
args.push([{ isGold: resKey === 'gold', amount }, amount]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (nation[resKey] >= reqNation) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
resKey === 'rice' &&
|
||||
nation[resKey] <= ai.aiConst.minNationalRice / 2 &&
|
||||
genRes >= ai.aiConst.minNationalRice / 2
|
||||
) {
|
||||
const amount = genRes < ai.aiConst.minNationalRice ? genRes : genRes / 2;
|
||||
args.push([{ isGold: false, amount }, amount]);
|
||||
}
|
||||
if (genRes < reqRes * 1.5) {
|
||||
continue;
|
||||
}
|
||||
if (reqRes > 0 && !ai.rng.nextBool(genRes / reqRes - 0.5)) {
|
||||
continue;
|
||||
}
|
||||
const amount = genRes - reqRes;
|
||||
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
|
||||
continue;
|
||||
}
|
||||
args.push([{ isGold: resKey === 'gold', amount }, amount]);
|
||||
}
|
||||
|
||||
if (args.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ai.buildGeneralCandidate('che_헌납', ai.rng.choiceUsingWeightPair(args), 'NPC헌납');
|
||||
};
|
||||
|
||||
export const do후방워프 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
if (!city || !ai.nation || !ai.map) {
|
||||
return null;
|
||||
}
|
||||
if ([0, 1].includes(ai.dipState)) {
|
||||
return null;
|
||||
}
|
||||
if (!(ai.genType & t통솔장)) {
|
||||
return null;
|
||||
}
|
||||
if (ai.general.crew >= ai.nationPolicy.minWarCrew) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let minRecruitPop = ai.general.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
|
||||
if (!ai.generalPolicy.can('한계징병')) {
|
||||
minRecruitPop = Math.max(
|
||||
minRecruitPop,
|
||||
ai.general.stats.leadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
|
||||
);
|
||||
}
|
||||
|
||||
if (ai.generalPolicy.can('한계징병')) {
|
||||
if (city.population >= minRecruitPop) {
|
||||
return null;
|
||||
}
|
||||
} else if (
|
||||
city.population / city.populationMax >= ai.nationPolicy.safeRecruitCityPopulationRatio &&
|
||||
city.population >= ai.nationPolicy.minNpcRecruitCityPopulation &&
|
||||
city.population >= minRecruitPop
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ai.categorizeNationCities();
|
||||
|
||||
const recruitable: Record<number, number> = {};
|
||||
for (const candidate of Object.values(ai.backupCities)) {
|
||||
if (candidate.id === city.id) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.population / candidate.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.population < ai.nationPolicy.minNpcRecruitCityPopulation) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.population < minRecruitPop) {
|
||||
continue;
|
||||
}
|
||||
recruitable[candidate.id] = candidate.population / candidate.populationMax;
|
||||
}
|
||||
if (Object.keys(recruitable).length === 0) {
|
||||
for (const candidate of Object.values(ai.supplyCities)) {
|
||||
if (candidate.id === city.id) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.population < ai.nationPolicy.minNpcRecruitCityPopulation) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.population <= minRecruitPop) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.population / candidate.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio) {
|
||||
continue;
|
||||
}
|
||||
recruitable[candidate.id] =
|
||||
candidate.frontState > 0
|
||||
? candidate.population / candidate.populationMax / 2
|
||||
: candidate.population / candidate.populationMax;
|
||||
}
|
||||
}
|
||||
if (Object.keys(recruitable).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ai.buildGeneralCandidate(
|
||||
'che_NPC능동',
|
||||
{ optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(recruitable) },
|
||||
'후방워프'
|
||||
);
|
||||
};
|
||||
|
||||
export const do전방워프 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
if (!city || !ai.nation || !ai.map) {
|
||||
return null;
|
||||
}
|
||||
if (!ai.attackable || [0, 1].includes(ai.dipState)) {
|
||||
return null;
|
||||
}
|
||||
if (!(ai.genType & t통솔장)) {
|
||||
return null;
|
||||
}
|
||||
if (ai.general.crew < ai.nationPolicy.minWarCrew) {
|
||||
return null;
|
||||
}
|
||||
if (city.frontState > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ai.categorizeNationCities();
|
||||
const candidateCities: Record<number, number> = {};
|
||||
for (const frontCity of Object.values(ai.frontCities)) {
|
||||
if (frontCity.supplyState <= 0) {
|
||||
continue;
|
||||
}
|
||||
candidateCities[frontCity.id] = frontCity.important;
|
||||
}
|
||||
if (Object.keys(candidateCities).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ai.buildGeneralCandidate(
|
||||
'che_NPC능동',
|
||||
{ optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(candidateCities) },
|
||||
'전방워프'
|
||||
);
|
||||
};
|
||||
|
||||
export const do내정워프 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
if (!city || !ai.nation || !ai.map) {
|
||||
return null;
|
||||
}
|
||||
if (ai.genType & t통솔장 && [2, 3, 4].includes(ai.dipState)) {
|
||||
return null;
|
||||
}
|
||||
if (ai.rng.nextBool(0.6)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const develRate = ai.calcCityDevelRate(city);
|
||||
let warpProp = 1;
|
||||
let availableTypeCnt = 0;
|
||||
for (const [key, [value, type]] of Object.entries(develRate)) {
|
||||
if (!(ai.genType & type)) {
|
||||
continue;
|
||||
}
|
||||
warpProp *= value;
|
||||
availableTypeCnt += 1;
|
||||
void key;
|
||||
}
|
||||
if (availableTypeCnt === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!ai.rng.nextBool(warpProp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ai.categorizeNationCities();
|
||||
ai.categorizeNationGeneral();
|
||||
const candidateCities: Record<number, number> = {};
|
||||
for (const candidate of Object.values(ai.supplyCities)) {
|
||||
if (candidate.id === city.id) {
|
||||
continue;
|
||||
}
|
||||
let realDevelRate = 0.0001;
|
||||
for (const [_, [value, type]] of Object.entries(ai.calcCityDevelRate(candidate))) {
|
||||
if (!(ai.genType & type)) {
|
||||
continue;
|
||||
}
|
||||
realDevelRate += value;
|
||||
}
|
||||
realDevelRate /= availableTypeCnt;
|
||||
if (realDevelRate >= 0.95) {
|
||||
continue;
|
||||
}
|
||||
candidateCities[candidate.id] =
|
||||
1 / (realDevelRate * Math.sqrt((candidate.generals ? Object.keys(candidate.generals).length : 0) + 1));
|
||||
}
|
||||
if (Object.keys(candidateCities).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ai.buildGeneralCandidate(
|
||||
'che_NPC능동',
|
||||
{ optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(candidateCities) },
|
||||
'내정워프'
|
||||
);
|
||||
};
|
||||
|
||||
export const do귀환 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
if (!city) {
|
||||
return null;
|
||||
}
|
||||
if (city.nationId === ai.general.nationId && city.supplyState > 0) {
|
||||
return null;
|
||||
}
|
||||
return ai.buildGeneralCandidate('che_귀환', {}, '귀환');
|
||||
};
|
||||
|
||||
export const do집합 = (ai: GeneralAI) => ai.buildGeneralCandidate('che_집합', {}, '집합');
|
||||
|
||||
export const do국가선택 = (ai: GeneralAI) => {
|
||||
if (!ai.worldRef) {
|
||||
return null;
|
||||
}
|
||||
if (ai.general.npcState === 9) {
|
||||
const ruler = ai.worldRef
|
||||
.listGenerals()
|
||||
.find((general) => general.officerLevel === 12 && general.npcState === 9);
|
||||
if (ruler) {
|
||||
return ai.buildGeneralCandidate('che_임관', { destNationId: ruler.nationId }, '국가선택');
|
||||
}
|
||||
}
|
||||
|
||||
if (ai.rng.nextBool(0.3)) {
|
||||
const nations = ai.worldRef.listNations().filter((nation) => nation.id > 0);
|
||||
if (nations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const destNation = ai.rng.choice(nations);
|
||||
return ai.buildGeneralCandidate('che_임관', { destNationId: destNation.id }, '국가선택');
|
||||
}
|
||||
|
||||
if (ai.rng.nextBool(0.2) && ai.map) {
|
||||
const neighbors = ai.map.cities.find((c) => c.id === ai.general.cityId)?.connections ?? [];
|
||||
if (neighbors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return ai.buildGeneralCandidate('che_이동', { destCityId: ai.rng.choice(neighbors) }, '국가선택');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const doNPC사망대비 = (ai: GeneralAI) => {
|
||||
const killturn = readMetaNumber(asRecord(ai.general.meta), 'killturn', 999);
|
||||
if (killturn > 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ai.general.nationId === 0) {
|
||||
const search = ai.buildGeneralCandidate('che_인재탐색', {}, 'NPC사망대비');
|
||||
if (search && !ai.rng.nextBool()) {
|
||||
return search;
|
||||
}
|
||||
return ai.buildGeneralCandidate('che_견문', {}, 'NPC사망대비');
|
||||
}
|
||||
|
||||
if (ai.general.gold + ai.general.rice === 0) {
|
||||
return ai.buildGeneralCandidate('che_물자조달', {}, 'NPC사망대비');
|
||||
}
|
||||
|
||||
if (ai.general.gold >= ai.general.rice) {
|
||||
return ai.buildGeneralCandidate(
|
||||
'che_헌납',
|
||||
{ isGold: true, amount: ai.aiConst.maxResourceActionAmount },
|
||||
'NPC사망대비'
|
||||
);
|
||||
}
|
||||
return ai.buildGeneralCandidate(
|
||||
'che_헌납',
|
||||
{ isGold: false, amount: ai.aiConst.maxResourceActionAmount },
|
||||
'NPC사망대비'
|
||||
);
|
||||
};
|
||||
|
||||
export const do중립 = (ai: GeneralAI) => {
|
||||
const nation = ai.nation;
|
||||
if (!nation || ai.general.nationId === 0) {
|
||||
const search = ai.buildGeneralCandidate('che_인재탐색', {}, '중립');
|
||||
if (search && !ai.rng.nextBool(0.8)) {
|
||||
return search;
|
||||
}
|
||||
return ai.buildGeneralCandidate('che_견문', {}, '중립');
|
||||
}
|
||||
|
||||
let candidates = ['che_물자조달', 'che_인재탐색'];
|
||||
if (nation.gold < ai.nationPolicy.reqNationGold || nation.rice < ai.nationPolicy.reqNationRice) {
|
||||
candidates = ['che_물자조달'];
|
||||
}
|
||||
|
||||
for (const key of candidates) {
|
||||
const cmd = ai.buildGeneralCandidate(key, {}, '중립');
|
||||
if (cmd) {
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
return ai.buildGeneralCandidate(ACTION_REST, {}, '중립');
|
||||
};
|
||||
|
||||
export const do거병 = (ai: GeneralAI) => {
|
||||
if (readMetaNumber(asRecord(ai.general.meta), 'makelimit', 0)) {
|
||||
return null;
|
||||
}
|
||||
if (ai.general.npcState > 2) {
|
||||
return null;
|
||||
}
|
||||
if (!ai.generalPolicy.can('건국')) {
|
||||
return null;
|
||||
}
|
||||
const city = ai.city;
|
||||
if (!city || !ai.map || !ai.worldRef) {
|
||||
return null;
|
||||
}
|
||||
if ((city.level < 5 || 6 < city.level) && ai.rng.nextBool(0.5)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const occupied = new Set(
|
||||
ai.worldRef
|
||||
.listCities()
|
||||
.filter((c) => c.nationId !== 0)
|
||||
.map((c) => c.id)
|
||||
);
|
||||
for (const general of ai.worldRef.listGenerals()) {
|
||||
if (general.officerLevel === 12 && general.nationId === 0) {
|
||||
occupied.add(general.cityId);
|
||||
}
|
||||
}
|
||||
|
||||
let availableNearCity = false;
|
||||
const nearby = searchDistance(ai.map, ai.general.cityId, 3);
|
||||
for (const [targetCityId, dist] of Object.entries(nearby)) {
|
||||
const cityId = Number(targetCityId);
|
||||
if (!Number.isFinite(cityId)) {
|
||||
continue;
|
||||
}
|
||||
if (occupied.has(cityId)) {
|
||||
continue;
|
||||
}
|
||||
const target = ai.worldRef.getCityById(cityId);
|
||||
if (!target || target.level < 5 || target.level > 6) {
|
||||
continue;
|
||||
}
|
||||
if (dist === 3 && ai.rng.nextBool()) {
|
||||
continue;
|
||||
}
|
||||
availableNearCity = true;
|
||||
break;
|
||||
}
|
||||
if (!availableNearCity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prop = (ai.rng.nextFloat1() * (ai.aiConst.defaultStatNpcMax + ai.aiConst.chiefStatMin)) / 2;
|
||||
const ratio = (ai.general.stats.leadership + ai.general.stats.strength + ai.general.stats.intelligence) / 3;
|
||||
if (prop >= ratio) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relYear = Math.max(0, ai.world.currentYear - ai.startYear);
|
||||
const more = valueFit(3 - relYear, 1, 3);
|
||||
if (!ai.rng.nextBool(0.0075 * more)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ai.buildGeneralCandidate('che_거병', {}, '거병');
|
||||
};
|
||||
|
||||
export const do건국 = (ai: GeneralAI) => {
|
||||
const mapName = ai.scenarioConfig.environment.mapName ?? 'sammo';
|
||||
const prefix = mapName.endsWith('_') ? mapName : `${mapName}_`;
|
||||
const nationType =
|
||||
ai.aiConst.availableNationTypes.length > 0
|
||||
? (ai.rng.choice(ai.aiConst.availableNationTypes) as string)
|
||||
: `${prefix}def`;
|
||||
const colorType = ai.rng.nextRangeInt(0, 34);
|
||||
const nationName = ai.general.name;
|
||||
|
||||
return ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국');
|
||||
};
|
||||
|
||||
export const do방랑군이동 = (ai: GeneralAI) => {
|
||||
const city = ai.city;
|
||||
if (!city || !ai.map || !ai.worldRef) {
|
||||
return null;
|
||||
}
|
||||
const occupied = new Set(
|
||||
ai.worldRef
|
||||
.listCities()
|
||||
.filter((c) => c.nationId !== 0)
|
||||
.map((c) => c.id)
|
||||
);
|
||||
for (const general of ai.worldRef.listGenerals()) {
|
||||
if (general.officerLevel === 12 && general.nationId === 0) {
|
||||
occupied.add(general.cityId);
|
||||
}
|
||||
}
|
||||
|
||||
const nearby = searchDistance(ai.map, city.id, 4);
|
||||
const candidates: Array<[number, number]> = [];
|
||||
for (const [cityIdRaw, dist] of Object.entries(nearby)) {
|
||||
const cityId = Number(cityIdRaw);
|
||||
if (!Number.isFinite(cityId) || occupied.has(cityId)) {
|
||||
continue;
|
||||
}
|
||||
const target = ai.worldRef.getCityById(cityId);
|
||||
if (!target || target.level < 5 || target.level > 6) {
|
||||
continue;
|
||||
}
|
||||
candidates.push([cityId, 1 / Math.pow(2, dist)]);
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const destCityId = ai.rng.choiceUsingWeightPair(candidates);
|
||||
if (destCityId === city.id) {
|
||||
return ai.buildGeneralCandidate('che_인재탐색', {}, '방랑군이동');
|
||||
}
|
||||
return ai.buildGeneralCandidate('che_이동', { destCityId }, '방랑군이동');
|
||||
};
|
||||
|
||||
export const generalActionHandlers: Record<
|
||||
string,
|
||||
(ai: GeneralAI) => ReturnType<GeneralAI['buildGeneralCandidate']> | null
|
||||
> = {
|
||||
NPC사망대비: doNPC사망대비,
|
||||
귀환: do귀환,
|
||||
금쌀구매: do금쌀구매,
|
||||
출병: do출병,
|
||||
긴급내정: do긴급내정,
|
||||
전투준비: do전투준비,
|
||||
전방워프: do전방워프,
|
||||
NPC헌납: doNPC헌납,
|
||||
징병: do징병,
|
||||
후방워프: do후방워프,
|
||||
전쟁내정: do전쟁내정,
|
||||
소집해제: do소집해제,
|
||||
일반내정: do일반내정,
|
||||
내정워프: do내정워프,
|
||||
국가선택: do국가선택,
|
||||
중립: do중립,
|
||||
집합: do집합,
|
||||
거병: do거병,
|
||||
건국: do건국,
|
||||
방랑군이동: do방랑군이동,
|
||||
};
|
||||
@@ -0,0 +1,1055 @@
|
||||
import type { City } from '@sammo-ts/logic';
|
||||
|
||||
import type { GeneralAI } from './generalAi.js';
|
||||
import { asRecord, calcCityDevRatio, joinYearMonth, parseYearMonth, readMetaNumber } from './aiUtils.js';
|
||||
import { isNeighbor, searchAllDistanceByCityList } from './distance.js';
|
||||
|
||||
const pickWeightedCandidate = (ai: GeneralAI, list: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]>) => {
|
||||
const items = list.filter(([item]) => Boolean(item)) as Array<
|
||||
[ReturnType<GeneralAI['buildNationCandidate']>, number]
|
||||
>;
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const picked = ai.rng.choiceUsingWeightPair(items);
|
||||
return picked ?? null;
|
||||
};
|
||||
|
||||
const pickRandomCityId = (ai: GeneralAI, cities: Record<number, City>): number | null => {
|
||||
const ids = Object.keys(cities).map((key) => Number(key));
|
||||
if (ids.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return ai.rng.choice(ids);
|
||||
};
|
||||
|
||||
const resolveCityPopRatio = (city: City): number => {
|
||||
if (city.populationMax <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return city.population / city.populationMax;
|
||||
};
|
||||
|
||||
const resolveLastAssignment = (general: GeneralAI['general'], yearMonth: number): boolean => {
|
||||
const last = readMetaNumber(asRecord(general.meta), 'last발령', 0);
|
||||
return last >= yearMonth;
|
||||
};
|
||||
|
||||
const selectRecruitableCity = (ai: GeneralAI, minPop: number): Record<number, number> => {
|
||||
const candidates: Record<number, number> = {};
|
||||
for (const city of Object.values(ai.backupCities)) {
|
||||
if (city.population < minPop) {
|
||||
continue;
|
||||
}
|
||||
const ratio = resolveCityPopRatio(city);
|
||||
candidates[city.id] = ratio;
|
||||
}
|
||||
if (Object.keys(candidates).length > 0) {
|
||||
return candidates;
|
||||
}
|
||||
for (const city of Object.values(ai.supplyCities)) {
|
||||
if (city.population < minPop) {
|
||||
continue;
|
||||
}
|
||||
const ratio = resolveCityPopRatio(city);
|
||||
candidates[city.id] = ratio;
|
||||
}
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const buildAssignmentCandidate = (ai: GeneralAI, destGeneralId: number, destCityId: number, reason: string) =>
|
||||
ai.buildNationCandidate('che_발령', { destGeneralId, destCityId }, reason);
|
||||
|
||||
const buildSeizureCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
|
||||
ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason);
|
||||
|
||||
const buildAwardCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
|
||||
ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason);
|
||||
|
||||
const resolveAwardAmount = (ai: GeneralAI, current: number, target: number): number | null => {
|
||||
const diff = target - current;
|
||||
if (diff <= 0) {
|
||||
return null;
|
||||
}
|
||||
const amount = Math.min(diff, ai.maxResourceActionAmount);
|
||||
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
|
||||
return null;
|
||||
}
|
||||
return amount;
|
||||
};
|
||||
|
||||
const resolveNationIncome = (ai: GeneralAI): number => {
|
||||
const cities = Object.values(ai.supplyCities);
|
||||
if (cities.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return cities.reduce((sum, city) => sum + city.population / 100 + city.agriculture / 100 + city.commerce / 100, 0);
|
||||
};
|
||||
|
||||
const pickFrontCityWeight = (ai: GeneralAI): Record<number, number> => {
|
||||
const candidates: Record<number, number> = {};
|
||||
for (const city of Object.values(ai.frontCities)) {
|
||||
candidates[city.id] = city.important;
|
||||
}
|
||||
return candidates;
|
||||
};
|
||||
|
||||
export const do부대전방발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.map) {
|
||||
return null;
|
||||
}
|
||||
if (!ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.frontCities).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ai.calcWarRoute();
|
||||
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
|
||||
|
||||
const troopCandidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
|
||||
for (const leader of Object.values(ai.troopLeaders)) {
|
||||
if (!ai.nationPolicy.combatForce[leader.id]) {
|
||||
continue;
|
||||
}
|
||||
if (ai.frontCities[leader.cityId]) {
|
||||
continue;
|
||||
}
|
||||
if (resolveLastAssignment(leader, yearMonth)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const force = ai.nationPolicy.combatForce[leader.id];
|
||||
let [fromCityId, toCityId] = force;
|
||||
|
||||
let targetCityId: number | null = null;
|
||||
if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) {
|
||||
targetCityId = pickRandomCityId(ai, ai.frontCities);
|
||||
} else {
|
||||
if (!ai.supplyCities[fromCityId]) {
|
||||
toCityId = fromCityId;
|
||||
fromCityId = ai.nation.capitalCityId ?? fromCityId;
|
||||
}
|
||||
targetCityId = fromCityId;
|
||||
while (targetCityId !== null && !ai.frontCities[targetCityId]) {
|
||||
const current = targetCityId;
|
||||
const distance = ai.warRoute[current]?.[toCityId];
|
||||
if (distance === undefined) {
|
||||
targetCityId = pickRandomCityId(ai, ai.frontCities);
|
||||
break;
|
||||
}
|
||||
const connections: number[] = ai.map.cities.find((city) => city.id === current)?.connections ?? [];
|
||||
const nextCandidates: number[] = connections.filter((nextCityId: number) => {
|
||||
const nextDistance = ai.warRoute?.[nextCityId]?.[toCityId];
|
||||
return nextDistance !== undefined && nextDistance <= distance;
|
||||
});
|
||||
if (nextCandidates.length === 0) {
|
||||
targetCityId = pickRandomCityId(ai, ai.frontCities);
|
||||
break;
|
||||
}
|
||||
targetCityId = ai.rng.choice(nextCandidates);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetCityId === null) {
|
||||
continue;
|
||||
}
|
||||
troopCandidates.push([buildAssignmentCandidate(ai, leader.id, targetCityId, '부대전방발령'), 1]);
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, troopCandidates);
|
||||
};
|
||||
|
||||
export const do부대후방발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.frontCities).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.supplyCities).length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
|
||||
const troopCandidates = Object.values(ai.troopLeaders).filter((leader) => {
|
||||
if (!ai.nationPolicy.supportForce.includes(leader.id)) {
|
||||
return false;
|
||||
}
|
||||
if (resolveLastAssignment(leader, yearMonth)) {
|
||||
return false;
|
||||
}
|
||||
const city = ai.supplyCities[leader.cityId];
|
||||
if (!city) {
|
||||
return true;
|
||||
}
|
||||
if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (troopCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cityCandidates: Record<number, number> = {};
|
||||
for (const city of Object.values(ai.backupCities)) {
|
||||
const ratio = resolveCityPopRatio(city);
|
||||
if (ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
|
||||
cityCandidates[city.id] = ratio;
|
||||
}
|
||||
}
|
||||
if (Object.keys(cityCandidates).length === 0) {
|
||||
for (const city of Object.values(ai.supplyCities)) {
|
||||
const ratio = resolveCityPopRatio(city);
|
||||
if (ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
|
||||
cityCandidates[city.id] = ratio;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(cityCandidates).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
|
||||
if (!Number.isFinite(destCityId)) {
|
||||
return null;
|
||||
}
|
||||
const leader = ai.rng.choice(troopCandidates);
|
||||
return buildAssignmentCandidate(ai, leader.id, destCityId, '부대후방발령');
|
||||
};
|
||||
|
||||
export const do부대구출발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.frontCities).length === 0) {
|
||||
return null;
|
||||
}
|
||||
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
|
||||
|
||||
const troopCandidates = Object.values(ai.troopLeaders).filter((leader) => {
|
||||
if (ai.nationPolicy.supportForce.includes(leader.id)) {
|
||||
return false;
|
||||
}
|
||||
if (ai.nationPolicy.combatForce[leader.id]) {
|
||||
return false;
|
||||
}
|
||||
if (resolveLastAssignment(leader, yearMonth)) {
|
||||
return false;
|
||||
}
|
||||
return !ai.supplyCities[leader.cityId];
|
||||
});
|
||||
|
||||
if (troopCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const destCityId = pickRandomCityId(ai, ai.frontCities);
|
||||
if (destCityId === null) {
|
||||
return null;
|
||||
}
|
||||
const leader = ai.rng.choice(troopCandidates);
|
||||
return buildAssignmentCandidate(ai, leader.id, destCityId, '부대구출발령');
|
||||
};
|
||||
|
||||
export const do부대유저장후방발령 = (ai: GeneralAI) => {
|
||||
if (Object.keys(ai.frontCities).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (ai.dipState !== 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Object.values(ai.userWarGenerals).filter((general) => {
|
||||
if (general.id === ai.general.id) {
|
||||
return false;
|
||||
}
|
||||
if (!ai.frontCities[general.cityId]) {
|
||||
return false;
|
||||
}
|
||||
if (!ai.nationCities[general.cityId]) {
|
||||
return false;
|
||||
}
|
||||
const troopLeaderId = general.troopId;
|
||||
if (!troopLeaderId || !ai.troopLeaders[troopLeaderId]) {
|
||||
return false;
|
||||
}
|
||||
if (troopLeaderId === general.id) {
|
||||
return false;
|
||||
}
|
||||
const troopLeader = ai.troopLeaders[troopLeaderId];
|
||||
if (troopLeader.cityId !== general.cityId) {
|
||||
return false;
|
||||
}
|
||||
if (!ai.supplyCities[troopLeader.cityId]) {
|
||||
return false;
|
||||
}
|
||||
const city = ai.nationCities[general.cityId];
|
||||
if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
|
||||
return false;
|
||||
}
|
||||
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
||||
return false;
|
||||
}
|
||||
const reserved = ai.getReservedTurn(general.id);
|
||||
if (reserved.action !== 'che_징병') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const destCityCandidates = selectRecruitableCity(ai, ai.nationPolicy.minNpcRecruitCityPopulation);
|
||||
if (Object.keys(destCityCandidates).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates));
|
||||
if (!Number.isFinite(destCityId)) {
|
||||
return null;
|
||||
}
|
||||
const destGeneral = ai.rng.choice(candidates);
|
||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '부대유저장후방발령');
|
||||
};
|
||||
|
||||
export const do유저장후방발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (ai.dipState !== 4) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.supplyCities).length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Object.values(ai.userWarGenerals).filter((general) => {
|
||||
if (general.id === ai.general.id) {
|
||||
return false;
|
||||
}
|
||||
if (!ai.supplyCities[general.cityId]) {
|
||||
return false;
|
||||
}
|
||||
if (general.troopId) {
|
||||
return false;
|
||||
}
|
||||
const city = ai.supplyCities[general.cityId];
|
||||
if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
|
||||
return false;
|
||||
}
|
||||
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const picked = ai.rng.choice(candidates);
|
||||
const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
|
||||
const destCityCandidates = selectRecruitableCity(ai, minPop);
|
||||
if (Object.keys(destCityCandidates).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates));
|
||||
if (!Number.isFinite(destCityId)) {
|
||||
return null;
|
||||
}
|
||||
return buildAssignmentCandidate(ai, picked.id, destCityId, '유저장후방발령');
|
||||
};
|
||||
|
||||
export const do유저장구출발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
const lostCandidates = Object.values(ai.lostGenerals).filter((general) => general.npcState < 2);
|
||||
if (lostCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const destCityId = pickRandomCityId(ai, ai.frontCities) ?? pickRandomCityId(ai, ai.supplyCities);
|
||||
if (destCityId === null) {
|
||||
return null;
|
||||
}
|
||||
const destGeneral = ai.rng.choice(lostCandidates);
|
||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장구출발령');
|
||||
};
|
||||
|
||||
export const do유저장전방발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.frontCities).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if ([0, 1].includes(ai.dipState)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Object.values(ai.userWarGenerals).filter((general) => {
|
||||
if (general.id === ai.general.id) {
|
||||
return false;
|
||||
}
|
||||
if (ai.frontCities[general.cityId]) {
|
||||
return false;
|
||||
}
|
||||
if (!ai.nationCities[general.cityId]) {
|
||||
return false;
|
||||
}
|
||||
if (general.crew < ai.nationPolicy.minWarCrew) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cityCandidates = pickFrontCityWeight(ai);
|
||||
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
|
||||
if (!Number.isFinite(destCityId)) {
|
||||
return null;
|
||||
}
|
||||
const destGeneral = ai.rng.choice(candidates);
|
||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장전방발령');
|
||||
};
|
||||
|
||||
export const do유저장내정발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.supplyCities).length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supplyCities = Object.values(ai.supplyCities);
|
||||
const avgDev = supplyCities.reduce((sum, city) => sum + city.dev, 0) / supplyCities.length;
|
||||
if (avgDev >= 0.99) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userGenerals = [0, 1].includes(ai.dipState)
|
||||
? [...Object.values(ai.userWarGenerals), ...Object.values(ai.userCivilGenerals)]
|
||||
: Object.values(ai.userCivilGenerals);
|
||||
|
||||
const generalCandidates = userGenerals.filter((general) => {
|
||||
if (general.troopId) {
|
||||
return false;
|
||||
}
|
||||
const city = ai.supplyCities[general.cityId];
|
||||
if (!city) {
|
||||
return false;
|
||||
}
|
||||
return city.dev >= 0.95;
|
||||
});
|
||||
|
||||
if (generalCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cityCandidates: Record<number, number> = {};
|
||||
for (const city of supplyCities) {
|
||||
const dev = Math.min(city.dev, 0.999);
|
||||
const score = Math.pow(1 - dev, 2) / Math.sqrt((city.generals ? Object.keys(city.generals).length : 0) + 1);
|
||||
cityCandidates[city.id] = score;
|
||||
}
|
||||
|
||||
const destGeneral = ai.rng.choice(generalCandidates);
|
||||
const srcCity = ai.supplyCities[destGeneral.cityId];
|
||||
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
|
||||
if (!Number.isFinite(destCityId)) {
|
||||
return null;
|
||||
}
|
||||
if (srcCity && srcCity.dev <= (ai.supplyCities[destCityId]?.dev ?? 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장내정발령');
|
||||
};
|
||||
|
||||
export const doNPC후방발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.frontCities).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (ai.dipState !== 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Object.values(ai.npcWarGenerals).filter((general) => {
|
||||
if (general.id === ai.general.id) {
|
||||
return false;
|
||||
}
|
||||
if (!ai.supplyCities[general.cityId]) {
|
||||
return false;
|
||||
}
|
||||
if (general.troopId) {
|
||||
return false;
|
||||
}
|
||||
const city = ai.supplyCities[general.cityId];
|
||||
if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
|
||||
return false;
|
||||
}
|
||||
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const picked = ai.rng.choice(candidates);
|
||||
const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
|
||||
const destCityCandidates = selectRecruitableCity(ai, minPop);
|
||||
if (Object.keys(destCityCandidates).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates));
|
||||
if (!Number.isFinite(destCityId)) {
|
||||
return null;
|
||||
}
|
||||
return buildAssignmentCandidate(ai, picked.id, destCityId, 'NPC후방발령');
|
||||
};
|
||||
|
||||
export const doNPC구출발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
const lostCandidates = Object.values(ai.lostGenerals).filter(
|
||||
(general) => general.npcState >= 2 && general.npcState !== 5
|
||||
);
|
||||
if (lostCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const destCityId = pickRandomCityId(ai, ai.supplyCities);
|
||||
if (destCityId === null) {
|
||||
return null;
|
||||
}
|
||||
const destGeneral = ai.rng.choice(lostCandidates);
|
||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC구출발령');
|
||||
};
|
||||
|
||||
export const doNPC전방발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.frontCities).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if ([0, 1].includes(ai.dipState)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Object.values(ai.npcWarGenerals).filter((general) => {
|
||||
if (ai.frontCities[general.cityId]) {
|
||||
return false;
|
||||
}
|
||||
if (!ai.nationCities[general.cityId]) {
|
||||
return false;
|
||||
}
|
||||
if (general.crew < ai.nationPolicy.minWarCrew) {
|
||||
return false;
|
||||
}
|
||||
if (general.troopId) {
|
||||
return false;
|
||||
}
|
||||
if (Math.max(general.train, general.atmos) < ai.nationPolicy.properWarTrainAtmos) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cityCandidates = pickFrontCityWeight(ai);
|
||||
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
|
||||
if (!Number.isFinite(destCityId)) {
|
||||
return null;
|
||||
}
|
||||
const destGeneral = ai.rng.choice(candidates);
|
||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC전방발령');
|
||||
};
|
||||
|
||||
export const doNPC내정발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.supplyCities).length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supplyCities = Object.values(ai.supplyCities);
|
||||
const avgDev = supplyCities.reduce((sum, city) => sum + city.dev, 0) / supplyCities.length;
|
||||
if (avgDev >= 0.99) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcGenerals = [0, 1].includes(ai.dipState)
|
||||
? [...Object.values(ai.npcWarGenerals), ...Object.values(ai.npcCivilGenerals)]
|
||||
: Object.values(ai.npcCivilGenerals);
|
||||
|
||||
const generalCandidates = npcGenerals.filter((general) => {
|
||||
const city = ai.supplyCities[general.cityId];
|
||||
if (!city) {
|
||||
return false;
|
||||
}
|
||||
return city.dev >= 0.95;
|
||||
});
|
||||
|
||||
if (generalCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cityCandidates: Record<number, number> = {};
|
||||
for (const city of supplyCities) {
|
||||
const dev = Math.min(city.dev, 0.999);
|
||||
const score = Math.pow(1 - dev, 2) / Math.sqrt((city.generals ? Object.keys(city.generals).length : 0) + 1);
|
||||
cityCandidates[city.id] = score;
|
||||
}
|
||||
|
||||
const destGeneral = ai.rng.choice(generalCandidates);
|
||||
const srcCity = ai.supplyCities[destGeneral.cityId];
|
||||
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
|
||||
if (!Number.isFinite(destCityId)) {
|
||||
return null;
|
||||
}
|
||||
if (srcCity && srcCity.dev <= (ai.supplyCities[destCityId]?.dev ?? 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC내정발령');
|
||||
};
|
||||
|
||||
export const do유저장긴급포상 = (ai: GeneralAI) => {
|
||||
const nation = ai.nation;
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const resourceMap: Array<['gold' | 'rice', number]> = [
|
||||
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
|
||||
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
|
||||
];
|
||||
|
||||
for (const [resKey, required] of resourceMap) {
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
|
||||
continue;
|
||||
}
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
|
||||
continue;
|
||||
}
|
||||
for (const general of Object.values(ai.userWarGenerals)) {
|
||||
const amount = resolveAwardAmount(ai, general[resKey], required);
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'), amount]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, candidates);
|
||||
};
|
||||
|
||||
export const do유저장포상 = (ai: GeneralAI) => {
|
||||
const nation = ai.nation;
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const resourceMap: Array<['gold' | 'rice', number]> = [
|
||||
['gold', ai.nationPolicy.reqHumanWarRecommandGold],
|
||||
['rice', ai.nationPolicy.reqHumanWarRecommandRice],
|
||||
];
|
||||
|
||||
for (const [resKey, required] of resourceMap) {
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
|
||||
continue;
|
||||
}
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
|
||||
continue;
|
||||
}
|
||||
for (const general of Object.values(ai.userWarGenerals)) {
|
||||
const amount = resolveAwardAmount(ai, general[resKey], required);
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'), amount]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, candidates);
|
||||
};
|
||||
|
||||
export const doNPC긴급포상 = (ai: GeneralAI) => {
|
||||
const nation = ai.nation;
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const resourceMap: Array<['gold' | 'rice', number]> = [
|
||||
['gold', ai.nationPolicy.reqNpcWarGold / 2],
|
||||
['rice', ai.nationPolicy.reqNpcWarRice / 2],
|
||||
];
|
||||
|
||||
for (const [resKey, required] of resourceMap) {
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
|
||||
continue;
|
||||
}
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
|
||||
continue;
|
||||
}
|
||||
for (const general of Object.values(ai.npcWarGenerals)) {
|
||||
const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999);
|
||||
if (killturn <= 5) {
|
||||
continue;
|
||||
}
|
||||
const amount = resolveAwardAmount(ai, general[resKey], required);
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'), amount]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, candidates);
|
||||
};
|
||||
|
||||
export const doNPC포상 = (ai: GeneralAI) => {
|
||||
const nation = ai.nation;
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const resourceMap: Array<['gold' | 'rice', number, number]> = [
|
||||
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
||||
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
||||
];
|
||||
|
||||
for (const [resKey, warReq, devReq] of resourceMap) {
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
|
||||
continue;
|
||||
}
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
|
||||
continue;
|
||||
}
|
||||
for (const general of Object.values(ai.npcWarGenerals)) {
|
||||
const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999);
|
||||
if (killturn <= 5) {
|
||||
continue;
|
||||
}
|
||||
const amount = resolveAwardAmount(ai, general[resKey], warReq);
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]);
|
||||
}
|
||||
for (const general of Object.values(ai.npcCivilGenerals)) {
|
||||
const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999);
|
||||
if (killturn <= 5) {
|
||||
continue;
|
||||
}
|
||||
const amount = resolveAwardAmount(ai, general[resKey], devReq);
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, candidates);
|
||||
};
|
||||
|
||||
export const doNPC몰수 = (ai: GeneralAI) => {
|
||||
const nation = ai.nation;
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const resourceMap: Array<['gold' | 'rice', number, number]> = [
|
||||
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
||||
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
||||
];
|
||||
|
||||
for (const [resKey, warReq, devReq] of resourceMap) {
|
||||
const nationLimit = resKey === 'gold' ? ai.nationPolicy.reqNationGold : ai.nationPolicy.reqNationRice;
|
||||
const nationEnough = nation[resKey] >= nationLimit;
|
||||
|
||||
for (const general of Object.values(ai.npcCivilGenerals)) {
|
||||
if (general[resKey] <= devReq * 1.5) {
|
||||
continue;
|
||||
}
|
||||
const amount = Math.min(general[resKey] - devReq * 1.2, ai.maxResourceActionAmount);
|
||||
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
|
||||
continue;
|
||||
}
|
||||
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
|
||||
}
|
||||
|
||||
if (!nationEnough) {
|
||||
for (const general of Object.values(ai.npcWarGenerals)) {
|
||||
const minRes = nation[resKey] < nationLimit * 0.5 ? warReq * 2 : warReq;
|
||||
if (general[resKey] <= minRes) {
|
||||
continue;
|
||||
}
|
||||
const amount = Math.min(general[resKey] - minRes, ai.maxResourceActionAmount);
|
||||
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
|
||||
continue;
|
||||
}
|
||||
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, candidates);
|
||||
};
|
||||
|
||||
export const do불가침제의 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || ai.general.officerLevel < 12) {
|
||||
return null;
|
||||
}
|
||||
if (!ai.worldRef) {
|
||||
return null;
|
||||
}
|
||||
const meta = asRecord(ai.nation.meta);
|
||||
const recvAssist = Array.isArray(meta.recv_assist) ? meta.recv_assist : [];
|
||||
const respAssist = asRecord(meta.resp_assist);
|
||||
const respAssistTry = asRecord(meta.resp_assist_try);
|
||||
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
|
||||
|
||||
const candidateList: Record<number, number> = {};
|
||||
for (const entry of recvAssist) {
|
||||
if (!Array.isArray(entry) || entry.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const destNationId = Number(entry[0]);
|
||||
const amount = Number(entry[1]);
|
||||
if (!Number.isFinite(destNationId) || !Number.isFinite(amount)) {
|
||||
continue;
|
||||
}
|
||||
const respEntry = asRecord(respAssist[`n${destNationId}`]);
|
||||
const respAmount = readMetaNumber(respEntry, '1', 0);
|
||||
const remain = amount - respAmount;
|
||||
if (remain <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (ai.warTargetNation[destNationId]) {
|
||||
continue;
|
||||
}
|
||||
const lastTry = readMetaNumber(asRecord(respAssistTry[`n${destNationId}`]), '1', 0);
|
||||
if (lastTry >= yearMonth - 8) {
|
||||
continue;
|
||||
}
|
||||
candidateList[destNationId] = remain;
|
||||
}
|
||||
|
||||
if (Object.keys(candidateList).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const income = resolveNationIncome(ai);
|
||||
if (income <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sorted = Object.entries(candidateList).sort((a, b) => b[1] - a[1]);
|
||||
let destNationId: number | null = null;
|
||||
let diplomatMonth = 0;
|
||||
for (const [idRaw, amount] of sorted) {
|
||||
if (amount * 4 < income) {
|
||||
break;
|
||||
}
|
||||
destNationId = Number(idRaw);
|
||||
diplomatMonth = (24 * amount) / income;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!destNationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth));
|
||||
return ai.buildNationCandidate(
|
||||
'che_불가침제의',
|
||||
{ destNationId, year: targetYear, month: targetMonth },
|
||||
'불가침제의'
|
||||
);
|
||||
};
|
||||
|
||||
export const do선전포고 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || ai.general.officerLevel < 12) {
|
||||
return null;
|
||||
}
|
||||
if (ai.dipState !== 0) {
|
||||
return null;
|
||||
}
|
||||
if (ai.attackable) {
|
||||
return null;
|
||||
}
|
||||
if (!ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.frontCities).length > 0) {
|
||||
return null;
|
||||
}
|
||||
if (!ai.map || !ai.worldRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const avgResources = Object.values({
|
||||
...ai.npcWarGenerals,
|
||||
...ai.npcCivilGenerals,
|
||||
...ai.userWarGenerals,
|
||||
...ai.userCivilGenerals,
|
||||
});
|
||||
if (avgResources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let avgGold = ai.nation.gold;
|
||||
let avgRice = ai.nation.rice;
|
||||
for (const general of avgResources) {
|
||||
const scale = general.npcState < 2 ? 0.5 : 1;
|
||||
avgGold += general.gold * scale;
|
||||
avgRice += general.rice * scale;
|
||||
}
|
||||
avgGold /= avgResources.length;
|
||||
avgRice /= avgResources.length;
|
||||
|
||||
const trialProp =
|
||||
avgGold / Math.max(ai.nationPolicy.reqNpcWarGold * 1.5, 2000) +
|
||||
avgRice / Math.max(ai.nationPolicy.reqNpcWarRice * 1.5, 2000);
|
||||
const devRate = ai.calcNationDevelopedRate();
|
||||
const chance = Math.pow((trialProp + (devRate.pop + devRate.all) / 2) / 4, 6);
|
||||
if (!ai.rng.nextBool(chance)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentNationId = ai.nation.id;
|
||||
const cities = ai.worldRef.listCities();
|
||||
const neighbors = ai.worldRef.listNations().filter((nation) => {
|
||||
if (nation.id <= 0 || nation.id === currentNationId) {
|
||||
return false;
|
||||
}
|
||||
return isNeighbor(ai.map!, cities, currentNationId, nation.id, true);
|
||||
});
|
||||
if (neighbors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const weight: Record<number, number> = {};
|
||||
for (const nation of neighbors) {
|
||||
weight[nation.id] = 1 / Math.sqrt(nation.power + 1);
|
||||
}
|
||||
|
||||
const destNationId = Number(ai.rng.choiceUsingWeight(weight));
|
||||
if (!Number.isFinite(destNationId)) {
|
||||
return null;
|
||||
}
|
||||
return ai.buildNationCandidate('che_선전포고', { destNationId }, '선전포고');
|
||||
};
|
||||
|
||||
export const do천도 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
return null;
|
||||
}
|
||||
if (!ai.map) {
|
||||
return null;
|
||||
}
|
||||
const nationCities = Object.values(ai.nationCities);
|
||||
if (nationCities.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cityIds = nationCities.map((city) => city.id);
|
||||
const distanceList = searchAllDistanceByCityList(ai.map, cityIds);
|
||||
const capitalId = ai.nation.capitalCityId;
|
||||
if (!distanceList[capitalId]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let maxDistance = 0;
|
||||
for (const distances of Object.values(distanceList)) {
|
||||
const sum = Object.values(distances).reduce((acc, value) => acc + value, 0);
|
||||
maxDistance = Math.max(maxDistance, sum);
|
||||
}
|
||||
|
||||
const cityScores: Record<number, number> = {};
|
||||
for (const city of nationCities) {
|
||||
const sumDistance = Object.values(distanceList[city.id] ?? {}).reduce((acc, value) => acc + value, 0);
|
||||
if (sumDistance <= 0) {
|
||||
continue;
|
||||
}
|
||||
const dev = calcCityDevRatio(city);
|
||||
cityScores[city.id] = city.population * (maxDistance / sumDistance) * Math.sqrt(dev);
|
||||
}
|
||||
|
||||
const sorted = Object.entries(cityScores).sort((a, b) => b[1] - a[1]);
|
||||
const topLimit = Math.ceil(sorted.length * 0.25);
|
||||
for (let idx = 0; idx < Math.min(topLimit, sorted.length); idx += 1) {
|
||||
if (Number(sorted[idx][0]) === capitalId) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const finalCityId = Number(sorted[0]?.[0]);
|
||||
if (!Number.isFinite(finalCityId)) {
|
||||
return null;
|
||||
}
|
||||
const dist = distanceList[capitalId]?.[finalCityId];
|
||||
if (dist === undefined) {
|
||||
return null;
|
||||
}
|
||||
let targetCityId = finalCityId;
|
||||
if (dist > 1) {
|
||||
const connections = ai.map.cities.find((city) => city.id === capitalId)?.connections ?? [];
|
||||
const candidates = connections.filter((stopId) => distanceList[stopId]?.[finalCityId] + 1 === dist);
|
||||
if (candidates.length > 0) {
|
||||
targetCityId = ai.rng.choice(candidates);
|
||||
}
|
||||
}
|
||||
|
||||
return ai.buildNationCandidate('che_천도', { destCityId: targetCityId }, '천도');
|
||||
};
|
||||
|
||||
export const nationActionHandlers: Record<
|
||||
string,
|
||||
(ai: GeneralAI) => ReturnType<GeneralAI['buildNationCandidate']> | null
|
||||
> = {
|
||||
불가침제의: do불가침제의,
|
||||
선전포고: do선전포고,
|
||||
천도: do천도,
|
||||
유저장긴급포상: do유저장긴급포상,
|
||||
부대전방발령: do부대전방발령,
|
||||
유저장구출발령: do유저장구출발령,
|
||||
유저장후방발령: do유저장후방발령,
|
||||
부대유저장후방발령: do부대유저장후방발령,
|
||||
유저장전방발령: do유저장전방발령,
|
||||
유저장포상: do유저장포상,
|
||||
부대구출발령: do부대구출발령,
|
||||
부대후방발령: do부대후방발령,
|
||||
NPC긴급포상: doNPC긴급포상,
|
||||
NPC구출발령: doNPC구출발령,
|
||||
NPC후방발령: doNPC후방발령,
|
||||
NPC포상: doNPC포상,
|
||||
NPC전방발령: doNPC전방발령,
|
||||
유저장내정발령: do유저장내정발령,
|
||||
NPC내정발령: doNPC내정발령,
|
||||
NPC몰수: doNPC몰수,
|
||||
};
|
||||
@@ -0,0 +1,426 @@
|
||||
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 = stat.npcMax;
|
||||
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 = stat.max;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
City,
|
||||
GeneralActionDefinition,
|
||||
MapDefinition,
|
||||
Nation,
|
||||
ScenarioConfig,
|
||||
ScenarioMeta,
|
||||
Troop,
|
||||
TurnCommandEnv,
|
||||
UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from '../types.js';
|
||||
import type { ReservedTurnEntry } from '../reservedTurnStore.js';
|
||||
|
||||
export interface AiWorldView {
|
||||
getGeneralById(id: number): TurnGeneral | null;
|
||||
getCityById(id: number): City | null;
|
||||
getNationById(id: number): Nation | null;
|
||||
getTroopById(id: number): Troop | null;
|
||||
getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null;
|
||||
listGenerals(): TurnGeneral[];
|
||||
listCities(): City[];
|
||||
listNations(): Nation[];
|
||||
listTroops(): Troop[];
|
||||
listDiplomacy(): TurnDiplomacy[];
|
||||
}
|
||||
|
||||
export interface AiStaticContext {
|
||||
scenarioConfig: ScenarioConfig;
|
||||
scenarioMeta?: ScenarioMeta;
|
||||
map?: MapDefinition;
|
||||
unitSet?: UnitSetDefinition;
|
||||
commandEnv: TurnCommandEnv;
|
||||
generalDefinitions: Map<string, GeneralActionDefinition>;
|
||||
nationDefinitions: Map<string, GeneralActionDefinition>;
|
||||
generalFallback: GeneralActionDefinition;
|
||||
nationFallback: GeneralActionDefinition;
|
||||
}
|
||||
|
||||
export interface AiTurnContext {
|
||||
general: TurnGeneral;
|
||||
city?: City;
|
||||
nation?: Nation | null;
|
||||
world: TurnWorldState;
|
||||
worldRef: AiWorldView | null;
|
||||
reservedTurn: ReservedTurnEntry;
|
||||
}
|
||||
|
||||
export interface AiCommandCandidate {
|
||||
action: string;
|
||||
args: Record<string, unknown>;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AiReservedTurnProvider {
|
||||
getGeneralTurn(generalId: number, turnIdx: number): ReservedTurnEntry;
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
} from '@sammo-ts/logic';
|
||||
import { buildCommandEnv, buildReservedTurnDefinitions } from './reservedTurnCommands.js';
|
||||
import { buildActionContext } from './reservedTurnActionContext.js';
|
||||
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
|
||||
import type { AiReservedTurnProvider } from './ai/types.js';
|
||||
|
||||
const DEFAULT_ACTION = '휴식';
|
||||
|
||||
@@ -433,6 +435,10 @@ export const createReservedTurnHandler = async (options: {
|
||||
return result;
|
||||
};
|
||||
|
||||
const reservedTurnProvider: AiReservedTurnProvider = {
|
||||
getGeneralTurn: (generalId, turnIdx) => options.reservedTurns.getGeneralTurn(generalId, turnIdx),
|
||||
};
|
||||
|
||||
return {
|
||||
execute(context): GeneralTurnResult {
|
||||
const worldRef = options.getWorld();
|
||||
@@ -661,16 +667,62 @@ export const createReservedTurnHandler = async (options: {
|
||||
};
|
||||
|
||||
if (currentNation && currentGeneral.officerLevel >= 5) {
|
||||
const nationCommand = options.reservedTurns.getNationTurn(
|
||||
let nationCommand = options.reservedTurns.getNationTurn(
|
||||
currentNation.id,
|
||||
currentGeneral.officerLevel,
|
||||
0
|
||||
);
|
||||
if (worldView && shouldUseAi(currentGeneral, context.world)) {
|
||||
const ai = new GeneralAI({
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
nation: currentNation,
|
||||
world: context.world,
|
||||
worldRef: worldView,
|
||||
reservedTurnProvider,
|
||||
scenarioConfig: options.scenarioConfig,
|
||||
scenarioMeta: options.scenarioMeta,
|
||||
map: options.map,
|
||||
unitSet: options.unitSet,
|
||||
commandEnv: env,
|
||||
generalDefinitions,
|
||||
nationDefinitions,
|
||||
generalFallback,
|
||||
nationFallback,
|
||||
});
|
||||
const candidate = ai.chooseNationTurn(nationCommand);
|
||||
if (candidate) {
|
||||
nationCommand = { action: candidate.action, args: candidate.args };
|
||||
}
|
||||
}
|
||||
runAction(nationDefinitions, nationFallback, nationCommand, false);
|
||||
options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1);
|
||||
}
|
||||
|
||||
const generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0);
|
||||
let generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0);
|
||||
if (worldView && shouldUseAi(currentGeneral, context.world)) {
|
||||
const ai = new GeneralAI({
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
nation: currentNation,
|
||||
world: context.world,
|
||||
worldRef: worldView,
|
||||
reservedTurnProvider,
|
||||
scenarioConfig: options.scenarioConfig,
|
||||
scenarioMeta: options.scenarioMeta,
|
||||
map: options.map,
|
||||
unitSet: options.unitSet,
|
||||
commandEnv: env,
|
||||
generalDefinitions,
|
||||
nationDefinitions,
|
||||
generalFallback,
|
||||
nationFallback,
|
||||
});
|
||||
const candidate = ai.chooseGeneralTurn(generalCommand);
|
||||
if (candidate) {
|
||||
generalCommand = { action: candidate.action, args: candidate.args };
|
||||
}
|
||||
}
|
||||
const nextTurnAt = runAction(generalDefinitions, generalFallback, generalCommand, true);
|
||||
options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1);
|
||||
|
||||
|
||||
@@ -135,6 +135,30 @@ export class InMemoryReservedTurnStore {
|
||||
this.generalTurns.set(generalId, buildTurnListFromRows(rows, this.maxGeneralTurns));
|
||||
}
|
||||
|
||||
async prefetchGeneralTurns(generalIds: number[]): Promise<void> {
|
||||
const targetIds = Array.from(new Set(generalIds)).filter((generalId) => !this.dirtyGeneralIds.has(generalId));
|
||||
if (targetIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const rows = await this.prisma.generalTurn.findMany({
|
||||
where: { generalId: { in: targetIds } },
|
||||
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||
});
|
||||
const grouped = new Map<number, typeof rows>();
|
||||
for (const row of rows) {
|
||||
const list = grouped.get(row.generalId);
|
||||
if (list) {
|
||||
list.push(row);
|
||||
} else {
|
||||
grouped.set(row.generalId, [row]);
|
||||
}
|
||||
}
|
||||
for (const generalId of targetIds) {
|
||||
const list = grouped.get(generalId) ?? [];
|
||||
this.generalTurns.set(generalId, buildTurnListFromRows(list, this.maxGeneralTurns));
|
||||
}
|
||||
}
|
||||
|
||||
async refreshNationTurns(nationId: number, officerLevel: number): Promise<void> {
|
||||
const key = buildNationKey(nationId, officerLevel);
|
||||
if (this.dirtyNationKeys.has(key)) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createReservedTurnStore } from './reservedTurnStore.js';
|
||||
import { createTurnDaemonCommandHandler } from './worldCommandHandler.js';
|
||||
import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
||||
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
||||
import { shouldUseAi } from './ai/generalAi.js';
|
||||
|
||||
export interface TurnDaemonRuntimeOptions {
|
||||
profile: string;
|
||||
@@ -116,14 +117,30 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
worldRef = world;
|
||||
|
||||
const stateStore = new InMemoryTurnStateStore(world);
|
||||
const prefetchedNationTurns = new Set<string>();
|
||||
const processor = new InMemoryTurnProcessor(world, {
|
||||
tickMinutes,
|
||||
beforeExecuteGeneral: reservedTurnStoreHandle
|
||||
? async (general) => {
|
||||
await reservedTurnStoreHandle.store.refreshGeneralTurns(general.id);
|
||||
const promises: Promise<unknown>[] = [];
|
||||
promises.push(reservedTurnStoreHandle.store.refreshGeneralTurns(general.id));
|
||||
if (general.nationId > 0 && general.officerLevel >= 5) {
|
||||
await reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel);
|
||||
promises.push(
|
||||
reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel)
|
||||
);
|
||||
}
|
||||
if (general.nationId > 0 && general.officerLevel >= 5 && shouldUseAi(general, world.getState())) {
|
||||
const key = `${general.nationId}:${world.getState().currentYear}:${world.getState().currentMonth}`;
|
||||
if (!prefetchedNationTurns.has(key)) {
|
||||
prefetchedNationTurns.add(key);
|
||||
const generalIds = world
|
||||
.listGenerals()
|
||||
.filter((candidate) => candidate.nationId === general.nationId)
|
||||
.map((candidate) => candidate.id);
|
||||
promises.push(reservedTurnStoreHandle.store.prefetchGeneralTurns(generalIds));
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user