Refactor AI nation actions: consolidate functions into a single export module for improved organization and maintainability.
This commit is contained in:
@@ -1,924 +1,2 @@
|
|||||||
import type {
|
export * from './generalAi/core.js';
|
||||||
City,
|
export type { GeneralAIOptions, GeneralAiDebugState } from './generalAi/types.js';
|
||||||
GeneralActionDefinition,
|
|
||||||
MapDefinition,
|
|
||||||
Nation,
|
|
||||||
ScenarioConfig,
|
|
||||||
ScenarioMeta,
|
|
||||||
TurnCommandEnv,
|
|
||||||
UnitSetDefinition,
|
|
||||||
} from '@sammo-ts/logic';
|
|
||||||
import { evaluateConstraints } from '@sammo-ts/logic';
|
|
||||||
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
|
||||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
|
||||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
|
||||||
import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
|
||||||
|
|
||||||
import type { ReservedTurnEntry } from '../reservedTurnStore.js';
|
|
||||||
import type { TurnGeneral, TurnWorldState } from '../types.js';
|
|
||||||
import type { AiCommandCandidate, AiReservedTurnProvider, AiWorldView } from './types.js';
|
|
||||||
import { AutorunGeneralPolicy, AutorunNationPolicy, AVAILABLE_INSTANT_TURN } from './policies.js';
|
|
||||||
import { asRecord, joinYearMonth, readMetaNumber, readNumber, roundTo, valueFit } from './aiUtils.js';
|
|
||||||
import { searchAllDistanceByNationList } from './distance.js';
|
|
||||||
import { generalActionHandlers } from './generalAiGeneralActions.js';
|
|
||||||
import { nationActionHandlers } from './generalAiNationActions.js';
|
|
||||||
|
|
||||||
const ACTION_REST = '휴식';
|
|
||||||
|
|
||||||
const t무장 = 1;
|
|
||||||
const t지장 = 2;
|
|
||||||
const t통솔장 = 4;
|
|
||||||
|
|
||||||
const d평화 = 0;
|
|
||||||
const d선포 = 1;
|
|
||||||
const d징병 = 2;
|
|
||||||
const d직전 = 3;
|
|
||||||
const d전쟁 = 4;
|
|
||||||
|
|
||||||
type ConstraintEnv = Record<string, unknown>;
|
|
||||||
|
|
||||||
const resolveConstraintEnv = (
|
|
||||||
world: TurnWorldState,
|
|
||||||
scenarioMeta: ScenarioMeta | undefined,
|
|
||||||
env: TurnCommandEnv
|
|
||||||
): ConstraintEnv => {
|
|
||||||
const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
|
||||||
const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentYear: world.currentYear,
|
|
||||||
currentMonth: world.currentMonth,
|
|
||||||
year: world.currentYear,
|
|
||||||
month: world.currentMonth,
|
|
||||||
startYear,
|
|
||||||
relYear,
|
|
||||||
openingPartYear: env.openingPartYear,
|
|
||||||
minAvailableRecruitPop: env.minAvailableRecruitPop,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSeedBase = (world: TurnWorldState): string => {
|
|
||||||
const meta = asRecord(world.meta);
|
|
||||||
const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id;
|
|
||||||
return String(rawSeed);
|
|
||||||
};
|
|
||||||
|
|
||||||
class WorldStateView implements StateView {
|
|
||||||
constructor(
|
|
||||||
private readonly world: AiWorldView | null,
|
|
||||||
private readonly env: ConstraintEnv,
|
|
||||||
private readonly args: Record<string, unknown>,
|
|
||||||
private readonly overrides?: {
|
|
||||||
general?: TurnGeneral;
|
|
||||||
city?: City;
|
|
||||||
nation?: Nation | null;
|
|
||||||
}
|
|
||||||
) {}
|
|
||||||
|
|
||||||
has(req: Parameters<StateView['has']>[0]): boolean {
|
|
||||||
return this.get(req) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get(req: Parameters<StateView['get']>[0]): unknown | null {
|
|
||||||
if (!this.world) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
switch (req.kind) {
|
|
||||||
case 'general':
|
|
||||||
if (this.overrides?.general && this.overrides.general.id === req.id) {
|
|
||||||
return this.overrides.general;
|
|
||||||
}
|
|
||||||
return this.world.getGeneralById(req.id);
|
|
||||||
case 'generalList':
|
|
||||||
return this.world.listGenerals();
|
|
||||||
case 'destGeneral':
|
|
||||||
return this.world.getGeneralById(req.id);
|
|
||||||
case 'city':
|
|
||||||
if (this.overrides?.city && this.overrides.city.id === req.id) {
|
|
||||||
return this.overrides.city;
|
|
||||||
}
|
|
||||||
return this.world.getCityById(req.id);
|
|
||||||
case 'destCity':
|
|
||||||
return this.world.getCityById(req.id);
|
|
||||||
case 'nation':
|
|
||||||
if (this.overrides?.nation && this.overrides.nation.id === req.id) {
|
|
||||||
return this.overrides.nation;
|
|
||||||
}
|
|
||||||
return this.world.getNationById(req.id);
|
|
||||||
case 'nationList':
|
|
||||||
return this.world.listNations();
|
|
||||||
case 'destNation':
|
|
||||||
return this.world.getNationById(req.id);
|
|
||||||
case 'diplomacy':
|
|
||||||
return this.world.getDiplomacyEntry(req.srcNationId, req.destNationId);
|
|
||||||
case 'diplomacyList':
|
|
||||||
return this.world.listDiplomacy();
|
|
||||||
case 'arg':
|
|
||||||
return this.args[req.key] ?? null;
|
|
||||||
case 'env':
|
|
||||||
return this.env[req.key] ?? null;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GeneralAIOptions {
|
|
||||||
general: TurnGeneral;
|
|
||||||
city?: City;
|
|
||||||
nation?: Nation | null;
|
|
||||||
world: TurnWorldState;
|
|
||||||
worldRef: AiWorldView | null;
|
|
||||||
reservedTurnProvider: AiReservedTurnProvider;
|
|
||||||
scenarioConfig: ScenarioConfig;
|
|
||||||
scenarioMeta?: ScenarioMeta;
|
|
||||||
map?: MapDefinition;
|
|
||||||
unitSet?: UnitSetDefinition;
|
|
||||||
commandEnv: TurnCommandEnv;
|
|
||||||
generalDefinitions: Map<string, GeneralActionDefinition>;
|
|
||||||
nationDefinitions: Map<string, GeneralActionDefinition>;
|
|
||||||
generalFallback: GeneralActionDefinition;
|
|
||||||
nationFallback: GeneralActionDefinition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeneralAiDebugState = {
|
|
||||||
generalId: number;
|
|
||||||
nationId: number | null;
|
|
||||||
cityId: number | null;
|
|
||||||
yearMonth: number;
|
|
||||||
startYear: number;
|
|
||||||
startYearMonth: number;
|
|
||||||
dipState: number;
|
|
||||||
attackable: boolean;
|
|
||||||
warTargetNation: Record<number, number>;
|
|
||||||
genType: number;
|
|
||||||
lastAttackable: number;
|
|
||||||
frontCities: Array<{ id: number; frontState: number; supplyState: number }>;
|
|
||||||
supplyCities: Array<{ id: number; frontState: number; supplyState: number }>;
|
|
||||||
policy: {
|
|
||||||
minAvailableRecruitPop: number;
|
|
||||||
minNpcWarLeadership: number;
|
|
||||||
minWarCrew: number;
|
|
||||||
cureThreshold: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export class GeneralAI {
|
|
||||||
public readonly general: TurnGeneral;
|
|
||||||
public readonly city?: City;
|
|
||||||
public readonly nation?: Nation | null;
|
|
||||||
public readonly world: TurnWorldState;
|
|
||||||
public readonly worldRef: AiWorldView | null;
|
|
||||||
public readonly map?: MapDefinition;
|
|
||||||
public readonly unitSet?: UnitSetDefinition;
|
|
||||||
public readonly commandEnv: TurnCommandEnv;
|
|
||||||
public readonly scenarioConfig: ScenarioConfig;
|
|
||||||
public readonly scenarioMeta?: ScenarioMeta;
|
|
||||||
|
|
||||||
public readonly generalDefinitions: Map<string, GeneralActionDefinition>;
|
|
||||||
public readonly nationDefinitions: Map<string, GeneralActionDefinition>;
|
|
||||||
public readonly generalFallback: GeneralActionDefinition;
|
|
||||||
public readonly nationFallback: GeneralActionDefinition;
|
|
||||||
|
|
||||||
public readonly rng: RandUtil;
|
|
||||||
public readonly env: ConstraintEnv;
|
|
||||||
public readonly startYear: number;
|
|
||||||
public readonly turnTermMinutes: number;
|
|
||||||
|
|
||||||
public readonly aiConst: {
|
|
||||||
baseGold: number;
|
|
||||||
baseRice: number;
|
|
||||||
minAvailableRecruitPop: number;
|
|
||||||
maxResourceActionAmount: number;
|
|
||||||
minNationalGold: number;
|
|
||||||
minNationalRice: number;
|
|
||||||
defaultStatMax: number;
|
|
||||||
defaultStatNpcMax: number;
|
|
||||||
chiefStatMin: number;
|
|
||||||
npcMessageFreqByDay: number;
|
|
||||||
availableNationTypes: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
public generalPolicy: AutorunGeneralPolicy;
|
|
||||||
public nationPolicy: AutorunNationPolicy;
|
|
||||||
|
|
||||||
public genType = 0;
|
|
||||||
public dipState = d평화;
|
|
||||||
public warTargetNation: Record<number, number> = {};
|
|
||||||
public attackable = false;
|
|
||||||
public maxResourceActionAmount = 0;
|
|
||||||
|
|
||||||
public nationCities: Record<
|
|
||||||
number,
|
|
||||||
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
||||||
> = {};
|
|
||||||
public frontCities: Record<
|
|
||||||
number,
|
|
||||||
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
||||||
> = {};
|
|
||||||
public supplyCities: Record<
|
|
||||||
number,
|
|
||||||
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
||||||
> = {};
|
|
||||||
public backupCities: Record<
|
|
||||||
number,
|
|
||||||
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
|
||||||
> = {};
|
|
||||||
public warRoute: Record<number, Record<number, number>> | null = null;
|
|
||||||
|
|
||||||
public nationGenerals: TurnGeneral[] = [];
|
|
||||||
public npcCivilGenerals: Record<number, TurnGeneral> = {};
|
|
||||||
public npcWarGenerals: Record<number, TurnGeneral> = {};
|
|
||||||
public userGenerals: Record<number, TurnGeneral> = {};
|
|
||||||
public userWarGenerals: Record<number, TurnGeneral> = {};
|
|
||||||
public userCivilGenerals: Record<number, TurnGeneral> = {};
|
|
||||||
public chiefGenerals: Record<number, TurnGeneral> = {};
|
|
||||||
public lostGenerals: Record<number, TurnGeneral> = {};
|
|
||||||
public troopLeaders: Record<number, TurnGeneral> = {};
|
|
||||||
|
|
||||||
private reqUpdateInstance = true;
|
|
||||||
private devRate: Record<string, number> | null = null;
|
|
||||||
private categorizedCities = false;
|
|
||||||
private categorizedGenerals = false;
|
|
||||||
|
|
||||||
private readonly reservedTurnProvider: AiReservedTurnProvider;
|
|
||||||
|
|
||||||
constructor(options: GeneralAIOptions) {
|
|
||||||
this.general = options.general;
|
|
||||||
this.city = options.city;
|
|
||||||
this.nation = options.nation ?? null;
|
|
||||||
this.world = options.world;
|
|
||||||
this.worldRef = options.worldRef;
|
|
||||||
this.map = options.map;
|
|
||||||
this.unitSet = options.unitSet;
|
|
||||||
this.commandEnv = options.commandEnv;
|
|
||||||
this.scenarioConfig = options.scenarioConfig;
|
|
||||||
this.scenarioMeta = options.scenarioMeta;
|
|
||||||
this.reservedTurnProvider = options.reservedTurnProvider;
|
|
||||||
|
|
||||||
this.generalDefinitions = options.generalDefinitions;
|
|
||||||
this.nationDefinitions = options.nationDefinitions;
|
|
||||||
this.generalFallback = options.generalFallback;
|
|
||||||
this.nationFallback = options.nationFallback;
|
|
||||||
|
|
||||||
this.startYear = resolveStartYear(this.world, this.scenarioMeta);
|
|
||||||
this.turnTermMinutes = resolveTurnTermMinutes(this.world);
|
|
||||||
this.env = resolveConstraintEnv(this.world, this.scenarioMeta, this.commandEnv);
|
|
||||||
|
|
||||||
const seed = simpleSerialize(
|
|
||||||
buildSeedBase(this.world),
|
|
||||||
'GeneralAI',
|
|
||||||
this.world.currentYear,
|
|
||||||
this.world.currentMonth,
|
|
||||||
this.general.id
|
|
||||||
);
|
|
||||||
this.rng = new RandUtil(LiteHashDRBG.build(seed));
|
|
||||||
|
|
||||||
const constValues = asRecord(this.scenarioConfig.const);
|
|
||||||
this.aiConst = {
|
|
||||||
baseGold: this.commandEnv.baseGold,
|
|
||||||
baseRice: this.commandEnv.baseRice,
|
|
||||||
minAvailableRecruitPop: readNumber(constValues.minAvailableRecruitPop, 30000),
|
|
||||||
maxResourceActionAmount: this.commandEnv.maxResourceActionAmount || 10000,
|
|
||||||
minNationalGold: readNumber(constValues.minNationalGold, this.commandEnv.baseGold),
|
|
||||||
minNationalRice: readNumber(constValues.minNationalRice, this.commandEnv.baseRice),
|
|
||||||
defaultStatMax: this.scenarioConfig.stat.max,
|
|
||||||
defaultStatNpcMax: this.scenarioConfig.stat.npcMax,
|
|
||||||
chiefStatMin: this.scenarioConfig.stat.chiefMin,
|
|
||||||
npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0),
|
|
||||||
availableNationTypes: Array.isArray(constValues.availableNationType)
|
|
||||||
? constValues.availableNationType.filter((value) => typeof value === 'string')
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const generalPolicy = new AutorunGeneralPolicy(
|
|
||||||
this.general,
|
|
||||||
asRecord((this.world.meta as Record<string, unknown>)?.autorun_user)?.options as Record<string, boolean>,
|
|
||||||
asRecord(this.nation?.meta)?.npc_general_policy as Record<string, unknown> | null,
|
|
||||||
asRecord(this.world.meta)?.npc_general_policy as Record<string, unknown> | null
|
|
||||||
);
|
|
||||||
const nationPolicy = new AutorunNationPolicy({
|
|
||||||
general: this.general,
|
|
||||||
aiOptions: asRecord((this.world.meta as Record<string, unknown>)?.autorun_user)?.options as Record<
|
|
||||||
string,
|
|
||||||
boolean
|
|
||||||
> | null,
|
|
||||||
nationPolicy: asRecord(this.nation?.meta)?.npc_nation_policy as Record<string, unknown> | null,
|
|
||||||
serverPolicy: asRecord(this.world.meta)?.npc_nation_policy as Record<string, unknown> | null,
|
|
||||||
nation: this.nation ?? {
|
|
||||||
id: 0,
|
|
||||||
name: '재야',
|
|
||||||
color: '#000000',
|
|
||||||
capitalCityId: null,
|
|
||||||
chiefGeneralId: null,
|
|
||||||
gold: 0,
|
|
||||||
rice: 0,
|
|
||||||
power: 0,
|
|
||||||
level: 0,
|
|
||||||
typeCode: 'neutral',
|
|
||||||
meta: {},
|
|
||||||
},
|
|
||||||
env: this.commandEnv,
|
|
||||||
scenarioConfig: this.scenarioConfig,
|
|
||||||
unitSet: this.unitSet,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.generalPolicy = generalPolicy;
|
|
||||||
this.nationPolicy = nationPolicy;
|
|
||||||
}
|
|
||||||
|
|
||||||
chooseNationTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
|
||||||
this.updateInstance();
|
|
||||||
if (!this.nation || !this.worldRef) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
this.categorizeNationCities();
|
|
||||||
this.categorizeNationGeneral();
|
|
||||||
|
|
||||||
if (reservedTurn.action !== ACTION_REST) {
|
|
||||||
const reservedCandidate = this.buildNationCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
|
|
||||||
if (reservedCandidate) {
|
|
||||||
return reservedCandidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const actionName of this.nationPolicy.priority) {
|
|
||||||
if (!this.nationPolicy.can(actionName)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (this.general.npcState < 2 && !AVAILABLE_INSTANT_TURN[actionName]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const handler = nationActionHandlers[actionName];
|
|
||||||
if (!handler) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const result = handler(this);
|
|
||||||
if (result) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.buildNationCandidate(ACTION_REST, {}, 'neutral');
|
|
||||||
}
|
|
||||||
|
|
||||||
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
|
||||||
this.updateInstance();
|
|
||||||
if (!this.worldRef) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.general.npcState === 5) {
|
|
||||||
const result = generalActionHandlers['집합']?.(this);
|
|
||||||
return result ?? this.buildGeneralCandidate(ACTION_REST, {}, 'npc_troop');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reservedTurn.action !== ACTION_REST) {
|
|
||||||
const reservedCandidate = this.buildGeneralCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
|
|
||||||
if (reservedCandidate) {
|
|
||||||
return reservedCandidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold
|
|
||||||
) {
|
|
||||||
const heal = this.buildGeneralCandidate('che_요양', {}, 'heal');
|
|
||||||
if (heal) {
|
|
||||||
return heal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) {
|
|
||||||
const rebellion = generalActionHandlers['거병']?.(this);
|
|
||||||
if (rebellion) {
|
|
||||||
return rebellion;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.general.nationId === 0 && this.generalPolicy.can('국가선택')) {
|
|
||||||
const pickNation = generalActionHandlers['국가선택']?.(this);
|
|
||||||
if (pickNation) {
|
|
||||||
return pickNation;
|
|
||||||
}
|
|
||||||
const neutral = generalActionHandlers['중립']?.(this);
|
|
||||||
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.general.npcState < 2 && this.general.nationId === 0 && !this.generalPolicy.can('국가선택')) {
|
|
||||||
return this.buildGeneralCandidate(ACTION_REST, {}, 'neutral_user');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) {
|
|
||||||
const worldMeta = asRecord(this.world.meta);
|
|
||||||
const initYear = readNumber(worldMeta.initYear, this.scenarioMeta?.startYear ?? this.startYear);
|
|
||||||
const initMonth = readNumber(worldMeta.initMonth, 1);
|
|
||||||
const relYearMonth =
|
|
||||||
joinYearMonth(this.world.currentYear, this.world.currentMonth) - joinYearMonth(initYear, initMonth);
|
|
||||||
if (relYearMonth > 1) {
|
|
||||||
const establish = generalActionHandlers['건국']?.(this);
|
|
||||||
if (establish) {
|
|
||||||
return establish;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const move = generalActionHandlers['방랑군이동']?.(this);
|
|
||||||
if (move) {
|
|
||||||
return move;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const actionName of this.generalPolicy.priority) {
|
|
||||||
if (!this.generalPolicy.can(actionName)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const handler = generalActionHandlers[actionName];
|
|
||||||
if (!handler) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const result = handler(this);
|
|
||||||
if (result) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const neutral = generalActionHandlers['중립']?.(this);
|
|
||||||
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
|
|
||||||
}
|
|
||||||
|
|
||||||
getDebugState(): GeneralAiDebugState {
|
|
||||||
this.updateInstance();
|
|
||||||
this.categorizeNationCities();
|
|
||||||
const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth);
|
|
||||||
const startYearMonth = joinYearMonth(this.startYear + 2, 5);
|
|
||||||
const meta = asRecord(this.nation?.meta ?? {});
|
|
||||||
const lastAttackable = readMetaNumber(meta, 'last_attackable', 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
generalId: this.general.id,
|
|
||||||
nationId: this.nation?.id ?? null,
|
|
||||||
cityId: this.city?.id ?? null,
|
|
||||||
yearMonth,
|
|
||||||
startYear: this.startYear,
|
|
||||||
startYearMonth,
|
|
||||||
dipState: this.dipState,
|
|
||||||
attackable: this.attackable,
|
|
||||||
warTargetNation: { ...this.warTargetNation },
|
|
||||||
genType: this.genType,
|
|
||||||
lastAttackable,
|
|
||||||
frontCities: Object.values(this.frontCities).map((city) => ({
|
|
||||||
id: city.id,
|
|
||||||
frontState: city.frontState,
|
|
||||||
supplyState: city.supplyState,
|
|
||||||
})),
|
|
||||||
supplyCities: Object.values(this.supplyCities).map((city) => ({
|
|
||||||
id: city.id,
|
|
||||||
frontState: city.frontState,
|
|
||||||
supplyState: city.supplyState,
|
|
||||||
})),
|
|
||||||
policy: {
|
|
||||||
minAvailableRecruitPop: this.aiConst.minAvailableRecruitPop,
|
|
||||||
minNpcWarLeadership: this.nationPolicy.minNpcWarLeadership,
|
|
||||||
minWarCrew: this.nationPolicy.minWarCrew,
|
|
||||||
cureThreshold: this.nationPolicy.cureThreshold,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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,25 @@
|
|||||||
|
import type { ScenarioMeta, TurnCommandEnv } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import type { TurnWorldState } from '../../types.js';
|
||||||
|
|
||||||
|
export type ConstraintEnv = Record<string, unknown>;
|
||||||
|
|
||||||
|
export const resolveConstraintEnv = (
|
||||||
|
world: TurnWorldState,
|
||||||
|
scenarioMeta: ScenarioMeta | undefined,
|
||||||
|
env: TurnCommandEnv
|
||||||
|
): ConstraintEnv => {
|
||||||
|
const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
||||||
|
const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentYear: world.currentYear,
|
||||||
|
currentMonth: world.currentMonth,
|
||||||
|
year: world.currentYear,
|
||||||
|
month: world.currentMonth,
|
||||||
|
startYear,
|
||||||
|
relYear,
|
||||||
|
openingPartYear: env.openingPartYear,
|
||||||
|
minAvailableRecruitPop: env.minAvailableRecruitPop,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
import type {
|
||||||
|
City,
|
||||||
|
GeneralActionDefinition,
|
||||||
|
MapDefinition,
|
||||||
|
Nation,
|
||||||
|
ScenarioConfig,
|
||||||
|
ScenarioMeta,
|
||||||
|
TurnCommandEnv,
|
||||||
|
UnitSetDefinition,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
import { evaluateConstraints } from '@sammo-ts/logic';
|
||||||
|
import type { ConstraintContext } 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';
|
||||||
|
import { resolveConstraintEnv, type ConstraintEnv } from './constraint.js';
|
||||||
|
import { buildSeedBase } from './seed.js';
|
||||||
|
import { WorldStateView } from './worldStateView.js';
|
||||||
|
import type { GeneralAIOptions, GeneralAiDebugState } from './types.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;
|
||||||
|
|
||||||
|
export class GeneralAI {
|
||||||
|
public readonly general: TurnGeneral;
|
||||||
|
public readonly city?: City;
|
||||||
|
public readonly nation?: Nation | null;
|
||||||
|
public readonly world: TurnWorldState;
|
||||||
|
public readonly worldRef: AiWorldView | null;
|
||||||
|
public readonly map?: MapDefinition;
|
||||||
|
public readonly unitSet?: UnitSetDefinition;
|
||||||
|
public readonly commandEnv: TurnCommandEnv;
|
||||||
|
public readonly scenarioConfig: ScenarioConfig;
|
||||||
|
public readonly scenarioMeta?: ScenarioMeta;
|
||||||
|
|
||||||
|
public readonly generalDefinitions: Map<string, GeneralActionDefinition>;
|
||||||
|
public readonly nationDefinitions: Map<string, GeneralActionDefinition>;
|
||||||
|
public readonly generalFallback: GeneralActionDefinition;
|
||||||
|
public readonly nationFallback: GeneralActionDefinition;
|
||||||
|
|
||||||
|
public readonly rng: RandUtil;
|
||||||
|
public readonly env: ConstraintEnv;
|
||||||
|
public readonly startYear: number;
|
||||||
|
public readonly turnTermMinutes: number;
|
||||||
|
|
||||||
|
public readonly aiConst: {
|
||||||
|
baseGold: number;
|
||||||
|
baseRice: number;
|
||||||
|
minAvailableRecruitPop: number;
|
||||||
|
maxResourceActionAmount: number;
|
||||||
|
minNationalGold: number;
|
||||||
|
minNationalRice: number;
|
||||||
|
defaultStatMax: number;
|
||||||
|
defaultStatNpcMax: number;
|
||||||
|
chiefStatMin: number;
|
||||||
|
npcMessageFreqByDay: number;
|
||||||
|
availableNationTypes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
public generalPolicy: AutorunGeneralPolicy;
|
||||||
|
public nationPolicy: AutorunNationPolicy;
|
||||||
|
|
||||||
|
public genType = 0;
|
||||||
|
public dipState = d평화;
|
||||||
|
public warTargetNation: Record<number, number> = {};
|
||||||
|
public attackable = false;
|
||||||
|
public maxResourceActionAmount = 0;
|
||||||
|
|
||||||
|
public nationCities: Record<
|
||||||
|
number,
|
||||||
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
||||||
|
> = {};
|
||||||
|
public frontCities: Record<
|
||||||
|
number,
|
||||||
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
||||||
|
> = {};
|
||||||
|
public supplyCities: Record<
|
||||||
|
number,
|
||||||
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
||||||
|
> = {};
|
||||||
|
public backupCities: Record<
|
||||||
|
number,
|
||||||
|
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
|
||||||
|
> = {};
|
||||||
|
public warRoute: Record<number, Record<number, number>> | null = null;
|
||||||
|
|
||||||
|
public nationGenerals: TurnGeneral[] = [];
|
||||||
|
public npcCivilGenerals: Record<number, TurnGeneral> = {};
|
||||||
|
public npcWarGenerals: Record<number, TurnGeneral> = {};
|
||||||
|
public userGenerals: Record<number, TurnGeneral> = {};
|
||||||
|
public userWarGenerals: Record<number, TurnGeneral> = {};
|
||||||
|
public userCivilGenerals: Record<number, TurnGeneral> = {};
|
||||||
|
public chiefGenerals: Record<number, TurnGeneral> = {};
|
||||||
|
public lostGenerals: Record<number, TurnGeneral> = {};
|
||||||
|
public troopLeaders: Record<number, TurnGeneral> = {};
|
||||||
|
|
||||||
|
private reqUpdateInstance = true;
|
||||||
|
private devRate: Record<string, number> | null = null;
|
||||||
|
private categorizedCities = false;
|
||||||
|
private categorizedGenerals = false;
|
||||||
|
|
||||||
|
private readonly reservedTurnProvider: AiReservedTurnProvider;
|
||||||
|
|
||||||
|
constructor(options: GeneralAIOptions) {
|
||||||
|
this.general = options.general;
|
||||||
|
this.city = options.city;
|
||||||
|
this.nation = options.nation ?? null;
|
||||||
|
this.world = options.world;
|
||||||
|
this.worldRef = options.worldRef;
|
||||||
|
this.map = options.map;
|
||||||
|
this.unitSet = options.unitSet;
|
||||||
|
this.commandEnv = options.commandEnv;
|
||||||
|
this.scenarioConfig = options.scenarioConfig;
|
||||||
|
this.scenarioMeta = options.scenarioMeta;
|
||||||
|
this.reservedTurnProvider = options.reservedTurnProvider;
|
||||||
|
|
||||||
|
this.generalDefinitions = options.generalDefinitions;
|
||||||
|
this.nationDefinitions = options.nationDefinitions;
|
||||||
|
this.generalFallback = options.generalFallback;
|
||||||
|
this.nationFallback = options.nationFallback;
|
||||||
|
|
||||||
|
this.startYear = resolveStartYear(this.world, this.scenarioMeta);
|
||||||
|
this.turnTermMinutes = resolveTurnTermMinutes(this.world);
|
||||||
|
this.env = resolveConstraintEnv(this.world, this.scenarioMeta, this.commandEnv);
|
||||||
|
|
||||||
|
const seed = simpleSerialize(
|
||||||
|
buildSeedBase(this.world),
|
||||||
|
'GeneralAI',
|
||||||
|
this.world.currentYear,
|
||||||
|
this.world.currentMonth,
|
||||||
|
this.general.id
|
||||||
|
);
|
||||||
|
this.rng = new RandUtil(LiteHashDRBG.build(seed));
|
||||||
|
|
||||||
|
const constValues = asRecord(this.scenarioConfig.const);
|
||||||
|
this.aiConst = {
|
||||||
|
baseGold: this.commandEnv.baseGold,
|
||||||
|
baseRice: this.commandEnv.baseRice,
|
||||||
|
minAvailableRecruitPop: readNumber(constValues.minAvailableRecruitPop, 30000),
|
||||||
|
maxResourceActionAmount: this.commandEnv.maxResourceActionAmount || 10000,
|
||||||
|
minNationalGold: readNumber(constValues.minNationalGold, this.commandEnv.baseGold),
|
||||||
|
minNationalRice: readNumber(constValues.minNationalRice, this.commandEnv.baseRice),
|
||||||
|
defaultStatMax: this.scenarioConfig.stat.max,
|
||||||
|
defaultStatNpcMax: this.scenarioConfig.stat.npcMax,
|
||||||
|
chiefStatMin: this.scenarioConfig.stat.chiefMin,
|
||||||
|
npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0),
|
||||||
|
availableNationTypes: Array.isArray(constValues.availableNationType)
|
||||||
|
? constValues.availableNationType.filter((value) => typeof value === 'string')
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const generalPolicy = new AutorunGeneralPolicy(
|
||||||
|
this.general,
|
||||||
|
asRecord((this.world.meta as Record<string, unknown>)?.autorun_user)?.options as Record<string, boolean>,
|
||||||
|
asRecord(this.nation?.meta)?.npc_general_policy as Record<string, unknown> | null,
|
||||||
|
asRecord(this.world.meta)?.npc_general_policy as Record<string, unknown> | null
|
||||||
|
);
|
||||||
|
const nationPolicy = new AutorunNationPolicy({
|
||||||
|
general: this.general,
|
||||||
|
aiOptions: asRecord((this.world.meta as Record<string, unknown>)?.autorun_user)?.options as Record<
|
||||||
|
string,
|
||||||
|
boolean
|
||||||
|
> | null,
|
||||||
|
nationPolicy: asRecord(this.nation?.meta)?.npc_nation_policy as Record<string, unknown> | null,
|
||||||
|
serverPolicy: asRecord(this.world.meta)?.npc_nation_policy as Record<string, unknown> | null,
|
||||||
|
nation: this.nation ?? {
|
||||||
|
id: 0,
|
||||||
|
name: '재야',
|
||||||
|
color: '#000000',
|
||||||
|
capitalCityId: null,
|
||||||
|
chiefGeneralId: null,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
level: 0,
|
||||||
|
typeCode: 'neutral',
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
env: this.commandEnv,
|
||||||
|
scenarioConfig: this.scenarioConfig,
|
||||||
|
unitSet: this.unitSet,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.generalPolicy = generalPolicy;
|
||||||
|
this.nationPolicy = nationPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
chooseNationTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
||||||
|
this.updateInstance();
|
||||||
|
if (!this.nation || !this.worldRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.categorizeNationCities();
|
||||||
|
this.categorizeNationGeneral();
|
||||||
|
|
||||||
|
if (reservedTurn.action !== ACTION_REST) {
|
||||||
|
const reservedCandidate = this.buildNationCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
|
||||||
|
if (reservedCandidate) {
|
||||||
|
return reservedCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const actionName of this.nationPolicy.priority) {
|
||||||
|
if (!this.nationPolicy.can(actionName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (this.general.npcState < 2 && !AVAILABLE_INSTANT_TURN[actionName]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const handler = nationActionHandlers[actionName];
|
||||||
|
if (!handler) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = handler(this);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildNationCandidate(ACTION_REST, {}, 'neutral');
|
||||||
|
}
|
||||||
|
|
||||||
|
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
||||||
|
this.updateInstance();
|
||||||
|
if (!this.worldRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.general.npcState === 5) {
|
||||||
|
const result = generalActionHandlers['집합']?.(this);
|
||||||
|
return result ?? this.buildGeneralCandidate(ACTION_REST, {}, 'npc_troop');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reservedTurn.action !== ACTION_REST) {
|
||||||
|
const reservedCandidate = this.buildGeneralCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
|
||||||
|
if (reservedCandidate) {
|
||||||
|
return reservedCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold
|
||||||
|
) {
|
||||||
|
const heal = this.buildGeneralCandidate('che_요양', {}, 'heal');
|
||||||
|
if (heal) {
|
||||||
|
return heal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) {
|
||||||
|
const rebellion = generalActionHandlers['거병']?.(this);
|
||||||
|
if (rebellion) {
|
||||||
|
return rebellion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.general.nationId === 0 && this.generalPolicy.can('국가선택')) {
|
||||||
|
const pickNation = generalActionHandlers['국가선택']?.(this);
|
||||||
|
if (pickNation) {
|
||||||
|
return pickNation;
|
||||||
|
}
|
||||||
|
const neutral = generalActionHandlers['중립']?.(this);
|
||||||
|
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.general.npcState < 2 && this.general.nationId === 0 && !this.generalPolicy.can('국가선택')) {
|
||||||
|
return this.buildGeneralCandidate(ACTION_REST, {}, 'neutral_user');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) {
|
||||||
|
const worldMeta = asRecord(this.world.meta);
|
||||||
|
const initYear = readNumber(worldMeta.initYear, this.scenarioMeta?.startYear ?? this.startYear);
|
||||||
|
const initMonth = readNumber(worldMeta.initMonth, 1);
|
||||||
|
const relYearMonth =
|
||||||
|
joinYearMonth(this.world.currentYear, this.world.currentMonth) - joinYearMonth(initYear, initMonth);
|
||||||
|
if (relYearMonth > 1) {
|
||||||
|
const establish = generalActionHandlers['건국']?.(this);
|
||||||
|
if (establish) {
|
||||||
|
return establish;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const move = generalActionHandlers['방랑군이동']?.(this);
|
||||||
|
if (move) {
|
||||||
|
return move;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const actionName of this.generalPolicy.priority) {
|
||||||
|
if (!this.generalPolicy.can(actionName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const handler = generalActionHandlers[actionName];
|
||||||
|
if (!handler) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = handler(this);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const neutral = generalActionHandlers['중립']?.(this);
|
||||||
|
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
|
||||||
|
}
|
||||||
|
|
||||||
|
getDebugState(): GeneralAiDebugState {
|
||||||
|
this.updateInstance();
|
||||||
|
this.categorizeNationCities();
|
||||||
|
const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth);
|
||||||
|
const startYearMonth = joinYearMonth(this.startYear + 2, 5);
|
||||||
|
const meta = asRecord(this.nation?.meta ?? {});
|
||||||
|
const lastAttackable = readMetaNumber(meta, 'last_attackable', 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generalId: this.general.id,
|
||||||
|
nationId: this.nation?.id ?? null,
|
||||||
|
cityId: this.city?.id ?? null,
|
||||||
|
yearMonth,
|
||||||
|
startYear: this.startYear,
|
||||||
|
startYearMonth,
|
||||||
|
dipState: this.dipState,
|
||||||
|
attackable: this.attackable,
|
||||||
|
warTargetNation: { ...this.warTargetNation },
|
||||||
|
genType: this.genType,
|
||||||
|
lastAttackable,
|
||||||
|
frontCities: Object.values(this.frontCities).map((city) => ({
|
||||||
|
id: city.id,
|
||||||
|
frontState: city.frontState,
|
||||||
|
supplyState: city.supplyState,
|
||||||
|
})),
|
||||||
|
supplyCities: Object.values(this.supplyCities).map((city) => ({
|
||||||
|
id: city.id,
|
||||||
|
frontState: city.frontState,
|
||||||
|
supplyState: city.supplyState,
|
||||||
|
})),
|
||||||
|
policy: {
|
||||||
|
minAvailableRecruitPop: this.aiConst.minAvailableRecruitPop,
|
||||||
|
minNpcWarLeadership: this.nationPolicy.minNpcWarLeadership,
|
||||||
|
minWarCrew: this.nationPolicy.minWarCrew,
|
||||||
|
cureThreshold: this.nationPolicy.cureThreshold,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { GeneralAIOptions, GeneralAiDebugState };
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { valueFit } from '../../aiUtils.js';
|
||||||
|
import { pickWeightedCandidate, resolveCityTrust, t무장, t지장, t통솔장 } from './helpers.js';
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
|
||||||
|
|
||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { City } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { asRecord, readMetaNumber } from '../../aiUtils.js';
|
||||||
|
|
||||||
|
export const ACTION_REST = '휴식';
|
||||||
|
|
||||||
|
export const t무장 = 1;
|
||||||
|
export const t지장 = 2;
|
||||||
|
export const t통솔장 = 4;
|
||||||
|
|
||||||
|
export const resolveCityTrust = (city: City): number => readMetaNumber(asRecord(city.meta), 'trust', 0);
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { do일반내정, do긴급내정, do전쟁내정 } from './devActions.js';
|
||||||
|
import { do금쌀구매 } from './economyActions.js';
|
||||||
|
import { do징병 } from './recruitActions.js';
|
||||||
|
import { do전투준비, do소집해제, do출병 } from './warActions.js';
|
||||||
|
import { do후방워프, do전방워프, do내정워프, do귀환, do집합 } from './warpActions.js';
|
||||||
|
import { doNPC헌납, doNPC사망대비 } from './npcActions.js';
|
||||||
|
import { do국가선택, do중립, do거병, do건국, do방랑군이동 } from './politicsActions.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
do일반내정,
|
||||||
|
do긴급내정,
|
||||||
|
do전쟁내정,
|
||||||
|
do금쌀구매,
|
||||||
|
do징병,
|
||||||
|
do전투준비,
|
||||||
|
do소집해제,
|
||||||
|
do출병,
|
||||||
|
do후방워프,
|
||||||
|
do전방워프,
|
||||||
|
do내정워프,
|
||||||
|
do귀환,
|
||||||
|
do집합,
|
||||||
|
doNPC헌납,
|
||||||
|
doNPC사망대비,
|
||||||
|
do국가선택,
|
||||||
|
do중립,
|
||||||
|
do거병,
|
||||||
|
do건국,
|
||||||
|
do방랑군이동,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,96 @@
|
|||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { asRecord, readMetaNumber } from '../../aiUtils.js';
|
||||||
|
import { t통솔장 } from './helpers.js';
|
||||||
|
|
||||||
|
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 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사망대비'
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
|
||||||
|
|
||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
|
||||||
|
import { ACTION_REST } from './helpers.js';
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
return ai.buildGeneralCandidate('che_랜덤임관', {}, '국가선택');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 }, '방랑군이동');
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { getTechCost, isCrewTypeAvailable } from '@sammo-ts/logic/world/unitSet.js';
|
||||||
|
|
||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
|
||||||
|
import { t통솔장 } from './helpers.js';
|
||||||
|
|
||||||
|
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) && ai.general.npcState < 2) {
|
||||||
|
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.general.gold <= 0 || ai.general.rice <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }, '징병');
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { valueFit } from '../../aiUtils.js';
|
||||||
|
import { pickWeightedCandidate } from './helpers.js';
|
||||||
|
|
||||||
|
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) }, '출병');
|
||||||
|
};
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { t통솔장 } from './helpers.js';
|
||||||
|
|
||||||
|
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_집합', {}, '집합');
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { GeneralAI } from '../../core.js';
|
||||||
|
import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio, selectRecruitableCity } from '../helpers.js';
|
||||||
|
|
||||||
|
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내정발령');
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type { GeneralAI } from '../../core.js';
|
||||||
|
import { joinYearMonth } from '../../../aiUtils.js';
|
||||||
|
import {
|
||||||
|
buildAssignmentCandidate,
|
||||||
|
pickRandomCityId,
|
||||||
|
pickWeightedCandidate,
|
||||||
|
resolveCityPopRatio,
|
||||||
|
resolveLastAssignment,
|
||||||
|
} from '../helpers.js';
|
||||||
|
|
||||||
|
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, '부대구출발령');
|
||||||
|
};
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import type { GeneralAI } from '../../core.js';
|
||||||
|
import {
|
||||||
|
buildAssignmentCandidate,
|
||||||
|
pickFrontCityWeight,
|
||||||
|
pickRandomCityId,
|
||||||
|
resolveCityPopRatio,
|
||||||
|
selectRecruitableCity,
|
||||||
|
} from '../helpers.js';
|
||||||
|
|
||||||
|
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, '유저장내정발령');
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { calcCityDevRatio } from '../../aiUtils.js';
|
||||||
|
import { searchAllDistanceByCityList } from '../../distance.js';
|
||||||
|
|
||||||
|
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 }, '천도');
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.js';
|
||||||
|
import { isNeighbor } from '../../distance.js';
|
||||||
|
import { resolveNationIncome } from './helpers.js';
|
||||||
|
|
||||||
|
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 }, '선전포고');
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { City } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { asRecord, readMetaNumber } from '../../aiUtils.js';
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveCityPopRatio = (city: City): number => {
|
||||||
|
if (city.populationMax <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return city.population / city.populationMax;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveLastAssignment = (general: GeneralAI['general'], yearMonth: number): boolean => {
|
||||||
|
const last = readMetaNumber(asRecord(general.meta), 'last발령', 0);
|
||||||
|
return last >= yearMonth;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildAssignmentCandidate = (ai: GeneralAI, destGeneralId: number, destCityId: number, reason: string) =>
|
||||||
|
ai.buildNationCandidate('che_발령', { destGeneralId, destCityId }, reason);
|
||||||
|
|
||||||
|
export const buildSeizureCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
|
||||||
|
ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason);
|
||||||
|
|
||||||
|
export const buildAwardCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
|
||||||
|
ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason);
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { do부대전방발령, do부대후방발령, do부대구출발령 } from './assignments/troopAssignments.js';
|
||||||
|
import {
|
||||||
|
do부대유저장후방발령,
|
||||||
|
do유저장후방발령,
|
||||||
|
do유저장구출발령,
|
||||||
|
do유저장전방발령,
|
||||||
|
do유저장내정발령,
|
||||||
|
} from './assignments/userAssignments.js';
|
||||||
|
import { doNPC후방발령, doNPC구출발령, doNPC전방발령, doNPC내정발령 } from './assignments/npcAssignments.js';
|
||||||
|
import { do유저장긴급포상, do유저장포상, doNPC긴급포상, doNPC포상, doNPC몰수 } from './rewards.js';
|
||||||
|
import { do불가침제의, do선전포고 } from './diplomacy.js';
|
||||||
|
import { do천도 } from './capital.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
do부대전방발령,
|
||||||
|
do부대후방발령,
|
||||||
|
do부대구출발령,
|
||||||
|
do부대유저장후방발령,
|
||||||
|
do유저장후방발령,
|
||||||
|
do유저장구출발령,
|
||||||
|
do유저장전방발령,
|
||||||
|
do유저장내정발령,
|
||||||
|
doNPC후방발령,
|
||||||
|
doNPC구출발령,
|
||||||
|
doNPC전방발령,
|
||||||
|
doNPC내정발령,
|
||||||
|
do유저장긴급포상,
|
||||||
|
do유저장포상,
|
||||||
|
doNPC긴급포상,
|
||||||
|
doNPC포상,
|
||||||
|
doNPC몰수,
|
||||||
|
do불가침제의,
|
||||||
|
do선전포고,
|
||||||
|
do천도,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,189 @@
|
|||||||
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { asRecord, readMetaNumber } from '../../aiUtils.js';
|
||||||
|
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate, resolveAwardAmount } from './helpers.js';
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { TurnWorldState } from '../../types.js';
|
||||||
|
import { asRecord } from '../aiUtils.js';
|
||||||
|
|
||||||
|
export const buildSeedBase = (world: TurnWorldState): string => {
|
||||||
|
const meta = asRecord(world.meta);
|
||||||
|
const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id;
|
||||||
|
return String(rawSeed);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type {
|
||||||
|
City,
|
||||||
|
GeneralActionDefinition,
|
||||||
|
MapDefinition,
|
||||||
|
Nation,
|
||||||
|
ScenarioConfig,
|
||||||
|
ScenarioMeta,
|
||||||
|
TurnCommandEnv,
|
||||||
|
UnitSetDefinition,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import type { ReservedTurnEntry } from '../../reservedTurnStore.js';
|
||||||
|
import type { TurnGeneral, TurnWorldState } from '../../types.js';
|
||||||
|
import type { AiReservedTurnProvider, AiWorldView } from '../types.js';
|
||||||
|
|
||||||
|
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 type GeneralAiDebugState = {
|
||||||
|
generalId: number;
|
||||||
|
nationId: number | null;
|
||||||
|
cityId: number | null;
|
||||||
|
yearMonth: number;
|
||||||
|
startYear: number;
|
||||||
|
startYearMonth: number;
|
||||||
|
dipState: number;
|
||||||
|
attackable: boolean;
|
||||||
|
warTargetNation: Record<number, number>;
|
||||||
|
genType: number;
|
||||||
|
lastAttackable: number;
|
||||||
|
frontCities: Array<{ id: number; frontState: number; supplyState: number }>;
|
||||||
|
supplyCities: Array<{ id: number; frontState: number; supplyState: number }>;
|
||||||
|
policy: {
|
||||||
|
minAvailableRecruitPop: number;
|
||||||
|
minNpcWarLeadership: number;
|
||||||
|
minWarCrew: number;
|
||||||
|
cureThreshold: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { ReservedTurnEntry };
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { City, Nation } from '@sammo-ts/logic';
|
||||||
|
import type { StateView } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import type { TurnGeneral } from '../../types.js';
|
||||||
|
import type { AiWorldView } from '../types.js';
|
||||||
|
import type { ConstraintEnv } from './constraint.js';
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,942 +1 @@
|
|||||||
import type { City } from '@sammo-ts/logic';
|
export * from './generalAi/general/index.js';
|
||||||
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) && ai.general.npcState < 2) {
|
|
||||||
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.general.gold <= 0 || ai.general.rice <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
return ai.buildGeneralCandidate('che_랜덤임관', {}, '국가선택');
|
|
||||||
}
|
|
||||||
|
|
||||||
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방랑군이동,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,1055 +1 @@
|
|||||||
import type { City } from '@sammo-ts/logic';
|
export * from './generalAi/nation/index.js';
|
||||||
|
|
||||||
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몰수,
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user