feat(ai): add autorun policies for general and nation management

- Introduced `policies.ts` to define AI policies for generals and nations, including priority actions and flags.
- Implemented `AutorunGeneralPolicy` class to manage general-specific actions based on AI options and server/nation policies.
- Implemented `AutorunNationPolicy` class to handle nation-specific actions, including resource requirements and combat strategies.
- Added types for AI context and world view in `types.ts` to facilitate interaction with game state and entities.
This commit is contained in:
2026-01-11 14:49:42 +00:00
parent cc41d1538e
commit 550910811c
10 changed files with 3623 additions and 4 deletions
+89
View File
@@ -0,0 +1,89 @@
import type { City, Nation } from '@sammo-ts/logic';
export const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
export const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
export const readNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
export const readBoolean = (value: unknown, fallback = false): boolean => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
return value === 'true' || value === '1';
}
return fallback;
};
export const readString = (value: unknown, fallback = ''): string => (typeof value === 'string' ? value : fallback);
export const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback = 0): number =>
readNumber(meta[key], fallback);
export const readMetaString = (meta: Record<string, unknown>, key: string, fallback = ''): string =>
readString(meta[key], fallback);
export const readMetaBoolean = (meta: Record<string, unknown>, key: string, fallback = false): boolean =>
readBoolean(meta[key], fallback);
export const valueFit = (value: number, min?: number | null, max?: number | null): number => {
let next = value;
if (min !== null && min !== undefined && next < min) {
next = min;
}
if (max !== null && max !== undefined && next > max) {
next = max;
}
return next;
};
export const roundTo = (value: number, digits = 0): number => {
if (!Number.isFinite(value)) {
return 0;
}
const factor = Math.pow(10, Math.abs(digits));
if (digits >= 0) {
return Math.round(value * factor) / factor;
}
return Math.round(value / factor) * factor;
};
export const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
export const parseYearMonth = (value: number): [number, number] => {
const year = Math.floor(value / 12);
const month = (value % 12) + 1;
return [year, month];
};
export const calcCityDevRatio = (city: City): number => {
const total = city.agriculture + city.commerce + city.security + city.defence + city.wall;
const max = city.agricultureMax + city.commerceMax + city.securityMax + city.defenceMax + city.wallMax;
if (max <= 0) {
return 0;
}
return total / max;
};
export const readNationTech = (nation: Nation | null | undefined): number => {
if (!nation) {
return 0;
}
return readMetaNumber(asRecord(nation.meta), 'tech', 0);
};
+96
View File
@@ -0,0 +1,96 @@
import type { City, MapDefinition } from '@sammo-ts/logic';
const buildConnectionMap = (map: MapDefinition): Map<number, number[]> => {
const result = new Map<number, number[]>();
for (const city of map.cities) {
result.set(city.id, city.connections ?? []);
}
return result;
};
export const searchAllDistanceByCityList = (
map: MapDefinition,
cityIds: number[]
): Record<number, Record<number, number>> => {
if (cityIds.length === 0) {
return {};
}
const connectionMap = buildConnectionMap(map);
const citySet = new Set(cityIds);
const result: Record<number, Record<number, number>> = {};
for (const startId of citySet) {
const distances: Record<number, number> = { [startId]: 0 };
const queue: Array<[number, number]> = [[startId, 0]];
while (queue.length > 0) {
const [currentId, dist] = queue.shift()!;
const connections = connectionMap.get(currentId) ?? [];
for (const nextId of connections) {
if (!citySet.has(nextId)) {
continue;
}
if (distances[nextId] !== undefined) {
continue;
}
distances[nextId] = dist + 1;
queue.push([nextId, dist + 1]);
}
}
result[startId] = distances;
}
return result;
};
export const searchAllDistanceByNationList = (
map: MapDefinition,
cities: City[],
nationIds: number[],
suppliedCityOnly: boolean
): Record<number, Record<number, number>> => {
if (nationIds.length === 0) {
return {};
}
const cityIds = cities
.filter((city) => nationIds.includes(city.nationId))
.filter((city) => !suppliedCityOnly || city.supplyState > 0)
.map((city) => city.id);
return searchAllDistanceByCityList(map, cityIds);
};
export const isNeighbor = (
map: MapDefinition,
cities: City[],
nationA: number,
nationB: number,
includeNoSupply = true
): boolean => {
if (nationA === nationB) {
return false;
}
const connectionMap = buildConnectionMap(map);
const nationACities = new Set(
cities
.filter((city) => city.nationId === nationA)
.filter((city) => includeNoSupply || city.supplyState > 0)
.map((city) => city.id)
);
const nationBCities = cities
.filter((city) => city.nationId === nationB)
.filter((city) => includeNoSupply || city.supplyState > 0)
.map((city) => city.id);
for (const cityId of nationBCities) {
const connections = connectionMap.get(cityId) ?? [];
for (const adjId of connections) {
if (nationACities.has(adjId)) {
return true;
}
}
}
return false;
};
+860
View File
@@ -0,0 +1,860 @@
import type {
City,
GeneralActionDefinition,
MapDefinition,
Nation,
ScenarioConfig,
ScenarioMeta,
TurnCommandEnv,
UnitSetDefinition,
} from '@sammo-ts/logic';
import { evaluateConstraints } from '@sammo-ts/logic';
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import type { ReservedTurnEntry } from '../reservedTurnStore.js';
import type { TurnGeneral, TurnWorldState } from '../types.js';
import type { AiCommandCandidate, AiReservedTurnProvider, AiWorldView } from './types.js';
import { AutorunGeneralPolicy, AutorunNationPolicy, AVAILABLE_INSTANT_TURN } from './policies.js';
import { asRecord, joinYearMonth, readMetaNumber, readNumber, roundTo, valueFit } from './aiUtils.js';
import { searchAllDistanceByNationList } from './distance.js';
import { generalActionHandlers } from './generalAiGeneralActions.js';
import { nationActionHandlers } from './generalAiNationActions.js';
const ACTION_REST = '휴식';
const t무장 = 1;
const t지장 = 2;
const t통솔장 = 4;
const d평화 = 0;
const d선포 = 1;
const d징병 = 2;
const d직전 = 3;
const d전쟁 = 4;
type ConstraintEnv = Record<string, unknown>;
const resolveConstraintEnv = (
world: TurnWorldState,
scenarioMeta: ScenarioMeta | undefined,
env: TurnCommandEnv
): ConstraintEnv => {
const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined;
const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined;
return {
currentYear: world.currentYear,
currentMonth: world.currentMonth,
year: world.currentYear,
month: world.currentMonth,
startYear,
relYear,
openingPartYear: env.openingPartYear,
};
};
const buildSeedBase = (world: TurnWorldState): string => {
const meta = asRecord(world.meta);
const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id;
return String(rawSeed);
};
class WorldStateView implements StateView {
constructor(
private readonly world: AiWorldView | null,
private readonly env: ConstraintEnv,
private readonly args: Record<string, unknown>,
private readonly overrides?: {
general?: TurnGeneral;
city?: City;
nation?: Nation | null;
}
) {}
has(req: Parameters<StateView['has']>[0]): boolean {
return this.get(req) !== null;
}
get(req: Parameters<StateView['get']>[0]): unknown | null {
if (!this.world) {
return null;
}
switch (req.kind) {
case 'general':
if (this.overrides?.general && this.overrides.general.id === req.id) {
return this.overrides.general;
}
return this.world.getGeneralById(req.id);
case 'generalList':
return this.world.listGenerals();
case 'destGeneral':
return this.world.getGeneralById(req.id);
case 'city':
if (this.overrides?.city && this.overrides.city.id === req.id) {
return this.overrides.city;
}
return this.world.getCityById(req.id);
case 'destCity':
return this.world.getCityById(req.id);
case 'nation':
if (this.overrides?.nation && this.overrides.nation.id === req.id) {
return this.overrides.nation;
}
return this.world.getNationById(req.id);
case 'nationList':
return this.world.listNations();
case 'destNation':
return this.world.getNationById(req.id);
case 'diplomacy':
return this.world.getDiplomacyEntry(req.srcNationId, req.destNationId);
case 'diplomacyList':
return this.world.listDiplomacy();
case 'arg':
return this.args[req.key] ?? null;
case 'env':
return this.env[req.key] ?? null;
default:
return null;
}
}
}
export interface GeneralAIOptions {
general: TurnGeneral;
city?: City;
nation?: Nation | null;
world: TurnWorldState;
worldRef: AiWorldView | null;
reservedTurnProvider: AiReservedTurnProvider;
scenarioConfig: ScenarioConfig;
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
unitSet?: UnitSetDefinition;
commandEnv: TurnCommandEnv;
generalDefinitions: Map<string, GeneralActionDefinition>;
nationDefinitions: Map<string, GeneralActionDefinition>;
generalFallback: GeneralActionDefinition;
nationFallback: GeneralActionDefinition;
}
export class GeneralAI {
public readonly general: TurnGeneral;
public readonly city?: City;
public readonly nation?: Nation | null;
public readonly world: TurnWorldState;
public readonly worldRef: AiWorldView | null;
public readonly map?: MapDefinition;
public readonly unitSet?: UnitSetDefinition;
public readonly commandEnv: TurnCommandEnv;
public readonly scenarioConfig: ScenarioConfig;
public readonly scenarioMeta?: ScenarioMeta;
public readonly generalDefinitions: Map<string, GeneralActionDefinition>;
public readonly nationDefinitions: Map<string, GeneralActionDefinition>;
public readonly generalFallback: GeneralActionDefinition;
public readonly nationFallback: GeneralActionDefinition;
public readonly rng: RandUtil;
public readonly env: ConstraintEnv;
public readonly startYear: number;
public readonly turnTermMinutes: number;
public readonly aiConst: {
baseGold: number;
baseRice: number;
minAvailableRecruitPop: number;
maxResourceActionAmount: number;
minNationalGold: number;
minNationalRice: number;
defaultStatMax: number;
defaultStatNpcMax: number;
chiefStatMin: number;
npcMessageFreqByDay: number;
availableNationTypes: string[];
};
public generalPolicy: AutorunGeneralPolicy;
public nationPolicy: AutorunNationPolicy;
public genType = 0;
public dipState = d평화;
public warTargetNation: Record<number, number> = {};
public attackable = false;
public maxResourceActionAmount = 0;
public nationCities: Record<
number,
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
> = {};
public frontCities: Record<
number,
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
> = {};
public supplyCities: Record<
number,
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
> = {};
public backupCities: Record<
number,
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
> = {};
public warRoute: Record<number, Record<number, number>> | null = null;
public nationGenerals: TurnGeneral[] = [];
public npcCivilGenerals: Record<number, TurnGeneral> = {};
public npcWarGenerals: Record<number, TurnGeneral> = {};
public userGenerals: Record<number, TurnGeneral> = {};
public userWarGenerals: Record<number, TurnGeneral> = {};
public userCivilGenerals: Record<number, TurnGeneral> = {};
public chiefGenerals: Record<number, TurnGeneral> = {};
public lostGenerals: Record<number, TurnGeneral> = {};
public troopLeaders: Record<number, TurnGeneral> = {};
private reqUpdateInstance = true;
private devRate: Record<string, number> | null = null;
private categorizedCities = false;
private categorizedGenerals = false;
private readonly reservedTurnProvider: AiReservedTurnProvider;
constructor(options: GeneralAIOptions) {
this.general = options.general;
this.city = options.city;
this.nation = options.nation ?? null;
this.world = options.world;
this.worldRef = options.worldRef;
this.map = options.map;
this.unitSet = options.unitSet;
this.commandEnv = options.commandEnv;
this.scenarioConfig = options.scenarioConfig;
this.scenarioMeta = options.scenarioMeta;
this.reservedTurnProvider = options.reservedTurnProvider;
this.generalDefinitions = options.generalDefinitions;
this.nationDefinitions = options.nationDefinitions;
this.generalFallback = options.generalFallback;
this.nationFallback = options.nationFallback;
this.startYear = resolveStartYear(this.world, this.scenarioMeta);
this.turnTermMinutes = resolveTurnTermMinutes(this.world);
this.env = resolveConstraintEnv(this.world, this.scenarioMeta, this.commandEnv);
const seed = simpleSerialize(
buildSeedBase(this.world),
'GeneralAI',
this.world.currentYear,
this.world.currentMonth,
this.general.id
);
this.rng = new RandUtil(LiteHashDRBG.build(seed));
const constValues = asRecord(this.scenarioConfig.const);
this.aiConst = {
baseGold: this.commandEnv.baseGold,
baseRice: this.commandEnv.baseRice,
minAvailableRecruitPop: readNumber(constValues.minAvailableRecruitPop, 30000),
maxResourceActionAmount: this.commandEnv.maxResourceActionAmount || 10000,
minNationalGold: readNumber(constValues.minNationalGold, this.commandEnv.baseGold),
minNationalRice: readNumber(constValues.minNationalRice, this.commandEnv.baseRice),
defaultStatMax: this.scenarioConfig.stat.max,
defaultStatNpcMax: this.scenarioConfig.stat.npcMax,
chiefStatMin: this.scenarioConfig.stat.chiefMin,
npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0),
availableNationTypes: Array.isArray(constValues.availableNationType)
? constValues.availableNationType.filter((value) => typeof value === 'string')
: [],
};
const generalPolicy = new AutorunGeneralPolicy(
this.general,
asRecord((this.world.meta as Record<string, unknown>)?.autorun_user)?.options as Record<string, boolean>,
asRecord(this.nation?.meta)?.npc_general_policy as Record<string, unknown> | null,
asRecord(this.world.meta)?.npc_general_policy as Record<string, unknown> | null
);
const nationPolicy = new AutorunNationPolicy({
general: this.general,
aiOptions: asRecord((this.world.meta as Record<string, unknown>)?.autorun_user)?.options as Record<
string,
boolean
> | null,
nationPolicy: asRecord(this.nation?.meta)?.npc_nation_policy as Record<string, unknown> | null,
serverPolicy: asRecord(this.world.meta)?.npc_nation_policy as Record<string, unknown> | null,
nation: this.nation ?? {
id: 0,
name: '재야',
color: '#000000',
capitalCityId: null,
chiefGeneralId: null,
gold: 0,
rice: 0,
power: 0,
level: 0,
typeCode: 'neutral',
meta: {},
},
env: this.commandEnv,
scenarioConfig: this.scenarioConfig,
unitSet: this.unitSet,
});
this.generalPolicy = generalPolicy;
this.nationPolicy = nationPolicy;
}
chooseNationTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
this.updateInstance();
if (!this.nation || !this.worldRef) {
return null;
}
this.categorizeNationCities();
this.categorizeNationGeneral();
if (reservedTurn.action !== ACTION_REST) {
const reservedCandidate = this.buildNationCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
if (reservedCandidate) {
return reservedCandidate;
}
}
for (const actionName of this.nationPolicy.priority) {
if (!this.nationPolicy.can(actionName)) {
continue;
}
if (this.general.npcState < 2 && !AVAILABLE_INSTANT_TURN[actionName]) {
continue;
}
const handler = nationActionHandlers[actionName];
if (!handler) {
continue;
}
const result = handler(this);
if (result) {
return result;
}
}
return this.buildNationCandidate(ACTION_REST, {}, 'neutral');
}
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
this.updateInstance();
if (!this.worldRef) {
return null;
}
if (this.general.npcState === 5) {
const result = generalActionHandlers['집합']?.(this);
return result ?? this.buildGeneralCandidate(ACTION_REST, {}, 'npc_troop');
}
if (reservedTurn.action !== ACTION_REST) {
const reservedCandidate = this.buildGeneralCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
if (reservedCandidate) {
return reservedCandidate;
}
}
if (
readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold
) {
const heal = this.buildGeneralCandidate('che_요양', {}, 'heal');
if (heal) {
return heal;
}
}
if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) {
const rebellion = generalActionHandlers['거병']?.(this);
if (rebellion) {
return rebellion;
}
}
if (this.general.nationId === 0 && this.generalPolicy.can('국가선택')) {
const pickNation = generalActionHandlers['국가선택']?.(this);
if (pickNation) {
return pickNation;
}
const neutral = generalActionHandlers['중립']?.(this);
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
}
if (this.general.npcState < 2 && this.general.nationId === 0 && !this.generalPolicy.can('국가선택')) {
return this.buildGeneralCandidate(ACTION_REST, {}, 'neutral_user');
}
if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) {
const relYearMonth =
joinYearMonth(this.world.currentYear, this.world.currentMonth) -
joinYearMonth(this.scenarioMeta?.startYear ?? this.startYear, 1);
if (relYearMonth > 1) {
const establish = generalActionHandlers['건국']?.(this);
if (establish) {
return establish;
}
}
const move = generalActionHandlers['방랑군이동']?.(this);
if (move) {
return move;
}
}
for (const actionName of this.generalPolicy.priority) {
if (!this.generalPolicy.can(actionName)) {
continue;
}
const handler = generalActionHandlers[actionName];
if (!handler) {
continue;
}
const result = handler(this);
if (result) {
return result;
}
}
const neutral = generalActionHandlers['중립']?.(this);
return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral');
}
buildGeneralCandidate(action: string, args: Record<string, unknown>, reason: string): AiCommandCandidate | null {
return this.buildCandidate(this.generalDefinitions, this.generalFallback, action, args, reason);
}
buildNationCandidate(action: string, args: Record<string, unknown>, reason: string): AiCommandCandidate | null {
return this.buildCandidate(this.nationDefinitions, this.nationFallback, action, args, reason);
}
getReservedTurn(generalId: number): ReservedTurnEntry {
return this.reservedTurnProvider.getGeneralTurn(generalId, 0);
}
calcNationDevelopedRate(): Record<string, number> {
if (this.devRate) {
return this.devRate;
}
this.categorizeNationCities();
const devRate: Record<string, number> = { all: 0 };
const cities = Object.values(this.supplyCities);
if (cities.length === 0) {
this.devRate = devRate;
return devRate;
}
for (const city of cities) {
const entries = this.calcCityDevelRate(city);
for (const [key, [score]] of Object.entries(entries)) {
if (key === 'trust') {
continue;
}
devRate[key] = (devRate[key] ?? 0) + score;
devRate.all += score;
}
}
for (const key of Object.keys(devRate)) {
devRate[key] /= cities.length;
}
devRate.all /= Math.max(1, Object.keys(devRate).length - 1);
this.devRate = devRate;
return devRate;
}
calcCityDevelRate(city: City): Record<string, [number, number]> {
const trust = readMetaNumber(asRecord(city.meta), 'trust', 0) / 100;
return {
trust: [trust, t통솔장],
pop: [city.populationMax > 0 ? city.population / city.populationMax : 0, t통솔장],
agri: [city.agricultureMax > 0 ? city.agriculture / city.agricultureMax : 0, t지장],
comm: [city.commerceMax > 0 ? city.commerce / city.commerceMax : 0, t지장],
secu: [city.securityMax > 0 ? city.security / city.securityMax : 0, t무장],
def: [city.defenceMax > 0 ? city.defence / city.defenceMax : 0, t무장],
wall: [city.wallMax > 0 ? city.wall / city.wallMax : 0, t무장],
};
}
calcWarRoute(): void {
if (this.warRoute || !this.map || !this.worldRef) {
return;
}
const target = Object.keys(this.warTargetNation).map((key) => Number(key));
if (this.nation) {
target.push(this.nation.id);
}
this.warRoute = searchAllDistanceByNationList(this.map, this.worldRef.listCities(), target, false);
}
categorizeNationCities(): void {
if (this.categorizedCities) {
return;
}
this.categorizedCities = true;
if (!this.nation || !this.worldRef) {
return;
}
const nationId = this.nation.id;
const nationCities: Record<
number,
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
> = {};
const frontCities: Record<
number,
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
> = {};
const supplyCities: Record<
number,
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
> = {};
const backupCities: Record<
number,
City & { dev: number; important: number; generals?: Record<number, TurnGeneral> }
> = {};
for (const city of this.worldRef.listCities()) {
if (city.nationId !== nationId) {
continue;
}
const max = city.agricultureMax + city.commerceMax + city.securityMax + city.defenceMax + city.wallMax;
const dev =
max > 0 ? (city.agriculture + city.commerce + city.security + city.defence + city.wall) / max : 0;
const entry = { ...city, dev, important: 1, generals: {} as Record<number, TurnGeneral> };
nationCities[city.id] = entry;
if (city.supplyState > 0) {
supplyCities[city.id] = entry;
if (city.frontState <= 0) {
backupCities[city.id] = entry;
}
}
if (city.frontState > 0) {
frontCities[city.id] = entry;
}
}
this.nationCities = nationCities;
this.frontCities = frontCities;
this.supplyCities = supplyCities;
this.backupCities = backupCities;
}
categorizeNationGeneral(): void {
if (this.categorizedGenerals) {
return;
}
this.categorizedGenerals = true;
if (!this.nation || !this.worldRef) {
return;
}
this.categorizeNationCities();
const nationId = this.nation.id;
const nationGenerals = this.worldRef
.listGenerals()
.filter((general) => general.nationId === nationId && general.id !== this.general.id);
const userGenerals: Record<number, TurnGeneral> = {};
const userCivilGenerals: Record<number, TurnGeneral> = {};
const userWarGenerals: Record<number, TurnGeneral> = {};
const lostGenerals: Record<number, TurnGeneral> = {};
const npcCivilGenerals: Record<number, TurnGeneral> = {};
const npcWarGenerals: Record<number, TurnGeneral> = {};
const troopLeaders: Record<number, TurnGeneral> = {};
const chiefGenerals: Record<number, TurnGeneral> = {};
let lastWar = Number.MAX_SAFE_INTEGER;
for (const candidate of nationGenerals) {
const belong = readMetaNumber(asRecord(candidate.meta), 'belong', 0);
const recentWarTurn = this.calcRecentWarTurn(candidate);
if (belong > 0 && recentWarTurn >= (belong - 1) * 12) {
continue;
}
lastWar = Math.min(lastWar, recentWarTurn);
}
for (const candidate of nationGenerals) {
const officerLevel = candidate.officerLevel;
const npcType = candidate.npcState;
const officerCity = readMetaNumber(
asRecord(candidate.meta),
'officer_city',
readMetaNumber(asRecord(candidate.meta), 'officerCity', 0)
);
if (officerLevel > 4) {
chiefGenerals[officerLevel] = candidate;
} else if (officerLevel >= 2 && officerCity > 0 && this.nationCities[officerCity]) {
this.nationCities[officerCity].important += 1;
}
const cityId = candidate.cityId;
const city = this.nationCities[cityId];
if (city) {
city.generals ??= {};
city.generals[candidate.id] = candidate;
if (city.supplyState <= 0) {
lostGenerals[candidate.id] = candidate;
}
} else {
lostGenerals[candidate.id] = candidate;
}
const isTroopLeader =
npcType === 5 ||
(candidate.troopId === candidate.id && this.getReservedTurn(candidate.id).action === 'che_집합');
if (isTroopLeader) {
troopLeaders[candidate.id] = candidate;
continue;
}
const killturn = readMetaNumber(asRecord(candidate.meta), 'killturn', 999);
if (killturn <= 5) {
npcCivilGenerals[candidate.id] = candidate;
continue;
}
if (npcType < 2) {
userGenerals[candidate.id] = candidate;
const recentWarTurn = this.calcRecentWarTurn(candidate);
if (recentWarTurn <= lastWar + 12) {
userWarGenerals[candidate.id] = candidate;
} else if (this.dipState !== d평화 && candidate.crew >= this.nationPolicy.minWarCrew) {
userWarGenerals[candidate.id] = candidate;
} else {
userCivilGenerals[candidate.id] = candidate;
}
continue;
}
if (candidate.stats.leadership >= this.nationPolicy.minNpcWarLeadership) {
npcWarGenerals[candidate.id] = candidate;
} else {
npcCivilGenerals[candidate.id] = candidate;
}
}
this.nationGenerals = nationGenerals;
this.userGenerals = userGenerals;
this.userCivilGenerals = userCivilGenerals;
this.userWarGenerals = userWarGenerals;
this.lostGenerals = lostGenerals;
this.npcCivilGenerals = npcCivilGenerals;
this.npcWarGenerals = npcWarGenerals;
this.troopLeaders = troopLeaders;
this.chiefGenerals = chiefGenerals;
}
private updateInstance(): void {
if (!this.reqUpdateInstance) {
return;
}
this.reqUpdateInstance = false;
const nation = this.nation;
if (!nation) {
return;
}
const baseDevelCost = this.commandEnv.develCost * 12;
const nationMeta = asRecord(nation.meta);
const prevIncomeGold = readMetaNumber(nationMeta, 'prev_income_gold', 1000);
const prevIncomeRice = readMetaNumber(nationMeta, 'prev_income_rice', 1000);
const elapsedYears = this.world.currentYear - this.startYear - 3;
const maxCandidate = Math.max(
this.nationPolicy.minimumResourceActionAmount,
prevIncomeGold / 10,
prevIncomeRice / 10,
nation.gold / 5,
nation.rice / 5,
elapsedYears * 1000
);
this.maxResourceActionAmount = valueFit(
roundTo(maxCandidate, -2),
null,
this.nationPolicy.maximumResourceActionAmount
);
if (this.maxResourceActionAmount > this.aiConst.maxResourceActionAmount) {
this.maxResourceActionAmount = this.aiConst.maxResourceActionAmount;
}
this.calcDiplomacyState();
this.genType = this.calcGenType();
void baseDevelCost;
}
private buildCandidate(
definitions: Map<string, GeneralActionDefinition>,
fallback: GeneralActionDefinition,
action: string,
args: Record<string, unknown>,
reason: string
): AiCommandCandidate | null {
const definition = definitions.get(action) ?? fallback;
const parsedArgs = definition.parseArgs(args);
if (parsedArgs === null) {
return null;
}
const constraintEnv = this.buildConstraintEnv();
const ctx: ConstraintContext = {
actorId: this.general.id,
cityId: this.city?.id,
nationId: this.general.nationId,
args: parsedArgs as Record<string, unknown>,
env: constraintEnv,
mode: 'full',
};
const view = new WorldStateView(this.worldRef, constraintEnv, parsedArgs as Record<string, unknown>, {
general: this.general,
city: this.city,
nation: this.nation ?? null,
});
const constraints = definition.buildConstraints(ctx, parsedArgs as never);
const result = evaluateConstraints(constraints, ctx, view);
if (result.kind !== 'allow') {
return null;
}
return {
action: definition.key,
args: parsedArgs as Record<string, unknown>,
reason,
};
}
private buildConstraintEnv(): ConstraintEnv {
return {
...this.env,
cities: this.worldRef?.listCities() ?? [],
nations: this.worldRef?.listNations() ?? [],
map: this.map,
unitSet: this.unitSet,
};
}
private calcGenType(): number {
const leadership = this.general.stats.leadership;
const strength = Math.max(this.general.stats.strength, 1);
const intel = Math.max(this.general.stats.intelligence, 1);
let genType = 0;
if (strength >= intel) {
genType = t무장;
if (intel >= strength * 0.8) {
if (this.rng.nextBool(intel / strength / 2)) {
genType |= t지장;
}
}
} else {
genType = t지장;
if (strength >= intel * 0.8) {
if (this.rng.nextBool(strength / intel / 2)) {
genType |= t무장;
}
}
}
if (leadership >= this.nationPolicy.minNpcWarLeadership) {
genType |= t통솔장;
}
return genType;
}
private calcDiplomacyState(): void {
if (!this.nation || !this.worldRef) {
return;
}
const nationId = this.nation.id;
const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth);
const startYearMonth = joinYearMonth(this.startYear + 2, 5);
const warTargets = this.worldRef
.listDiplomacy()
.filter((entry) => entry.fromNationId === nationId && (entry.state === 0 || entry.state === 1));
if (yearMonth <= startYearMonth) {
this.dipState = warTargets.length === 0 ? d평화 : d선포;
this.attackable = false;
return;
}
const frontStatus = this.worldRef
.listCities()
.some((city) => city.nationId === nationId && city.supplyState > 0 && city.frontState > 0);
this.attackable = frontStatus;
let onWar = 0;
let onWarReady = 0;
const warTargetNation: Record<number, number> = {};
for (const entry of warTargets) {
if (entry.state === 0) {
onWar += 1;
warTargetNation[entry.toNationId] = 2;
} else if (entry.state === 1 && entry.term < 5) {
onWarReady += 1;
warTargetNation[entry.toNationId] = 1;
}
}
if (onWar === 0 && onWarReady === 0) {
warTargetNation[0] = 1;
}
this.warTargetNation = warTargetNation;
const declareTerms = warTargets.filter((entry) => entry.state === 1).map((entry) => entry.term);
const minWarTerm = declareTerms.length > 0 ? Math.min(...declareTerms) : null;
if (minWarTerm === null) {
this.dipState = d평화;
} else if (minWarTerm > 8) {
this.dipState = d선포;
} else if (minWarTerm > 5) {
this.dipState = d징병;
} else {
this.dipState = d직전;
}
const meta = asRecord(this.nation.meta);
const lastAttackable = readMetaNumber(meta, 'last_attackable', 0);
if (Object.prototype.hasOwnProperty.call(warTargetNation, 0) && this.attackable) {
this.dipState = d전쟁;
} else if (onWar > 0) {
if (this.attackable) {
this.dipState = d전쟁;
} else if (lastAttackable >= yearMonth - 5) {
this.dipState = d전쟁;
}
}
}
private calcRecentWarTurn(general: TurnGeneral): number {
const recent = general.recentWarTime;
if (!recent) {
return 12000;
}
const diffMs = general.turnTime.getTime() - recent.getTime();
if (diffMs <= 0) {
return 0;
}
const turnMs = this.turnTermMinutes * 60 * 1000;
if (turnMs <= 0) {
return 12000;
}
return Math.floor(diffMs / turnMs);
}
}
export const shouldUseAi = (general: TurnGeneral, world: TurnWorldState): boolean => {
if (general.npcState >= 2) {
return true;
}
const meta = asRecord(general.meta);
const limit = readMetaNumber(meta, 'autorun_limit', 0);
if (limit <= 0) {
return false;
}
const current = joinYearMonth(world.currentYear, world.currentMonth);
return current < limit;
};
@@ -0,0 +1,943 @@
import type { City } from '@sammo-ts/logic';
import { findCrewTypeById, getTechCost, isCrewTypeAvailable } from '@sammo-ts/logic/world/unitSet.js';
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
import type { GeneralAI } from './generalAi.js';
import { asRecord, readMetaNumber, roundTo, valueFit } from './aiUtils.js';
const ACTION_REST = '휴식';
const t무장 = 1;
const t지장 = 2;
const t통솔장 = 4;
const resolveCityTrust = (city: City): number => readMetaNumber(asRecord(city.meta), 'trust', 0);
const pickWeightedCandidate = (
ai: GeneralAI,
list: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]>
) => {
const items = list.filter(([item]) => Boolean(item)) as Array<
[ReturnType<GeneralAI['buildGeneralCandidate']>, number]
>;
if (items.length === 0) {
return null;
}
const picked = ai.rng.choiceUsingWeightPair(items);
return picked ?? null;
};
export const do일반내정 = (ai: GeneralAI) => {
const city = ai.city;
const nation = ai.nation;
if (!city || !nation) {
return null;
}
if (nation.rice < ai.aiConst.baseRice && ai.rng.nextBool(0.3)) {
return null;
}
const develRate = ai.calcCityDevelRate(city);
const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
if (ai.genType & t통솔장) {
if (develRate.trust[0] < 0.98) {
cmdList.push([
ai.buildGeneralCandidate('che_주민선정', {}, '일반내정'),
(ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
]);
}
if (develRate.pop[0] < 0.8) {
cmdList.push([
ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'),
ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001),
]);
} else if (develRate.pop[0] < 0.99) {
cmdList.push([
ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'),
ai.general.stats.leadership / valueFit(develRate.pop[0] / 4, 0.001),
]);
}
}
if (ai.genType & t무장) {
if (develRate.def[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_수비강화', {}, '일반내정'),
ai.general.stats.strength / valueFit(develRate.def[0], 0.001),
]);
}
if (develRate.wall[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_성벽보수', {}, '일반내정'),
ai.general.stats.strength / valueFit(develRate.wall[0], 0.001),
]);
}
if (develRate.secu[0] < 0.9) {
cmdList.push([
ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'),
ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1),
]);
} else if (develRate.secu[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'),
ai.general.stats.strength / 2 / valueFit(develRate.secu[0], 0.001),
]);
}
}
if (ai.genType & t지장) {
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), ai.general.stats.intelligence]);
if (develRate.agri[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'),
((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / valueFit(develRate.agri[0], 0.001, 1),
]);
}
if (develRate.comm[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_상업투자', {}, '일반내정'),
((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / valueFit(develRate.comm[0], 0.001, 1),
]);
}
}
return pickWeightedCandidate(ai, cmdList);
};
export const do긴급내정 = (ai: GeneralAI) => {
const city = ai.city;
if (!city) {
return null;
}
if (ai.dipState === 0) {
return null;
}
const trust = resolveCityTrust(city);
if (trust < 70 && ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin)) {
return ai.buildGeneralCandidate('che_주민선정', {}, '긴급내정');
}
if (
city.population < ai.nationPolicy.minNpcRecruitCityPopulation &&
ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin / 2)
) {
return ai.buildGeneralCandidate('che_정착장려', {}, '긴급내정');
}
return null;
};
export const do전쟁내정 = (ai: GeneralAI) => {
const city = ai.city;
const nation = ai.nation;
if (!city || !nation) {
return null;
}
if (ai.dipState === 0) {
return null;
}
if (nation.rice < ai.aiConst.baseRice && ai.rng.nextBool(0.3)) {
return null;
}
if (ai.rng.nextBool(0.3)) {
return null;
}
const develRate = ai.calcCityDevelRate(city);
const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
if (ai.genType & t통솔장) {
if (develRate.trust[0] < 0.98) {
cmdList.push([
ai.buildGeneralCandidate('che_주민선정', {}, '전쟁내정'),
(ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
]);
}
if (develRate.pop[0] < 0.8) {
const weight =
city.frontState > 0
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]);
}
}
if (ai.genType & t무장) {
if (develRate.def[0] < 0.5) {
cmdList.push([
ai.buildGeneralCandidate('che_수비강화', {}, '전쟁내정'),
ai.general.stats.strength / valueFit(develRate.def[0], 0.001) / 2,
]);
}
if (develRate.wall[0] < 0.5) {
cmdList.push([
ai.buildGeneralCandidate('che_성벽보수', {}, '전쟁내정'),
ai.general.stats.strength / valueFit(develRate.wall[0], 0.001) / 2,
]);
}
if (develRate.secu[0] < 0.5) {
cmdList.push([
ai.buildGeneralCandidate('che_치안강화', {}, '전쟁내정'),
ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4,
]);
}
}
if (ai.genType & t지장) {
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), ai.general.stats.intelligence]);
if (develRate.agri[0] < 0.5) {
const weight =
city.frontState > 0
? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.agri[0], 0.001, 1)
: ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.agri[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]);
}
if (develRate.comm[0] < 0.5) {
const weight =
city.frontState > 0
? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.comm[0], 0.001, 1)
: ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.comm[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]);
}
}
return pickWeightedCandidate(ai, cmdList);
};
export const do금쌀구매 = (ai: GeneralAI) => {
const city = ai.city;
if (!city) {
return null;
}
const trade = readMetaNumber(asRecord(city.meta), 'trade', 0);
if (trade === 0 && !ai.generalPolicy.can('상인무시')) {
return null;
}
const kill = readMetaNumber(asRecord(ai.general.meta), 'killcrew', 50000) + 50000;
const death = readMetaNumber(asRecord(ai.general.meta), 'deathcrew', 50000) + 50000;
const deathRate = death / kill;
const absGold = ai.general.gold;
const absRice = ai.general.rice;
const relGold = absGold;
const relRice = absRice * deathRate;
const baseDevelCost = ai.commandEnv.develCost * 12;
if (absGold + absRice < baseDevelCost * 2) {
return null;
}
const crewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0);
const fullLeadership = ai.general.stats.leadership;
const crewAmount = fullLeadership * 100;
const goldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0;
const riceCost = crewAmount / 100;
if ((relGold + relRice) * 1.5 <= goldCost + riceCost) {
return null;
}
if (ai.general.npcState < 2 && relGold >= goldCost * 3 && relRice >= riceCost * 3) {
return null;
}
let tryBuying = false;
if (ai.generalPolicy.can('상인무시')) {
if (relRice * 1.5 < relGold && relRice < riceCost * 2) {
tryBuying = true;
} else if (relRice * 2 < relGold) {
tryBuying = true;
}
} else if (relRice * 2 < relGold && relRice < riceCost * 3) {
tryBuying = true;
}
if (tryBuying) {
const amount = valueFit(Math.floor((relGold - relRice) / (1 + deathRate)), 100, ai.maxResourceActionAmount);
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
return ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매');
}
}
let trySelling = false;
if (ai.generalPolicy.can('상인무시')) {
if (relGold * 1.5 < relRice && relGold < goldCost * 2) {
trySelling = true;
} else if (relGold * 2 < relRice) {
trySelling = true;
}
} else if (relGold * 2 < relRice && relGold < goldCost * 3) {
trySelling = true;
}
if (trySelling) {
const amount = valueFit(Math.floor((relRice - relGold) / (1 + deathRate)), 100, ai.maxResourceActionAmount);
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매');
}
}
return null;
};
export const do징병 = (ai: GeneralAI) => {
const city = ai.city;
const nation = ai.nation;
if (!city || !nation || !ai.unitSet || !ai.map) {
return null;
}
if ([0, 1].includes(ai.dipState)) {
return null;
}
if (!(ai.genType & t통솔장)) {
return null;
}
if (ai.general.crew >= ai.nationPolicy.minWarCrew) {
return null;
}
if (!ai.generalPolicy.can('한계징병')) {
const remainPop =
city.population - ai.nationPolicy.minNpcRecruitCityPopulation - ai.general.stats.leadership * 100;
if (remainPop <= 0) {
return null;
}
const maxPop = city.populationMax - ai.nationPolicy.minNpcRecruitCityPopulation;
if (
city.population / city.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio &&
ai.rng.nextBool(remainPop / Math.max(1, maxPop))
) {
return null;
}
}
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const crewAmountBase = ai.general.stats.leadership * 100;
const armType =
readMetaNumber(asRecord(ai.general.meta), 'armType', 0) ||
(ai.general.stats.strength >= ai.general.stats.intelligence * 0.9 ? 1 : 4);
const candidates = (ai.unitSet?.crewTypes ?? [])
.filter((crew) => crew.armType === armType)
.filter((crew) =>
isCrewTypeAvailable(ai.unitSet!, crew.id, {
general: ai.general,
nation,
map: ai.map!,
cities: ai.worldRef?.listCities() ?? [],
currentYear: ai.world.currentYear,
startYear: ai.startYear,
})
);
if (candidates.length === 0) {
return null;
}
const picked = ai.rng.choiceUsingWeightPair(candidates.map((crew) => [crew, Math.max(1, crew.cost)]));
const crewTypeId = picked.id;
let crewAmount = crewAmountBase;
const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
const riceCost = crewAmount / 100;
if (ai.generalPolicy.can('모병') && ai.general.gold >= goldCost * 6) {
const hire = ai.buildGeneralCandidate('che_모병', { crewType: crewTypeId, amount: crewAmount }, '징병');
if (hire) {
return hire;
}
}
if (ai.general.gold < goldCost && ai.general.gold * 2 >= goldCost) {
crewAmount *= 0.5;
crewAmount = roundTo(crewAmount - 49, -2);
}
if (!ai.generalPolicy.can('한계징병') && ai.general.rice * 1.1 <= riceCost) {
return null;
}
return ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병');
};
export const do전투준비 = (ai: GeneralAI) => {
if ([0, 1].includes(ai.dipState)) {
return null;
}
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
if (ai.general.train < ai.nationPolicy.properWarTrainAtmos) {
cmdList.push([
ai.buildGeneralCandidate('che_훈련', {}, '전투준비'),
ai.commandEnv.maxTrainByCommand / valueFit(ai.general.train, 1),
]);
}
if (ai.general.atmos < ai.nationPolicy.properWarTrainAtmos) {
cmdList.push([
ai.buildGeneralCandidate('che_사기진작', {}, '전투준비'),
ai.commandEnv.maxAtmosByCommand / valueFit(ai.general.atmos, 1),
]);
}
return pickWeightedCandidate(ai, cmdList);
};
export const do소집해제 = (ai: GeneralAI) => {
if (ai.attackable) {
return null;
}
if (ai.dipState !== 0) {
return null;
}
if (ai.general.crew === 0) {
return null;
}
if (ai.rng.nextBool(0.75)) {
return null;
}
return ai.buildGeneralCandidate('che_소집해제', {}, '소집해제');
};
export const do출병 = (ai: GeneralAI) => {
const city = ai.city;
const nation = ai.nation;
if (!city || !nation || !ai.map || !ai.worldRef) {
return null;
}
if (!ai.attackable || ai.dipState !== 4) {
return null;
}
if (nation.rice < ai.aiConst.baseRice && ai.general.npcState >= 2 && ai.rng.nextBool(0.7)) {
return null;
}
if (ai.general.train < Math.min(100, ai.nationPolicy.properWarTrainAtmos)) {
return null;
}
if (ai.general.atmos < Math.min(100, ai.nationPolicy.properWarTrainAtmos)) {
return null;
}
if (ai.general.crew < Math.min((ai.general.stats.leadership - 2) * 100, ai.nationPolicy.minWarCrew)) {
return null;
}
if (city.frontState <= 1) {
return null;
}
const attackableNations = Object.entries(ai.warTargetNation)
.filter(([, state]) => state !== 1)
.map(([id]) => Number(id));
if (attackableNations.length === 0) {
attackableNations.push(0);
}
const neighbors = ai.map.cities.find((c) => c.id === city.id)?.connections ?? [];
const attackableCities = neighbors.filter((cityId) => {
const destCity = ai.worldRef?.getCityById(cityId);
return destCity ? attackableNations.includes(destCity.nationId) : false;
});
if (attackableCities.length === 0) {
return null;
}
return ai.buildGeneralCandidate('che_출병', { destCityId: ai.rng.choice(attackableCities) }, '출병');
};
export const doNPC헌납 = (ai: GeneralAI) => {
const nation = ai.nation;
if (!nation) {
return null;
}
const resourceMap: Array<['rice' | 'gold', number, number, number]> = [
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
];
const args: Array<[Record<string, unknown>, number]> = [];
for (const [resKey, reqNation, reqNpcWar, reqNpcDevel] of resourceMap) {
const genRes = ai.general[resKey];
let reqRes = reqNpcDevel;
if (ai.genType & t통솔장) {
reqRes = reqNpcWar;
} else {
if (genRes >= reqNpcWar && reqNpcWar > reqNpcDevel + 1000) {
const amount = genRes - reqNpcDevel;
args.push([{ isGold: resKey === 'gold', amount }, amount]);
continue;
}
if (genRes >= reqNpcDevel * 5 && genRes >= 5000) {
const amount = genRes - reqNpcDevel;
args.push([{ isGold: resKey === 'gold', amount }, amount]);
continue;
}
}
if (nation[resKey] >= reqNation) {
continue;
}
if (
resKey === 'rice' &&
nation[resKey] <= ai.aiConst.minNationalRice / 2 &&
genRes >= ai.aiConst.minNationalRice / 2
) {
const amount = genRes < ai.aiConst.minNationalRice ? genRes : genRes / 2;
args.push([{ isGold: false, amount }, amount]);
}
if (genRes < reqRes * 1.5) {
continue;
}
if (reqRes > 0 && !ai.rng.nextBool(genRes / reqRes - 0.5)) {
continue;
}
const amount = genRes - reqRes;
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue;
}
args.push([{ isGold: resKey === 'gold', amount }, amount]);
}
if (args.length === 0) {
return null;
}
return ai.buildGeneralCandidate('che_헌납', ai.rng.choiceUsingWeightPair(args), 'NPC헌납');
};
export const do후방워프 = (ai: GeneralAI) => {
const city = ai.city;
if (!city || !ai.nation || !ai.map) {
return null;
}
if ([0, 1].includes(ai.dipState)) {
return null;
}
if (!(ai.genType & t통솔장)) {
return null;
}
if (ai.general.crew >= ai.nationPolicy.minWarCrew) {
return null;
}
let minRecruitPop = ai.general.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
if (!ai.generalPolicy.can('한계징병')) {
minRecruitPop = Math.max(
minRecruitPop,
ai.general.stats.leadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
);
}
if (ai.generalPolicy.can('한계징병')) {
if (city.population >= minRecruitPop) {
return null;
}
} else if (
city.population / city.populationMax >= ai.nationPolicy.safeRecruitCityPopulationRatio &&
city.population >= ai.nationPolicy.minNpcRecruitCityPopulation &&
city.population >= minRecruitPop
) {
return null;
}
ai.categorizeNationCities();
const recruitable: Record<number, number> = {};
for (const candidate of Object.values(ai.backupCities)) {
if (candidate.id === city.id) {
continue;
}
if (candidate.population / candidate.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio) {
continue;
}
if (candidate.population < ai.nationPolicy.minNpcRecruitCityPopulation) {
continue;
}
if (candidate.population < minRecruitPop) {
continue;
}
recruitable[candidate.id] = candidate.population / candidate.populationMax;
}
if (Object.keys(recruitable).length === 0) {
for (const candidate of Object.values(ai.supplyCities)) {
if (candidate.id === city.id) {
continue;
}
if (candidate.population < ai.nationPolicy.minNpcRecruitCityPopulation) {
continue;
}
if (candidate.population <= minRecruitPop) {
continue;
}
if (candidate.population / candidate.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio) {
continue;
}
recruitable[candidate.id] =
candidate.frontState > 0
? candidate.population / candidate.populationMax / 2
: candidate.population / candidate.populationMax;
}
}
if (Object.keys(recruitable).length === 0) {
return null;
}
return ai.buildGeneralCandidate(
'che_NPC능동',
{ optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(recruitable) },
'후방워프'
);
};
export const do전방워프 = (ai: GeneralAI) => {
const city = ai.city;
if (!city || !ai.nation || !ai.map) {
return null;
}
if (!ai.attackable || [0, 1].includes(ai.dipState)) {
return null;
}
if (!(ai.genType & t통솔장)) {
return null;
}
if (ai.general.crew < ai.nationPolicy.minWarCrew) {
return null;
}
if (city.frontState > 0) {
return null;
}
ai.categorizeNationCities();
const candidateCities: Record<number, number> = {};
for (const frontCity of Object.values(ai.frontCities)) {
if (frontCity.supplyState <= 0) {
continue;
}
candidateCities[frontCity.id] = frontCity.important;
}
if (Object.keys(candidateCities).length === 0) {
return null;
}
return ai.buildGeneralCandidate(
'che_NPC능동',
{ optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(candidateCities) },
'전방워프'
);
};
export const do내정워프 = (ai: GeneralAI) => {
const city = ai.city;
if (!city || !ai.nation || !ai.map) {
return null;
}
if (ai.genType & t통솔장 && [2, 3, 4].includes(ai.dipState)) {
return null;
}
if (ai.rng.nextBool(0.6)) {
return null;
}
const develRate = ai.calcCityDevelRate(city);
let warpProp = 1;
let availableTypeCnt = 0;
for (const [key, [value, type]] of Object.entries(develRate)) {
if (!(ai.genType & type)) {
continue;
}
warpProp *= value;
availableTypeCnt += 1;
void key;
}
if (availableTypeCnt === 0) {
return null;
}
if (!ai.rng.nextBool(warpProp)) {
return null;
}
ai.categorizeNationCities();
ai.categorizeNationGeneral();
const candidateCities: Record<number, number> = {};
for (const candidate of Object.values(ai.supplyCities)) {
if (candidate.id === city.id) {
continue;
}
let realDevelRate = 0.0001;
for (const [_, [value, type]] of Object.entries(ai.calcCityDevelRate(candidate))) {
if (!(ai.genType & type)) {
continue;
}
realDevelRate += value;
}
realDevelRate /= availableTypeCnt;
if (realDevelRate >= 0.95) {
continue;
}
candidateCities[candidate.id] =
1 / (realDevelRate * Math.sqrt((candidate.generals ? Object.keys(candidate.generals).length : 0) + 1));
}
if (Object.keys(candidateCities).length === 0) {
return null;
}
return ai.buildGeneralCandidate(
'che_NPC능동',
{ optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(candidateCities) },
'내정워프'
);
};
export const do귀환 = (ai: GeneralAI) => {
const city = ai.city;
if (!city) {
return null;
}
if (city.nationId === ai.general.nationId && city.supplyState > 0) {
return null;
}
return ai.buildGeneralCandidate('che_귀환', {}, '귀환');
};
export const do집합 = (ai: GeneralAI) => ai.buildGeneralCandidate('che_집합', {}, '집합');
export const do국가선택 = (ai: GeneralAI) => {
if (!ai.worldRef) {
return null;
}
if (ai.general.npcState === 9) {
const ruler = ai.worldRef
.listGenerals()
.find((general) => general.officerLevel === 12 && general.npcState === 9);
if (ruler) {
return ai.buildGeneralCandidate('che_임관', { destNationId: ruler.nationId }, '국가선택');
}
}
if (ai.rng.nextBool(0.3)) {
const nations = ai.worldRef.listNations().filter((nation) => nation.id > 0);
if (nations.length === 0) {
return null;
}
const destNation = ai.rng.choice(nations);
return ai.buildGeneralCandidate('che_임관', { destNationId: destNation.id }, '국가선택');
}
if (ai.rng.nextBool(0.2) && ai.map) {
const neighbors = ai.map.cities.find((c) => c.id === ai.general.cityId)?.connections ?? [];
if (neighbors.length === 0) {
return null;
}
return ai.buildGeneralCandidate('che_이동', { destCityId: ai.rng.choice(neighbors) }, '국가선택');
}
return null;
};
export const doNPC사망대비 = (ai: GeneralAI) => {
const killturn = readMetaNumber(asRecord(ai.general.meta), 'killturn', 999);
if (killturn > 5) {
return null;
}
if (ai.general.nationId === 0) {
const search = ai.buildGeneralCandidate('che_인재탐색', {}, 'NPC사망대비');
if (search && !ai.rng.nextBool()) {
return search;
}
return ai.buildGeneralCandidate('che_견문', {}, 'NPC사망대비');
}
if (ai.general.gold + ai.general.rice === 0) {
return ai.buildGeneralCandidate('che_물자조달', {}, 'NPC사망대비');
}
if (ai.general.gold >= ai.general.rice) {
return ai.buildGeneralCandidate(
'che_헌납',
{ isGold: true, amount: ai.aiConst.maxResourceActionAmount },
'NPC사망대비'
);
}
return ai.buildGeneralCandidate(
'che_헌납',
{ isGold: false, amount: ai.aiConst.maxResourceActionAmount },
'NPC사망대비'
);
};
export const do중립 = (ai: GeneralAI) => {
const nation = ai.nation;
if (!nation || ai.general.nationId === 0) {
const search = ai.buildGeneralCandidate('che_인재탐색', {}, '중립');
if (search && !ai.rng.nextBool(0.8)) {
return search;
}
return ai.buildGeneralCandidate('che_견문', {}, '중립');
}
let candidates = ['che_물자조달', 'che_인재탐색'];
if (nation.gold < ai.nationPolicy.reqNationGold || nation.rice < ai.nationPolicy.reqNationRice) {
candidates = ['che_물자조달'];
}
for (const key of candidates) {
const cmd = ai.buildGeneralCandidate(key, {}, '중립');
if (cmd) {
return cmd;
}
}
return ai.buildGeneralCandidate(ACTION_REST, {}, '중립');
};
export const do거병 = (ai: GeneralAI) => {
if (readMetaNumber(asRecord(ai.general.meta), 'makelimit', 0)) {
return null;
}
if (ai.general.npcState > 2) {
return null;
}
if (!ai.generalPolicy.can('건국')) {
return null;
}
const city = ai.city;
if (!city || !ai.map || !ai.worldRef) {
return null;
}
if ((city.level < 5 || 6 < city.level) && ai.rng.nextBool(0.5)) {
return null;
}
const occupied = new Set(
ai.worldRef
.listCities()
.filter((c) => c.nationId !== 0)
.map((c) => c.id)
);
for (const general of ai.worldRef.listGenerals()) {
if (general.officerLevel === 12 && general.nationId === 0) {
occupied.add(general.cityId);
}
}
let availableNearCity = false;
const nearby = searchDistance(ai.map, ai.general.cityId, 3);
for (const [targetCityId, dist] of Object.entries(nearby)) {
const cityId = Number(targetCityId);
if (!Number.isFinite(cityId)) {
continue;
}
if (occupied.has(cityId)) {
continue;
}
const target = ai.worldRef.getCityById(cityId);
if (!target || target.level < 5 || target.level > 6) {
continue;
}
if (dist === 3 && ai.rng.nextBool()) {
continue;
}
availableNearCity = true;
break;
}
if (!availableNearCity) {
return null;
}
const prop = (ai.rng.nextFloat1() * (ai.aiConst.defaultStatNpcMax + ai.aiConst.chiefStatMin)) / 2;
const ratio = (ai.general.stats.leadership + ai.general.stats.strength + ai.general.stats.intelligence) / 3;
if (prop >= ratio) {
return null;
}
const relYear = Math.max(0, ai.world.currentYear - ai.startYear);
const more = valueFit(3 - relYear, 1, 3);
if (!ai.rng.nextBool(0.0075 * more)) {
return null;
}
return ai.buildGeneralCandidate('che_거병', {}, '거병');
};
export const do건국 = (ai: GeneralAI) => {
const mapName = ai.scenarioConfig.environment.mapName ?? 'sammo';
const prefix = mapName.endsWith('_') ? mapName : `${mapName}_`;
const nationType =
ai.aiConst.availableNationTypes.length > 0
? (ai.rng.choice(ai.aiConst.availableNationTypes) as string)
: `${prefix}def`;
const colorType = ai.rng.nextRangeInt(0, 34);
const nationName = ai.general.name;
return ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국');
};
export const do방랑군이동 = (ai: GeneralAI) => {
const city = ai.city;
if (!city || !ai.map || !ai.worldRef) {
return null;
}
const occupied = new Set(
ai.worldRef
.listCities()
.filter((c) => c.nationId !== 0)
.map((c) => c.id)
);
for (const general of ai.worldRef.listGenerals()) {
if (general.officerLevel === 12 && general.nationId === 0) {
occupied.add(general.cityId);
}
}
const nearby = searchDistance(ai.map, city.id, 4);
const candidates: Array<[number, number]> = [];
for (const [cityIdRaw, dist] of Object.entries(nearby)) {
const cityId = Number(cityIdRaw);
if (!Number.isFinite(cityId) || occupied.has(cityId)) {
continue;
}
const target = ai.worldRef.getCityById(cityId);
if (!target || target.level < 5 || target.level > 6) {
continue;
}
candidates.push([cityId, 1 / Math.pow(2, dist)]);
}
if (candidates.length === 0) {
return null;
}
const destCityId = ai.rng.choiceUsingWeightPair(candidates);
if (destCityId === city.id) {
return ai.buildGeneralCandidate('che_인재탐색', {}, '방랑군이동');
}
return ai.buildGeneralCandidate('che_이동', { destCityId }, '방랑군이동');
};
export const generalActionHandlers: Record<
string,
(ai: GeneralAI) => ReturnType<GeneralAI['buildGeneralCandidate']> | null
> = {
NPC사망대비: doNPC사망대비,
귀환: do귀환,
금쌀구매: do금쌀구매,
출병: do출병,
긴급내정: do긴급내정,
전투준비: do전투준비,
전방워프: do전방워프,
NPC헌납: doNPC헌납,
징병: do징병,
후방워프: do후방워프,
전쟁내정: do전쟁내정,
소집해제: do소집해제,
일반내정: do일반내정,
내정워프: do내정워프,
국가선택: do국가선택,
중립: do중립,
집합: do집합,
거병: do거병,
건국: do건국,
방랑군이동: do방랑군이동,
};
@@ -0,0 +1,1055 @@
import type { City } from '@sammo-ts/logic';
import type { GeneralAI } from './generalAi.js';
import { asRecord, calcCityDevRatio, joinYearMonth, parseYearMonth, readMetaNumber } from './aiUtils.js';
import { isNeighbor, searchAllDistanceByCityList } from './distance.js';
const pickWeightedCandidate = (ai: GeneralAI, list: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]>) => {
const items = list.filter(([item]) => Boolean(item)) as Array<
[ReturnType<GeneralAI['buildNationCandidate']>, number]
>;
if (items.length === 0) {
return null;
}
const picked = ai.rng.choiceUsingWeightPair(items);
return picked ?? null;
};
const pickRandomCityId = (ai: GeneralAI, cities: Record<number, City>): number | null => {
const ids = Object.keys(cities).map((key) => Number(key));
if (ids.length === 0) {
return null;
}
return ai.rng.choice(ids);
};
const resolveCityPopRatio = (city: City): number => {
if (city.populationMax <= 0) {
return 0;
}
return city.population / city.populationMax;
};
const resolveLastAssignment = (general: GeneralAI['general'], yearMonth: number): boolean => {
const last = readMetaNumber(asRecord(general.meta), 'last발령', 0);
return last >= yearMonth;
};
const selectRecruitableCity = (ai: GeneralAI, minPop: number): Record<number, number> => {
const candidates: Record<number, number> = {};
for (const city of Object.values(ai.backupCities)) {
if (city.population < minPop) {
continue;
}
const ratio = resolveCityPopRatio(city);
candidates[city.id] = ratio;
}
if (Object.keys(candidates).length > 0) {
return candidates;
}
for (const city of Object.values(ai.supplyCities)) {
if (city.population < minPop) {
continue;
}
const ratio = resolveCityPopRatio(city);
candidates[city.id] = ratio;
}
return candidates;
};
const buildAssignmentCandidate = (ai: GeneralAI, destGeneralId: number, destCityId: number, reason: string) =>
ai.buildNationCandidate('che_발령', { destGeneralId, destCityId }, reason);
const buildSeizureCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason);
const buildAwardCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason);
const resolveAwardAmount = (ai: GeneralAI, current: number, target: number): number | null => {
const diff = target - current;
if (diff <= 0) {
return null;
}
const amount = Math.min(diff, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
return null;
}
return amount;
};
const resolveNationIncome = (ai: GeneralAI): number => {
const cities = Object.values(ai.supplyCities);
if (cities.length === 0) {
return 0;
}
return cities.reduce((sum, city) => sum + city.population / 100 + city.agriculture / 100 + city.commerce / 100, 0);
};
const pickFrontCityWeight = (ai: GeneralAI): Record<number, number> => {
const candidates: Record<number, number> = {};
for (const city of Object.values(ai.frontCities)) {
candidates[city.id] = city.important;
}
return candidates;
};
export const do부대전방발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.map) {
return null;
}
if (!ai.nation.capitalCityId) {
return null;
}
if (Object.keys(ai.frontCities).length === 0) {
return null;
}
ai.calcWarRoute();
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
const troopCandidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
for (const leader of Object.values(ai.troopLeaders)) {
if (!ai.nationPolicy.combatForce[leader.id]) {
continue;
}
if (ai.frontCities[leader.cityId]) {
continue;
}
if (resolveLastAssignment(leader, yearMonth)) {
continue;
}
const force = ai.nationPolicy.combatForce[leader.id];
let [fromCityId, toCityId] = force;
let targetCityId: number | null = null;
if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) {
targetCityId = pickRandomCityId(ai, ai.frontCities);
} else {
if (!ai.supplyCities[fromCityId]) {
toCityId = fromCityId;
fromCityId = ai.nation.capitalCityId ?? fromCityId;
}
targetCityId = fromCityId;
while (targetCityId !== null && !ai.frontCities[targetCityId]) {
const current = targetCityId;
const distance = ai.warRoute[current]?.[toCityId];
if (distance === undefined) {
targetCityId = pickRandomCityId(ai, ai.frontCities);
break;
}
const connections: number[] = ai.map.cities.find((city) => city.id === current)?.connections ?? [];
const nextCandidates: number[] = connections.filter((nextCityId: number) => {
const nextDistance = ai.warRoute?.[nextCityId]?.[toCityId];
return nextDistance !== undefined && nextDistance <= distance;
});
if (nextCandidates.length === 0) {
targetCityId = pickRandomCityId(ai, ai.frontCities);
break;
}
targetCityId = ai.rng.choice(nextCandidates);
}
}
if (targetCityId === null) {
continue;
}
troopCandidates.push([buildAssignmentCandidate(ai, leader.id, targetCityId, '부대전방발령'), 1]);
}
return pickWeightedCandidate(ai, troopCandidates);
};
export const do부대후방발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
if (Object.keys(ai.frontCities).length === 0) {
return null;
}
if (Object.keys(ai.supplyCities).length <= 1) {
return null;
}
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
const troopCandidates = Object.values(ai.troopLeaders).filter((leader) => {
if (!ai.nationPolicy.supportForce.includes(leader.id)) {
return false;
}
if (resolveLastAssignment(leader, yearMonth)) {
return false;
}
const city = ai.supplyCities[leader.cityId];
if (!city) {
return true;
}
if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
return false;
}
return true;
});
if (troopCandidates.length === 0) {
return null;
}
const cityCandidates: Record<number, number> = {};
for (const city of Object.values(ai.backupCities)) {
const ratio = resolveCityPopRatio(city);
if (ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
cityCandidates[city.id] = ratio;
}
}
if (Object.keys(cityCandidates).length === 0) {
for (const city of Object.values(ai.supplyCities)) {
const ratio = resolveCityPopRatio(city);
if (ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
cityCandidates[city.id] = ratio;
}
}
}
if (Object.keys(cityCandidates).length === 0) {
return null;
}
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
const leader = ai.rng.choice(troopCandidates);
return buildAssignmentCandidate(ai, leader.id, destCityId, '부대후방발령');
};
export const do부대구출발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
if (Object.keys(ai.frontCities).length === 0) {
return null;
}
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
const troopCandidates = Object.values(ai.troopLeaders).filter((leader) => {
if (ai.nationPolicy.supportForce.includes(leader.id)) {
return false;
}
if (ai.nationPolicy.combatForce[leader.id]) {
return false;
}
if (resolveLastAssignment(leader, yearMonth)) {
return false;
}
return !ai.supplyCities[leader.cityId];
});
if (troopCandidates.length === 0) {
return null;
}
const destCityId = pickRandomCityId(ai, ai.frontCities);
if (destCityId === null) {
return null;
}
const leader = ai.rng.choice(troopCandidates);
return buildAssignmentCandidate(ai, leader.id, destCityId, '부대구출발령');
};
export const do부대유저장후방발령 = (ai: GeneralAI) => {
if (Object.keys(ai.frontCities).length === 0) {
return null;
}
if (ai.dipState !== 4) {
return null;
}
const candidates = Object.values(ai.userWarGenerals).filter((general) => {
if (general.id === ai.general.id) {
return false;
}
if (!ai.frontCities[general.cityId]) {
return false;
}
if (!ai.nationCities[general.cityId]) {
return false;
}
const troopLeaderId = general.troopId;
if (!troopLeaderId || !ai.troopLeaders[troopLeaderId]) {
return false;
}
if (troopLeaderId === general.id) {
return false;
}
const troopLeader = ai.troopLeaders[troopLeaderId];
if (troopLeader.cityId !== general.cityId) {
return false;
}
if (!ai.supplyCities[troopLeader.cityId]) {
return false;
}
const city = ai.nationCities[general.cityId];
if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
return false;
}
if (general.crew >= ai.nationPolicy.minWarCrew) {
return false;
}
const reserved = ai.getReservedTurn(general.id);
if (reserved.action !== 'che_징병') {
return false;
}
return true;
});
if (candidates.length === 0) {
return null;
}
const destCityCandidates = selectRecruitableCity(ai, ai.nationPolicy.minNpcRecruitCityPopulation);
if (Object.keys(destCityCandidates).length === 0) {
return null;
}
const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
const destGeneral = ai.rng.choice(candidates);
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '부대유저장후방발령');
};
export const do유저장후방발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
if (ai.dipState !== 4) {
return null;
}
if (Object.keys(ai.supplyCities).length <= 1) {
return null;
}
const candidates = Object.values(ai.userWarGenerals).filter((general) => {
if (general.id === ai.general.id) {
return false;
}
if (!ai.supplyCities[general.cityId]) {
return false;
}
if (general.troopId) {
return false;
}
const city = ai.supplyCities[general.cityId];
if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
return false;
}
if (general.crew >= ai.nationPolicy.minWarCrew) {
return false;
}
return true;
});
if (candidates.length === 0) {
return null;
}
const picked = ai.rng.choice(candidates);
const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
const destCityCandidates = selectRecruitableCity(ai, minPop);
if (Object.keys(destCityCandidates).length === 0) {
return null;
}
const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
return buildAssignmentCandidate(ai, picked.id, destCityId, '유저장후방발령');
};
export const do유저장구출발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
const lostCandidates = Object.values(ai.lostGenerals).filter((general) => general.npcState < 2);
if (lostCandidates.length === 0) {
return null;
}
const destCityId = pickRandomCityId(ai, ai.frontCities) ?? pickRandomCityId(ai, ai.supplyCities);
if (destCityId === null) {
return null;
}
const destGeneral = ai.rng.choice(lostCandidates);
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장구출발령');
};
export const do유저장전방발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
if (Object.keys(ai.frontCities).length === 0) {
return null;
}
if ([0, 1].includes(ai.dipState)) {
return null;
}
const candidates = Object.values(ai.userWarGenerals).filter((general) => {
if (general.id === ai.general.id) {
return false;
}
if (ai.frontCities[general.cityId]) {
return false;
}
if (!ai.nationCities[general.cityId]) {
return false;
}
if (general.crew < ai.nationPolicy.minWarCrew) {
return false;
}
return true;
});
if (candidates.length === 0) {
return null;
}
const cityCandidates = pickFrontCityWeight(ai);
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
const destGeneral = ai.rng.choice(candidates);
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장전방발령');
};
export const do유저장내정발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
if (Object.keys(ai.supplyCities).length <= 1) {
return null;
}
const supplyCities = Object.values(ai.supplyCities);
const avgDev = supplyCities.reduce((sum, city) => sum + city.dev, 0) / supplyCities.length;
if (avgDev >= 0.99) {
return null;
}
const userGenerals = [0, 1].includes(ai.dipState)
? [...Object.values(ai.userWarGenerals), ...Object.values(ai.userCivilGenerals)]
: Object.values(ai.userCivilGenerals);
const generalCandidates = userGenerals.filter((general) => {
if (general.troopId) {
return false;
}
const city = ai.supplyCities[general.cityId];
if (!city) {
return false;
}
return city.dev >= 0.95;
});
if (generalCandidates.length === 0) {
return null;
}
const cityCandidates: Record<number, number> = {};
for (const city of supplyCities) {
const dev = Math.min(city.dev, 0.999);
const score = Math.pow(1 - dev, 2) / Math.sqrt((city.generals ? Object.keys(city.generals).length : 0) + 1);
cityCandidates[city.id] = score;
}
const destGeneral = ai.rng.choice(generalCandidates);
const srcCity = ai.supplyCities[destGeneral.cityId];
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
if (srcCity && srcCity.dev <= (ai.supplyCities[destCityId]?.dev ?? 0)) {
return null;
}
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장내정발령');
};
export const doNPC후방발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
if (Object.keys(ai.frontCities).length === 0) {
return null;
}
if (ai.dipState !== 4) {
return null;
}
const candidates = Object.values(ai.npcWarGenerals).filter((general) => {
if (general.id === ai.general.id) {
return false;
}
if (!ai.supplyCities[general.cityId]) {
return false;
}
if (general.troopId) {
return false;
}
const city = ai.supplyCities[general.cityId];
if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) {
return false;
}
if (general.crew >= ai.nationPolicy.minWarCrew) {
return false;
}
return true;
});
if (candidates.length === 0) {
return null;
}
const picked = ai.rng.choice(candidates);
const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
const destCityCandidates = selectRecruitableCity(ai, minPop);
if (Object.keys(destCityCandidates).length === 0) {
return null;
}
const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
return buildAssignmentCandidate(ai, picked.id, destCityId, 'NPC후방발령');
};
export const doNPC구출발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
const lostCandidates = Object.values(ai.lostGenerals).filter(
(general) => general.npcState >= 2 && general.npcState !== 5
);
if (lostCandidates.length === 0) {
return null;
}
const destCityId = pickRandomCityId(ai, ai.supplyCities);
if (destCityId === null) {
return null;
}
const destGeneral = ai.rng.choice(lostCandidates);
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC구출발령');
};
export const doNPC전방발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
if (Object.keys(ai.frontCities).length === 0) {
return null;
}
if ([0, 1].includes(ai.dipState)) {
return null;
}
const candidates = Object.values(ai.npcWarGenerals).filter((general) => {
if (ai.frontCities[general.cityId]) {
return false;
}
if (!ai.nationCities[general.cityId]) {
return false;
}
if (general.crew < ai.nationPolicy.minWarCrew) {
return false;
}
if (general.troopId) {
return false;
}
if (Math.max(general.train, general.atmos) < ai.nationPolicy.properWarTrainAtmos) {
return false;
}
return true;
});
if (candidates.length === 0) {
return null;
}
const cityCandidates = pickFrontCityWeight(ai);
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
const destGeneral = ai.rng.choice(candidates);
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC전방발령');
};
export const doNPC내정발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
if (Object.keys(ai.supplyCities).length <= 1) {
return null;
}
const supplyCities = Object.values(ai.supplyCities);
const avgDev = supplyCities.reduce((sum, city) => sum + city.dev, 0) / supplyCities.length;
if (avgDev >= 0.99) {
return null;
}
const npcGenerals = [0, 1].includes(ai.dipState)
? [...Object.values(ai.npcWarGenerals), ...Object.values(ai.npcCivilGenerals)]
: Object.values(ai.npcCivilGenerals);
const generalCandidates = npcGenerals.filter((general) => {
const city = ai.supplyCities[general.cityId];
if (!city) {
return false;
}
return city.dev >= 0.95;
});
if (generalCandidates.length === 0) {
return null;
}
const cityCandidates: Record<number, number> = {};
for (const city of supplyCities) {
const dev = Math.min(city.dev, 0.999);
const score = Math.pow(1 - dev, 2) / Math.sqrt((city.generals ? Object.keys(city.generals).length : 0) + 1);
cityCandidates[city.id] = score;
}
const destGeneral = ai.rng.choice(generalCandidates);
const srcCity = ai.supplyCities[destGeneral.cityId];
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
if (srcCity && srcCity.dev <= (ai.supplyCities[destCityId]?.dev ?? 0)) {
return null;
}
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC내정발령');
};
export const do유저장긴급포상 = (ai: GeneralAI) => {
const nation = ai.nation;
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
];
for (const [resKey, required] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
}
for (const general of Object.values(ai.userWarGenerals)) {
const amount = resolveAwardAmount(ai, general[resKey], required);
if (!amount) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'), amount]);
}
}
return pickWeightedCandidate(ai, candidates);
};
export const do유저장포상 = (ai: GeneralAI) => {
const nation = ai.nation;
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [
['gold', ai.nationPolicy.reqHumanWarRecommandGold],
['rice', ai.nationPolicy.reqHumanWarRecommandRice],
];
for (const [resKey, required] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
}
for (const general of Object.values(ai.userWarGenerals)) {
const amount = resolveAwardAmount(ai, general[resKey], required);
if (!amount) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'), amount]);
}
}
return pickWeightedCandidate(ai, candidates);
};
export const doNPC긴급포상 = (ai: GeneralAI) => {
const nation = ai.nation;
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [
['gold', ai.nationPolicy.reqNpcWarGold / 2],
['rice', ai.nationPolicy.reqNpcWarRice / 2],
];
for (const [resKey, required] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
}
for (const general of Object.values(ai.npcWarGenerals)) {
const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999);
if (killturn <= 5) {
continue;
}
const amount = resolveAwardAmount(ai, general[resKey], required);
if (!amount) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'), amount]);
}
}
return pickWeightedCandidate(ai, candidates);
};
export const doNPC포상 = (ai: GeneralAI) => {
const nation = ai.nation;
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number, number]> = [
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
];
for (const [resKey, warReq, devReq] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
}
for (const general of Object.values(ai.npcWarGenerals)) {
const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999);
if (killturn <= 5) {
continue;
}
const amount = resolveAwardAmount(ai, general[resKey], warReq);
if (!amount) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]);
}
for (const general of Object.values(ai.npcCivilGenerals)) {
const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999);
if (killturn <= 5) {
continue;
}
const amount = resolveAwardAmount(ai, general[resKey], devReq);
if (!amount) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]);
}
}
return pickWeightedCandidate(ai, candidates);
};
export const doNPC몰수 = (ai: GeneralAI) => {
const nation = ai.nation;
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number, number]> = [
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
];
for (const [resKey, warReq, devReq] of resourceMap) {
const nationLimit = resKey === 'gold' ? ai.nationPolicy.reqNationGold : ai.nationPolicy.reqNationRice;
const nationEnough = nation[resKey] >= nationLimit;
for (const general of Object.values(ai.npcCivilGenerals)) {
if (general[resKey] <= devReq * 1.5) {
continue;
}
const amount = Math.min(general[resKey] - devReq * 1.2, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue;
}
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
}
if (!nationEnough) {
for (const general of Object.values(ai.npcWarGenerals)) {
const minRes = nation[resKey] < nationLimit * 0.5 ? warReq * 2 : warReq;
if (general[resKey] <= minRes) {
continue;
}
const amount = Math.min(general[resKey] - minRes, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue;
}
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
}
}
}
return pickWeightedCandidate(ai, candidates);
};
export const do불가침제의 = (ai: GeneralAI) => {
if (!ai.nation || ai.general.officerLevel < 12) {
return null;
}
if (!ai.worldRef) {
return null;
}
const meta = asRecord(ai.nation.meta);
const recvAssist = Array.isArray(meta.recv_assist) ? meta.recv_assist : [];
const respAssist = asRecord(meta.resp_assist);
const respAssistTry = asRecord(meta.resp_assist_try);
const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth);
const candidateList: Record<number, number> = {};
for (const entry of recvAssist) {
if (!Array.isArray(entry) || entry.length < 2) {
continue;
}
const destNationId = Number(entry[0]);
const amount = Number(entry[1]);
if (!Number.isFinite(destNationId) || !Number.isFinite(amount)) {
continue;
}
const respEntry = asRecord(respAssist[`n${destNationId}`]);
const respAmount = readMetaNumber(respEntry, '1', 0);
const remain = amount - respAmount;
if (remain <= 0) {
continue;
}
if (ai.warTargetNation[destNationId]) {
continue;
}
const lastTry = readMetaNumber(asRecord(respAssistTry[`n${destNationId}`]), '1', 0);
if (lastTry >= yearMonth - 8) {
continue;
}
candidateList[destNationId] = remain;
}
if (Object.keys(candidateList).length === 0) {
return null;
}
const income = resolveNationIncome(ai);
if (income <= 0) {
return null;
}
const sorted = Object.entries(candidateList).sort((a, b) => b[1] - a[1]);
let destNationId: number | null = null;
let diplomatMonth = 0;
for (const [idRaw, amount] of sorted) {
if (amount * 4 < income) {
break;
}
destNationId = Number(idRaw);
diplomatMonth = (24 * amount) / income;
break;
}
if (!destNationId) {
return null;
}
const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth));
return ai.buildNationCandidate(
'che_불가침제의',
{ destNationId, year: targetYear, month: targetMonth },
'불가침제의'
);
};
export const do선전포고 = (ai: GeneralAI) => {
if (!ai.nation || ai.general.officerLevel < 12) {
return null;
}
if (ai.dipState !== 0) {
return null;
}
if (ai.attackable) {
return null;
}
if (!ai.nation.capitalCityId) {
return null;
}
if (Object.keys(ai.frontCities).length > 0) {
return null;
}
if (!ai.map || !ai.worldRef) {
return null;
}
const avgResources = Object.values({
...ai.npcWarGenerals,
...ai.npcCivilGenerals,
...ai.userWarGenerals,
...ai.userCivilGenerals,
});
if (avgResources.length === 0) {
return null;
}
let avgGold = ai.nation.gold;
let avgRice = ai.nation.rice;
for (const general of avgResources) {
const scale = general.npcState < 2 ? 0.5 : 1;
avgGold += general.gold * scale;
avgRice += general.rice * scale;
}
avgGold /= avgResources.length;
avgRice /= avgResources.length;
const trialProp =
avgGold / Math.max(ai.nationPolicy.reqNpcWarGold * 1.5, 2000) +
avgRice / Math.max(ai.nationPolicy.reqNpcWarRice * 1.5, 2000);
const devRate = ai.calcNationDevelopedRate();
const chance = Math.pow((trialProp + (devRate.pop + devRate.all) / 2) / 4, 6);
if (!ai.rng.nextBool(chance)) {
return null;
}
const currentNationId = ai.nation.id;
const cities = ai.worldRef.listCities();
const neighbors = ai.worldRef.listNations().filter((nation) => {
if (nation.id <= 0 || nation.id === currentNationId) {
return false;
}
return isNeighbor(ai.map!, cities, currentNationId, nation.id, true);
});
if (neighbors.length === 0) {
return null;
}
const weight: Record<number, number> = {};
for (const nation of neighbors) {
weight[nation.id] = 1 / Math.sqrt(nation.power + 1);
}
const destNationId = Number(ai.rng.choiceUsingWeight(weight));
if (!Number.isFinite(destNationId)) {
return null;
}
return ai.buildNationCandidate('che_선전포고', { destNationId }, '선전포고');
};
export const do천도 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
if (!ai.map) {
return null;
}
const nationCities = Object.values(ai.nationCities);
if (nationCities.length <= 1) {
return null;
}
const cityIds = nationCities.map((city) => city.id);
const distanceList = searchAllDistanceByCityList(ai.map, cityIds);
const capitalId = ai.nation.capitalCityId;
if (!distanceList[capitalId]) {
return null;
}
let maxDistance = 0;
for (const distances of Object.values(distanceList)) {
const sum = Object.values(distances).reduce((acc, value) => acc + value, 0);
maxDistance = Math.max(maxDistance, sum);
}
const cityScores: Record<number, number> = {};
for (const city of nationCities) {
const sumDistance = Object.values(distanceList[city.id] ?? {}).reduce((acc, value) => acc + value, 0);
if (sumDistance <= 0) {
continue;
}
const dev = calcCityDevRatio(city);
cityScores[city.id] = city.population * (maxDistance / sumDistance) * Math.sqrt(dev);
}
const sorted = Object.entries(cityScores).sort((a, b) => b[1] - a[1]);
const topLimit = Math.ceil(sorted.length * 0.25);
for (let idx = 0; idx < Math.min(topLimit, sorted.length); idx += 1) {
if (Number(sorted[idx][0]) === capitalId) {
return null;
}
}
const finalCityId = Number(sorted[0]?.[0]);
if (!Number.isFinite(finalCityId)) {
return null;
}
const dist = distanceList[capitalId]?.[finalCityId];
if (dist === undefined) {
return null;
}
let targetCityId = finalCityId;
if (dist > 1) {
const connections = ai.map.cities.find((city) => city.id === capitalId)?.connections ?? [];
const candidates = connections.filter((stopId) => distanceList[stopId]?.[finalCityId] + 1 === dist);
if (candidates.length > 0) {
targetCityId = ai.rng.choice(candidates);
}
}
return ai.buildNationCandidate('che_천도', { destCityId: targetCityId }, '천도');
};
export const nationActionHandlers: Record<
string,
(ai: GeneralAI) => ReturnType<GeneralAI['buildNationCandidate']> | null
> = {
불가침제의: do불가침제의,
선전포고: do선전포고,
천도: do천도,
유저장긴급포상: do유저장긴급포상,
부대전방발령: do부대전방발령,
유저장구출발령: do유저장구출발령,
유저장후방발령: do유저장후방발령,
부대유저장후방발령: do부대유저장후방발령,
유저장전방발령: do유저장전방발령,
유저장포상: do유저장포상,
부대구출발령: do부대구출발령,
부대후방발령: do부대후방발령,
NPC긴급포상: doNPC긴급포상,
NPC구출발령: doNPC구출발령,
NPC후방발령: doNPC후방발령,
NPC포상: doNPC포상,
NPC전방발령: doNPC전방발령,
유저장내정발령: do유저장내정발령,
NPC내정발령: doNPC내정발령,
NPC몰수: doNPC몰수,
};
+426
View File
@@ -0,0 +1,426 @@
import type { ScenarioConfig, TurnCommandEnv, UnitSetDefinition } from '@sammo-ts/logic';
import type { TurnGeneral } from '../types.js';
import type { Nation } from '@sammo-ts/logic';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import { asRecord, readMetaNumber, readNumber, roundTo } from './aiUtils.js';
export type PolicyFlags = Record<string, boolean>;
const DEFAULT_GENERAL_PRIORITY = [
'NPC사망대비',
'귀환',
'금쌀구매',
'출병',
'긴급내정',
'전투준비',
'전방워프',
'NPC헌납',
'징병',
'후방워프',
'전쟁내정',
'소집해제',
'일반내정',
'내정워프',
] as const;
const DEFAULT_NATION_PRIORITY = [
'불가침제의',
'선전포고',
'천도',
'유저장긴급포상',
'부대전방발령',
'유저장구출발령',
'유저장후방발령',
'부대유저장후방발령',
'유저장전방발령',
'유저장포상',
'부대구출발령',
'부대후방발령',
'NPC긴급포상',
'NPC구출발령',
'NPC후방발령',
'NPC포상',
'NPC전방발령',
'유저장내정발령',
'NPC내정발령',
'NPC몰수',
] as const;
export const AVAILABLE_INSTANT_TURN: Record<string, boolean> = {
유저장긴급포상: true,
유저장구출발령: true,
유저장후방발령: true,
유저장전방발령: true,
유저장내정발령: true,
유저장포상: true,
NPC긴급포상: true,
NPC구출발령: true,
NPC후방발령: true,
NPC내정발령: true,
NPC포상: true,
NPC전방발령: true,
};
const buildFlags = (entries: Array<[string, boolean]>): PolicyFlags => Object.fromEntries(entries);
const applyPriorityOverride = (priority: string[], override: unknown, flags: PolicyFlags): string[] => {
if (!Array.isArray(override)) {
return priority;
}
const filtered = override.filter((item) => typeof item === 'string' && flags[item] !== undefined);
return filtered.length > 0 ? (filtered as string[]) : priority;
};
const resolvePolicyRecord = (raw: unknown): Record<string, unknown> => asRecord(raw);
export class AutorunGeneralPolicy {
public priority: string[];
public flags: PolicyFlags;
constructor(
general: TurnGeneral,
aiOptions: Record<string, boolean> | null,
nationPolicy: Record<string, unknown> | null,
serverPolicy: Record<string, unknown> | null
) {
this.priority = [...DEFAULT_GENERAL_PRIORITY];
this.flags = buildFlags([
['NPC사망대비', true],
['일반내정', true],
['긴급내정', true],
['전쟁내정', true],
['금쌀구매', true],
['상인무시', true],
['징병', true],
['모병', false],
['한계징병', false],
['고급병종', false],
['전투준비', true],
['소집해제', true],
['출병', true],
['NPC헌납', true],
['후방워프', true],
['전방워프', true],
['내정워프', true],
['귀환', true],
['국가선택', true],
['집합', false],
['건국', true],
['선양', false],
]);
this.priority = applyPriorityOverride(this.priority, serverPolicy?.priority, this.flags);
this.priority = applyPriorityOverride(this.priority, nationPolicy?.priority, this.flags);
if (general.npcState >= 2) {
this.applyNpcState(general);
return;
}
// 유저장 기본값
this.flags['일반내정'] = false;
this.flags['긴급내정'] = false;
this.flags['전쟁내정'] = false;
this.flags['금쌀구매'] = false;
this.flags['상인무시'] = false;
this.flags['징병'] = false;
this.flags['모병'] = false;
this.flags['한계징병'] = true;
this.flags['고급병종'] = true;
this.flags['전투준비'] = false;
this.flags['출병'] = false;
this.flags['NPC헌납'] = false;
this.flags['후방워프'] = false;
this.flags['전방워프'] = false;
this.flags['내정워프'] = false;
this.flags['국가선택'] = false;
this.flags['집합'] = false;
this.flags['건국'] = false;
this.flags['선양'] = false;
const options = aiOptions ?? {};
for (const [key, enabled] of Object.entries(options)) {
if (!enabled) {
continue;
}
switch (key) {
case 'develop':
this.flags['일반내정'] = true;
this.flags['전쟁내정'] = true;
this.flags['금쌀구매'] = true;
break;
case 'warp':
this.flags['후방워프'] = true;
this.flags['전방워프'] = true;
this.flags['내정워프'] = true;
this.flags['금쌀구매'] = true;
this.flags['상인무시'] = true;
break;
case 'recruit_high': {
this.flags['모병'] = true;
this.flags['징병'] = true;
this.flags['소집해제'] = true;
this.flags['금쌀구매'] = true;
break;
}
case 'recruit':
this.flags['징병'] = true;
this.flags['소집해제'] = true;
this.flags['금쌀구매'] = true;
break;
case 'train':
this.flags['전투준비'] = true;
this.flags['금쌀구매'] = true;
break;
case 'battle':
this.flags['출병'] = true;
this.flags['금쌀구매'] = true;
break;
default:
break;
}
}
}
can(actionName: string): boolean {
return this.flags[actionName] ?? false;
}
private applyNpcState(general: TurnGeneral): void {
if (general.npcState === 5) {
this.flags['집합'] = true;
this.flags['선양'] = true;
this.flags['국가선택'] = false;
return;
}
if (general.npcState === 1) {
this.flags['NPC사망대비'] = false;
}
if (general.nationId !== 0) {
this.flags['국가선택'] = false;
this.flags['건국'] = false;
}
}
}
export class AutorunNationPolicy {
public priority: string[];
public flags: PolicyFlags;
public reqNationGold = 10000;
public reqNationRice = 12000;
public combatForce: Record<number, [number, number]> = {};
public supportForce: number[] = [];
public developForce: number[] = [];
public reqHumanWarUrgentGold = 0;
public reqHumanWarUrgentRice = 0;
public reqHumanWarRecommandGold = 0;
public reqHumanWarRecommandRice = 0;
public reqHumanDevelGold = 10000;
public reqHumanDevelRice = 10000;
public reqNpcWarGold = 0;
public reqNpcWarRice = 0;
public reqNpcDevelGold = 0;
public reqNpcDevelRice = 500;
public minimumResourceActionAmount = 1000;
public maximumResourceActionAmount = 10000;
public minNpcWarLeadership = 40;
public minWarCrew = 1500;
public minNpcRecruitCityPopulation = 50000;
public safeRecruitCityPopulationRatio = 0.5;
public properWarTrainAtmos = 90;
public cureThreshold = 10;
constructor(options: {
general: TurnGeneral;
aiOptions: Record<string, boolean> | null;
nationPolicy: Record<string, unknown> | null;
serverPolicy: Record<string, unknown> | null;
nation: Nation;
env: TurnCommandEnv;
scenarioConfig: ScenarioConfig;
unitSet?: UnitSetDefinition;
}) {
const { general, aiOptions, nationPolicy, serverPolicy, env, scenarioConfig, unitSet, nation } = options;
this.priority = [...DEFAULT_NATION_PRIORITY];
this.flags = buildFlags(DEFAULT_NATION_PRIORITY.map((key) => [key, true]));
this.flags['부대구출발령'] = true;
this.flags['부대후방발령'] = true;
this.flags['부대전방발령'] = true;
this.flags['NPC몰수'] = true;
this.flags['부대유저장후방발령'] = true;
const serverValues = resolvePolicyRecord(serverPolicy?.values);
for (const [key, value] of Object.entries(serverValues)) {
this.applyValueOverride(key, value);
}
if (serverPolicy?.priority) {
this.priority = applyPriorityOverride(this.priority, serverPolicy.priority, this.flags);
}
const nationValues = resolvePolicyRecord(nationPolicy?.values);
for (const [key, value] of Object.entries(nationValues)) {
this.applyValueOverride(key, value);
}
if (nationPolicy?.priority) {
this.priority = applyPriorityOverride(this.priority, nationPolicy.priority, this.flags);
}
if (this.priority.length === 0) {
this.priority = [...DEFAULT_NATION_PRIORITY];
}
if (this.reqNpcDevelGold === 0) {
this.reqNpcDevelGold = env.develCost * 30;
}
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const stat = scenarioConfig.stat;
if (this.reqNpcWarGold === 0 || this.reqNpcWarRice === 0) {
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.npcMax : 0;
const baseRice = stat.npcMax;
if (this.reqNpcWarGold === 0) {
this.reqNpcWarGold = roundTo(baseGold * 4, -2);
}
if (this.reqNpcWarRice === 0) {
this.reqNpcWarRice = roundTo(baseRice * 4, -2);
}
}
if (this.reqHumanWarUrgentGold === 0 || this.reqHumanWarUrgentRice === 0) {
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.max : 0;
const baseRice = stat.max;
if (this.reqHumanWarUrgentGold === 0) {
this.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
}
if (this.reqHumanWarUrgentRice === 0) {
this.reqHumanWarUrgentRice = roundTo(baseRice * 6, -2);
}
}
if (this.reqHumanWarRecommandGold === 0) {
this.reqHumanWarRecommandGold = roundTo(this.reqHumanWarUrgentGold * 2, -2);
}
if (this.reqHumanWarRecommandRice === 0) {
this.reqHumanWarRecommandRice = roundTo(this.reqHumanWarUrgentRice * 2, -2);
}
if (general.npcState >= 2) {
return;
}
if (!aiOptions?.chief) {
for (const key of Object.keys(this.flags)) {
this.flags[key] = false;
}
this.flags['선전포고'] = false;
this.flags['천도'] = false;
}
}
can(actionName: string): boolean {
return this.flags[actionName] ?? false;
}
private applyValueOverride(key: string, value: unknown): void {
switch (key) {
case 'reqNationGold':
this.reqNationGold = readNumber(value, this.reqNationGold);
return;
case 'reqNationRice':
this.reqNationRice = readNumber(value, this.reqNationRice);
return;
case 'CombatForce':
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
const next: Record<number, [number, number]> = {};
for (const [rawKey, rawValue] of Object.entries(record)) {
const leaderId = Number(rawKey);
if (!Number.isFinite(leaderId)) {
continue;
}
if (!Array.isArray(rawValue) || rawValue.length < 2) {
continue;
}
const fromCity = Number(rawValue[0]);
const toCity = Number(rawValue[1]);
if (!Number.isFinite(fromCity) || !Number.isFinite(toCity)) {
continue;
}
next[leaderId] = [fromCity, toCity];
}
this.combatForce = next;
}
return;
case 'SupportForce':
if (Array.isArray(value)) {
this.supportForce = value.filter((v) => typeof v === 'number') as number[];
}
return;
case 'DevelopForce':
if (Array.isArray(value)) {
this.developForce = value.filter((v) => typeof v === 'number') as number[];
}
return;
case 'reqHumanWarUrgentGold':
this.reqHumanWarUrgentGold = readNumber(value, this.reqHumanWarUrgentGold);
return;
case 'reqHumanWarUrgentRice':
this.reqHumanWarUrgentRice = readNumber(value, this.reqHumanWarUrgentRice);
return;
case 'reqHumanWarRecommandGold':
this.reqHumanWarRecommandGold = readNumber(value, this.reqHumanWarRecommandGold);
return;
case 'reqHumanWarRecommandRice':
this.reqHumanWarRecommandRice = readNumber(value, this.reqHumanWarRecommandRice);
return;
case 'reqHumanDevelGold':
this.reqHumanDevelGold = readNumber(value, this.reqHumanDevelGold);
return;
case 'reqHumanDevelRice':
this.reqHumanDevelRice = readNumber(value, this.reqHumanDevelRice);
return;
case 'reqNPCWarGold':
this.reqNpcWarGold = readNumber(value, this.reqNpcWarGold);
return;
case 'reqNPCWarRice':
this.reqNpcWarRice = readNumber(value, this.reqNpcWarRice);
return;
case 'reqNPCDevelGold':
this.reqNpcDevelGold = readNumber(value, this.reqNpcDevelGold);
return;
case 'reqNPCDevelRice':
this.reqNpcDevelRice = readNumber(value, this.reqNpcDevelRice);
return;
case 'minimumResourceActionAmount':
this.minimumResourceActionAmount = readNumber(value, this.minimumResourceActionAmount);
return;
case 'maximumResourceActionAmount':
this.maximumResourceActionAmount = readNumber(value, this.maximumResourceActionAmount);
return;
case 'minNPCWarLeadership':
this.minNpcWarLeadership = readNumber(value, this.minNpcWarLeadership);
return;
case 'minWarCrew':
this.minWarCrew = readNumber(value, this.minWarCrew);
return;
case 'minNPCRecruitCityPopulation':
this.minNpcRecruitCityPopulation = readNumber(value, this.minNpcRecruitCityPopulation);
return;
case 'safeRecruitCityPopulationRatio':
this.safeRecruitCityPopulationRatio = readNumber(value, this.safeRecruitCityPopulationRatio);
return;
case 'properWarTrainAtmos':
this.properWarTrainAtmos = readNumber(value, this.properWarTrainAtmos);
return;
case 'cureThreshold':
this.cureThreshold = readNumber(value, this.cureThreshold);
return;
default:
return;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import type {
City,
GeneralActionDefinition,
MapDefinition,
Nation,
ScenarioConfig,
ScenarioMeta,
Troop,
TurnCommandEnv,
UnitSetDefinition,
} from '@sammo-ts/logic';
import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from '../types.js';
import type { ReservedTurnEntry } from '../reservedTurnStore.js';
export interface AiWorldView {
getGeneralById(id: number): TurnGeneral | null;
getCityById(id: number): City | null;
getNationById(id: number): Nation | null;
getTroopById(id: number): Troop | null;
getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null;
listGenerals(): TurnGeneral[];
listCities(): City[];
listNations(): Nation[];
listTroops(): Troop[];
listDiplomacy(): TurnDiplomacy[];
}
export interface AiStaticContext {
scenarioConfig: ScenarioConfig;
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
unitSet?: UnitSetDefinition;
commandEnv: TurnCommandEnv;
generalDefinitions: Map<string, GeneralActionDefinition>;
nationDefinitions: Map<string, GeneralActionDefinition>;
generalFallback: GeneralActionDefinition;
nationFallback: GeneralActionDefinition;
}
export interface AiTurnContext {
general: TurnGeneral;
city?: City;
nation?: Nation | null;
world: TurnWorldState;
worldRef: AiWorldView | null;
reservedTurn: ReservedTurnEntry;
}
export interface AiCommandCandidate {
action: string;
args: Record<string, unknown>;
reason: string;
}
export interface AiReservedTurnProvider {
getGeneralTurn(generalId: number, turnIdx: number): ReservedTurnEntry;
}
@@ -38,6 +38,8 @@ import {
} from '@sammo-ts/logic';
import { buildCommandEnv, buildReservedTurnDefinitions } from './reservedTurnCommands.js';
import { buildActionContext } from './reservedTurnActionContext.js';
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
import type { AiReservedTurnProvider } from './ai/types.js';
const DEFAULT_ACTION = '휴식';
@@ -433,6 +435,10 @@ export const createReservedTurnHandler = async (options: {
return result;
};
const reservedTurnProvider: AiReservedTurnProvider = {
getGeneralTurn: (generalId, turnIdx) => options.reservedTurns.getGeneralTurn(generalId, turnIdx),
};
return {
execute(context): GeneralTurnResult {
const worldRef = options.getWorld();
@@ -661,16 +667,62 @@ export const createReservedTurnHandler = async (options: {
};
if (currentNation && currentGeneral.officerLevel >= 5) {
const nationCommand = options.reservedTurns.getNationTurn(
let nationCommand = options.reservedTurns.getNationTurn(
currentNation.id,
currentGeneral.officerLevel,
0
);
if (worldView && shouldUseAi(currentGeneral, context.world)) {
const ai = new GeneralAI({
general: currentGeneral,
city: currentCity,
nation: currentNation,
world: context.world,
worldRef: worldView,
reservedTurnProvider,
scenarioConfig: options.scenarioConfig,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
commandEnv: env,
generalDefinitions,
nationDefinitions,
generalFallback,
nationFallback,
});
const candidate = ai.chooseNationTurn(nationCommand);
if (candidate) {
nationCommand = { action: candidate.action, args: candidate.args };
}
}
runAction(nationDefinitions, nationFallback, nationCommand, false);
options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1);
}
const generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0);
let generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0);
if (worldView && shouldUseAi(currentGeneral, context.world)) {
const ai = new GeneralAI({
general: currentGeneral,
city: currentCity,
nation: currentNation,
world: context.world,
worldRef: worldView,
reservedTurnProvider,
scenarioConfig: options.scenarioConfig,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
commandEnv: env,
generalDefinitions,
nationDefinitions,
generalFallback,
nationFallback,
});
const candidate = ai.chooseGeneralTurn(generalCommand);
if (candidate) {
generalCommand = { action: candidate.action, args: candidate.args };
}
}
const nextTurnAt = runAction(generalDefinitions, generalFallback, generalCommand, true);
options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1);
@@ -135,6 +135,30 @@ export class InMemoryReservedTurnStore {
this.generalTurns.set(generalId, buildTurnListFromRows(rows, this.maxGeneralTurns));
}
async prefetchGeneralTurns(generalIds: number[]): Promise<void> {
const targetIds = Array.from(new Set(generalIds)).filter((generalId) => !this.dirtyGeneralIds.has(generalId));
if (targetIds.length === 0) {
return;
}
const rows = await this.prisma.generalTurn.findMany({
where: { generalId: { in: targetIds } },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
});
const grouped = new Map<number, typeof rows>();
for (const row of rows) {
const list = grouped.get(row.generalId);
if (list) {
list.push(row);
} else {
grouped.set(row.generalId, [row]);
}
}
for (const generalId of targetIds) {
const list = grouped.get(generalId) ?? [];
this.generalTurns.set(generalId, buildTurnListFromRows(list, this.maxGeneralTurns));
}
}
async refreshNationTurns(nationId: number, officerLevel: number): Promise<void> {
const key = buildNationKey(nationId, officerLevel);
if (this.dirtyNationKeys.has(key)) {
+19 -2
View File
@@ -20,6 +20,7 @@ import { createReservedTurnStore } from './reservedTurnStore.js';
import { createTurnDaemonCommandHandler } from './worldCommandHandler.js';
import { loadTurnCommandProfile } from './turnCommandProfile.js';
import { loadTurnWorldFromDatabase } from './worldLoader.js';
import { shouldUseAi } from './ai/generalAi.js';
export interface TurnDaemonRuntimeOptions {
profile: string;
@@ -116,14 +117,30 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
worldRef = world;
const stateStore = new InMemoryTurnStateStore(world);
const prefetchedNationTurns = new Set<string>();
const processor = new InMemoryTurnProcessor(world, {
tickMinutes,
beforeExecuteGeneral: reservedTurnStoreHandle
? async (general) => {
await reservedTurnStoreHandle.store.refreshGeneralTurns(general.id);
const promises: Promise<unknown>[] = [];
promises.push(reservedTurnStoreHandle.store.refreshGeneralTurns(general.id));
if (general.nationId > 0 && general.officerLevel >= 5) {
await reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel);
promises.push(
reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel)
);
}
if (general.nationId > 0 && general.officerLevel >= 5 && shouldUseAi(general, world.getState())) {
const key = `${general.nationId}:${world.getState().currentYear}:${world.getState().currentMonth}`;
if (!prefetchedNationTurns.has(key)) {
prefetchedNationTurns.add(key);
const generalIds = world
.listGenerals()
.filter((candidate) => candidate.nationId === general.nationId)
.map((candidate) => candidate.id);
promises.push(reservedTurnStoreHandle.store.prefetchGeneralTurns(generalIds));
}
}
await Promise.all(promises);
}
: undefined,
});