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, minAvailableRecruitPop: env.minAvailableRecruitPop, }; }; const buildSeedBase = (world: TurnWorldState): string => { const meta = asRecord(world.meta); const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id; return String(rawSeed); }; class WorldStateView implements StateView { constructor( private readonly world: AiWorldView | null, private readonly env: ConstraintEnv, private readonly args: Record, 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 worldMeta = asRecord(this.world.meta); const initYear = readNumber(worldMeta.initYear, this.scenarioMeta?.startYear ?? this.startYear); const initMonth = readNumber(worldMeta.initMonth, 1); const relYearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth) - joinYearMonth(initYear, initMonth); if (relYearMonth > 1) { const establish = generalActionHandlers['건국']?.(this); if (establish) { return establish; } } const move = generalActionHandlers['방랑군이동']?.(this); if (move) { return move; } } for (const actionName of this.generalPolicy.priority) { if (!this.generalPolicy.can(actionName)) { continue; } const handler = generalActionHandlers[actionName]; if (!handler) { continue; } const result = handler(this); if (result) { return result; } } const neutral = generalActionHandlers['중립']?.(this); return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral'); } buildGeneralCandidate(action: string, args: Record, 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; };