diff --git a/app/game-engine/src/turn/ai/aiUtils.ts b/app/game-engine/src/turn/ai/aiUtils.ts new file mode 100644 index 0000000..7b0d474 --- /dev/null +++ b/app/game-engine/src/turn/ai/aiUtils.ts @@ -0,0 +1,89 @@ +import type { City, Nation } from '@sammo-ts/logic'; + +export const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +export const asRecord = (value: unknown): Record => (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, key: string, fallback = 0): number => + readNumber(meta[key], fallback); + +export const readMetaString = (meta: Record, key: string, fallback = ''): string => + readString(meta[key], fallback); + +export const readMetaBoolean = (meta: Record, 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); +}; diff --git a/app/game-engine/src/turn/ai/distance.ts b/app/game-engine/src/turn/ai/distance.ts new file mode 100644 index 0000000..7998077 --- /dev/null +++ b/app/game-engine/src/turn/ai/distance.ts @@ -0,0 +1,96 @@ +import type { City, MapDefinition } from '@sammo-ts/logic'; + +const buildConnectionMap = (map: MapDefinition): Map => { + const result = new Map(); + for (const city of map.cities) { + result.set(city.id, city.connections ?? []); + } + return result; +}; + +export const searchAllDistanceByCityList = ( + map: MapDefinition, + cityIds: number[] +): Record> => { + if (cityIds.length === 0) { + return {}; + } + const connectionMap = buildConnectionMap(map); + const citySet = new Set(cityIds); + const result: Record> = {}; + + for (const startId of citySet) { + const distances: Record = { [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> => { + 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; +}; diff --git a/app/game-engine/src/turn/ai/generalAi.ts b/app/game-engine/src/turn/ai/generalAi.ts new file mode 100644 index 0000000..d4b0066 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi.ts @@ -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; + +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, + private readonly overrides?: { + general?: TurnGeneral; + city?: City; + nation?: Nation | null; + } + ) {} + + has(req: Parameters[0]): boolean { + return this.get(req) !== null; + } + + get(req: Parameters[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; + nationDefinitions: Map; + 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; + public readonly nationDefinitions: Map; + 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 = {}; + public attackable = false; + public maxResourceActionAmount = 0; + + public nationCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + public frontCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + public supplyCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + public backupCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + public warRoute: Record> | null = null; + + public nationGenerals: TurnGeneral[] = []; + public npcCivilGenerals: Record = {}; + public npcWarGenerals: Record = {}; + public userGenerals: Record = {}; + public userWarGenerals: Record = {}; + public userCivilGenerals: Record = {}; + public chiefGenerals: Record = {}; + public lostGenerals: Record = {}; + public troopLeaders: Record = {}; + + private reqUpdateInstance = true; + private devRate: Record | 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)?.autorun_user)?.options as Record, + asRecord(this.nation?.meta)?.npc_general_policy as Record | null, + asRecord(this.world.meta)?.npc_general_policy as Record | null + ); + const nationPolicy = new AutorunNationPolicy({ + general: this.general, + aiOptions: asRecord((this.world.meta as Record)?.autorun_user)?.options as Record< + string, + boolean + > | null, + nationPolicy: asRecord(this.nation?.meta)?.npc_nation_policy as Record | null, + serverPolicy: asRecord(this.world.meta)?.npc_nation_policy as Record | 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, reason: string): AiCommandCandidate | null { + return this.buildCandidate(this.generalDefinitions, this.generalFallback, action, args, reason); + } + + buildNationCandidate(action: string, args: Record, 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 { + if (this.devRate) { + return this.devRate; + } + this.categorizeNationCities(); + const devRate: Record = { 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 { + 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 } + > = {}; + const frontCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + const supplyCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + const backupCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + + 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 }; + 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 = {}; + const userCivilGenerals: Record = {}; + const userWarGenerals: Record = {}; + const lostGenerals: Record = {}; + const npcCivilGenerals: Record = {}; + const npcWarGenerals: Record = {}; + const troopLeaders: Record = {}; + const chiefGenerals: Record = {}; + + 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, + fallback: GeneralActionDefinition, + action: string, + args: Record, + 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, + env: constraintEnv, + mode: 'full', + }; + const view = new WorldStateView(this.worldRef, constraintEnv, parsedArgs as Record, { + 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, + 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 = {}; + 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; +}; diff --git a/app/game-engine/src/turn/ai/generalAiGeneralActions.ts b/app/game-engine/src/turn/ai/generalAiGeneralActions.ts new file mode 100644 index 0000000..8d510ae --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAiGeneralActions.ts @@ -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, number]> +) => { + const items = list.filter(([item]) => Boolean(item)) as Array< + [ReturnType, 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, 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, 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, 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, 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 = {}; + 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 = {}; + 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 = {}; + 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 | null +> = { + NPC사망대비: doNPC사망대비, + 귀환: do귀환, + 금쌀구매: do금쌀구매, + 출병: do출병, + 긴급내정: do긴급내정, + 전투준비: do전투준비, + 전방워프: do전방워프, + NPC헌납: doNPC헌납, + 징병: do징병, + 후방워프: do후방워프, + 전쟁내정: do전쟁내정, + 소집해제: do소집해제, + 일반내정: do일반내정, + 내정워프: do내정워프, + 국가선택: do국가선택, + 중립: do중립, + 집합: do집합, + 거병: do거병, + 건국: do건국, + 방랑군이동: do방랑군이동, +}; diff --git a/app/game-engine/src/turn/ai/generalAiNationActions.ts b/app/game-engine/src/turn/ai/generalAiNationActions.ts new file mode 100644 index 0000000..e29140c --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAiNationActions.ts @@ -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, number]>) => { + const items = list.filter(([item]) => Boolean(item)) as Array< + [ReturnType, number] + >; + if (items.length === 0) { + return null; + } + const picked = ai.rng.choiceUsingWeightPair(items); + return picked ?? null; +}; + +const pickRandomCityId = (ai: GeneralAI, cities: Record): 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 => { + const candidates: Record = {}; + 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 => { + const candidates: Record = {}; + 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, 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 = {}; + 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 = {}; + 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 = {}; + 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, 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, 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, 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, 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, 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 = {}; + 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 = {}; + 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 = {}; + 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 | 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몰수, +}; diff --git a/app/game-engine/src/turn/ai/policies.ts b/app/game-engine/src/turn/ai/policies.ts new file mode 100644 index 0000000..6f6744a --- /dev/null +++ b/app/game-engine/src/turn/ai/policies.ts @@ -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; + +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 = { + 유저장긴급포상: 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 => asRecord(raw); + +export class AutorunGeneralPolicy { + public priority: string[]; + public flags: PolicyFlags; + + constructor( + general: TurnGeneral, + aiOptions: Record | null, + nationPolicy: Record | null, + serverPolicy: Record | 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 = {}; + 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 | null; + nationPolicy: Record | null; + serverPolicy: Record | 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; + const next: Record = {}; + 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; + } + } +} diff --git a/app/game-engine/src/turn/ai/types.ts b/app/game-engine/src/turn/ai/types.ts new file mode 100644 index 0000000..615074f --- /dev/null +++ b/app/game-engine/src/turn/ai/types.ts @@ -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; + nationDefinitions: Map; + 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; + reason: string; +} + +export interface AiReservedTurnProvider { + getGeneralTurn(generalId: number, turnIdx: number): ReservedTurnEntry; +} diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 97f4cc1..f0570a3 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -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); diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index 40a4e86..47fc130 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -135,6 +135,30 @@ export class InMemoryReservedTurnStore { this.generalTurns.set(generalId, buildTurnListFromRows(rows, this.maxGeneralTurns)); } + async prefetchGeneralTurns(generalIds: number[]): Promise { + 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(); + 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 { const key = buildNationKey(nationId, officerLevel); if (this.dirtyNationKeys.has(key)) { diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index ddbd709..d84726d 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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(); const processor = new InMemoryTurnProcessor(world, { tickMinutes, beforeExecuteGeneral: reservedTurnStoreHandle ? async (general) => { - await reservedTurnStoreHandle.store.refreshGeneralTurns(general.id); + const promises: Promise[] = []; + 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, });