Spaces:
Paused
Paused
File size: 17,939 Bytes
2991d58 4f42c67 2991d58 15f3a03 2991d58 15f3a03 2991d58 15f3a03 2991d58 15f3a03 2991d58 15f3a03 2991d58 4f42c67 2991d58 4f42c67 2991d58 5d60e36 4f42c67 5d60e36 4f42c67 5d60e36 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 | // @ts-check
/* ============================================================
AI INTEL β what the AI BELIEVES about its opponent (docs/ai-adaptive-opponent.md)
Every other reactive read in the AI answers a question about right now: are there enemies at my
base (visibleThreatsNearHome), how many can I see (visibleEnemyForceCount), what do they field
most (counterToPlayerArmy). Each drives one hardcoded reaction, and none of them survives losing
sight of the enemy β entity sighting goes through the LIVE fog layer (isVisibleAt), so the
instant a scout dies the AI's picture of its opponent is empty and it has no way to know it.
This module holds the other kind of knowledge: a BELIEF about what sort of game the opponent is
playing, which persists between sightings and decays when it goes unrefreshed. It is the first
piece of AI state that can be WRONG β every other read is a fact about the current frame β and
that is the point. An opponent model you can deceive is what makes scouting worth its cost and
what makes killing the scout a real counter-play.
THREE THINGS IT REPORTS, and why they are three and not one:
mil / eco Ore-value of the enemy's military and economic assets, as far as `owner` has seen.
posture mil / (mil + eco) β 0 is a pure economy game, 1 is a pure war game. THE read.
confidence How much has been seen, and how recently.
Confidence is separate from posture on purpose. "I have seen nothing" and "I have seen an empty
base" are completely different situations, and a single number cannot tell them apart β
collapsing them is exactly how an AI ends up confidently raiding into an army it never scouted.
Low confidence means hedge and go look, not "the enemy is weak".
THE BELIEF IS A FADING HIGH-WATER MARK, not an average of sightings. Seeing two units near my
base tells me the enemy has AT LEAST that much; it says nothing about the army parked at home.
So a sighting can only ever raise the estimate, and time is the only thing that lowers it. That
also makes the read stable under the thing that would otherwise wreck it β an army walking in and
out of vision β while still letting a genuinely disbanded force fade away.
PURE AND FOG-LIMITED. No clock beyond state.time, no randomness, no reach into the other side's
fog: every read resolves from `owner`'s own state.fogs[owner] and otherOwner(owner), so it drives
either seat in self-play (see engine/ai.js's runAI header). The engine-purity and determinism
guards cover this file like any other.
============================================================ */
"use strict";
import { UNITS, BUILDINGS } from "./entities.js";
import { isVisibleAt } from "./fog.js";
import { otherOwner } from "./aiCommon.js";
// aiStrategy.js is the pure, import-free leaf its own header describes, so this is a downward
// edge with no cycle β the same one aiWorkers/aiMilitary/aiEconomy already have.
import { strategyFor } from "./aiStrategy.js";
// How long an unrefreshed belief takes to fade to nothing, in sim seconds. Four minutes: long
// enough that an army stepping out of vision for a fight doesn't erase itself, short enough that
// an AI still acting on five-minute-old intel is making a mistake the player earned by hiding.
export const INTEL_FADE = 240;
// Total ore-value of enemy assets at which the AI considers itself fully informed. Deliberately
// modest β this is "have I seen enough to have an opinion", not "have I seen everything", and an
// AI that refuses to act until it has scouted a whole base never acts at all.
export const INTEL_FULL = 900;
const clamp01 = v => (v < 0 ? 0 : v > 1 ? 1 : v);
/**
* How much of a belief set `age` sim-seconds ago is still worth β 1 fresh, 0 past the window,
* straight-line in between. Subtraction and division only, no pow()/exp(): a curved fade would
* put an engine-approximated math call on a path that feeds every AI decision, and this project's
* same-seed-same-game promise (CONTRIBUTING Β§1) does not want that.
* @param {number} age @returns {number}
*/
function fadeKeep(age) {
return age >= INTEL_FADE ? 0 : 1 - age / INTEL_FADE;
}
/**
* One channel's belief RIGHT NOW: the stored peak, faded by how long since that peak was set.
*
* THE FADE IS COMPUTED, NEVER ACCUMULATED. This used to fade the stored number and write the faded
* number back, then re-fade THAT from the same timestamp on the next think cycle β so the decay
* compounded once per cycle and its real rate depended on how often the AI happened to think. At
* engine/ai.js's 1.5s cadence the documented four-minute fade emptied in about sixty seconds, which
* quietly made the whole belief model inert the moment the enemy left vision. Keeping the peak
* immutable and deriving the current value from elapsed time makes the fade exactly the straight
* line the header describes, at any cadence.
*
* `atKey` falls back to the shared `intelAt` so a save written before the per-channel stamps
* existed loads as "both channels last refreshed when anything was last seen" β the closest true
* statement that old data supports.
* @param {Object} controller @param {number} now @param {string} valKey @param {string} atKey
* @returns {number}
*/
function channelValue(controller, now, valKey, atKey) {
const peak = controller[valKey] || 0;
if (peak <= 0) return 0;
const at = controller[atKey] != null ? controller[atKey] : controller.intelAt;
if (at == null) return 0;
return peak * fadeKeep(Math.max(0, now - at));
}
/**
* Fold one channel's live sighting into its stored peak. Only a sighting that beats what the AI
* already believes moves anything; anything less leaves the peak AND its stamp untouched, so the
* existing fade carries on from where it was rather than restarting.
* @param {Object} controller @param {number} now @param {string} valKey @param {string} atKey
* @param {number} live @returns {void}
*/
function refreshChannel(controller, now, valKey, atKey, live) {
if (live > channelValue(controller, now, valKey, atKey)) {
controller[valKey] = live;
controller[atKey] = now;
}
}
/** Sum of a cost object's commodities β the same flat ore-value engine/victory.js's own
* costValue uses, kept local so this file stays a leaf of entities/fog/aiCommon only.
* @param {Object} [cost] @returns {number} */
function costValue(cost) {
if (!cost) return 0;
let v = 0;
for (const com of Object.keys(cost)) v += cost[com] || 0;
return v;
}
/**
* Is this building a MILITARY investment? Derived from the definition itself rather than a
* hardcoded id list, so a building added later classifies itself: anything that shoots (turret,
* bastille, torpedobattery), projects a defensive aura (aegisbastion), or produces a combat unit
* (barracks, stardock) is military; everything else β refineries, habitats, the whole industry
* chain β is economy. A Command Center produces only workers and support, so it reads as economy,
* which is right: it is the thing a greedy player is protecting, not the thing they are threatening
* you with.
* @param {Object} def a BUILDINGS entry
* @returns {boolean}
*/
export function isMilitaryBuilding(def) {
if (!def) return false;
if (def.attack > 0 || def.guardAura) return true;
return (def.produces || []).some(t => UNITS[t] && UNITS[t].role === "combat");
}
/**
* What `owner` can SEE of its enemy this instant, valued in ore. The live half of the belief β
* updateIntel folds this into the persistent estimate below.
* @param {State} state @param {string} [owner]
* @returns {{ mil: number, eco: number }}
*/
export function sightEnemy(state, owner = "ai") {
const enemyOwner = otherOwner(owner);
const fog = state.fogs[owner];
let mil = 0, eco = 0;
for (const u of state.units.values()) {
if (u.owner !== enemyOwner || !isVisibleAt(fog, u.x, u.y)) continue;
const def = UNITS[u.type];
if (!def) continue;
if (def.role === "combat") mil += costValue(def.cost); else eco += costValue(def.cost);
}
for (const b of state.buildings.values()) {
if (b.owner !== enemyOwner || !isVisibleAt(fog, b.x, b.y)) continue;
const def = BUILDINGS[b.type];
if (!def) continue;
if (isMilitaryBuilding(def)) mil += costValue(def.cost); else eco += costValue(def.cost);
}
return { mil, eco };
}
/**
* Fold this cycle's sighting into `owner`'s persistent belief. Called once per think cycle from
* engine/ai.js's aiContext, before any phase reads it, so every phase in a cycle sees the same
* picture.
*
* The update is `max(live, faded peak)` per channel β see the high-water-mark note in this file's
* header. Only a sighting raises it; only time lowers it.
*
* THE TWO CHANNELS FADE ON SEPARATE CLOCKS, which is why there are two stamps and not one. A
* worker line parked in view is a real refresh of what the AI knows about the ECONOMY and says
* nothing at all about the army that walked off half a minute ago. Under one shared stamp that
* worker kept re-dating the military belief too, so a single visible drone held the estimate of an
* army that no longer existed up indefinitely β the mirror image of the compounding bug, and just
* as wrong. `intelAt` is still stamped whenever ANYTHING enemy is in sight: it answers a different
* question ("when did I last look at them at all"), which is what readEnemy's confidence needs.
*
* @param {State} state @param {string} [owner] @returns {void}
*/
export function updateIntel(state, owner = "ai") {
const controller = owner === "ai" ? state.ai : state.playerAi;
if (!controller) return;
const live = sightEnemy(state, owner);
refreshChannel(controller, state.time, "intelMil", "intelMilAt", live.mil);
refreshChannel(controller, state.time, "intelEco", "intelEcoAt", live.eco);
if (live.mil > 0 || live.eco > 0) controller.intelAt = state.time;
}
/**
* `owner`'s current read of its enemy. `posture` is null when nothing has ever been seen β the
* caller must handle "I do not know" separately from "they are peaceful", which is the whole reason
* confidence exists (see the header).
*
* @param {State} state @param {string} [owner]
* @returns {{ posture: number|null, confidence: number, mil: number, eco: number, age: number|null }}
*/
export function readEnemy(state, owner = "ai") {
const controller = owner === "ai" ? state.ai : state.playerAi;
if (!controller) return { posture: null, confidence: 0, mil: 0, eco: 0, age: null };
// Each channel's stored PEAK, faded by its own elapsed time β the fade lives here, at the read,
// and is never written back (see channelValue).
const mil = channelValue(controller, state.time, "intelMil", "intelMilAt");
const eco = channelValue(controller, state.time, "intelEco", "intelEcoAt");
const total = mil + eco;
const age = controller.intelAt == null ? null : Math.max(0, state.time - controller.intelAt);
// NOT a second fade on top of the one already in `mil`/`eco` above β this one answers "how long
// since I last laid eyes on them at all", which is a different question from how much of any one
// channel's estimate still stands, and it is the half that separates "I have seen nothing" from
// "I have seen an empty base".
const freshness = age == null ? 0 : fadeKeep(age);
return {
posture: total > 0 ? mil / total : null,
// Both halves must hold: a stale look at a whole base is as uninformative as a fresh glimpse
// of one worker, and multiplying is what makes either one alone insufficient.
confidence: clamp01(total / INTEL_FULL) * freshness,
mil, eco, age,
};
}
/**
* Is the enemy GREEDY and worth punishing β visibly investing in economy over defence, with enough
* seen to justify acting on it? The question Phase 2's raid trigger asks (docs/ai-adaptive-opponent.md).
* Deliberately a named predicate rather than two inequalities at the call site: "when do we punish
* greed" is a design decision that should be readable in one place and testable on its own.
*
* @param {State} state @param {string} [owner]
* @param {{ maxPosture?: number, minConfidence?: number }} [opts]
* @returns {boolean}
*/
export function enemyIsGreedy(state, owner = "ai", opts = {}) {
const r = readEnemy(state, owner);
if (r.posture == null) return false; // never seen them β hedge and scout, never assume weakness
// THE ADAPTATION POLICY IS EVOLVABLE (docs/ai-evolution-design.md Phase 4). Both thresholds read
// off the strategy row through the same `?? default` seam every other dial in this engine uses,
// so an absent value is exactly today's behaviour and tools/genome.js can search them without an
// engine change β the property that made a candidate AI JSON in the first place. An explicit
// opts value still wins, which is what lets aiMilitary scale the bar by difficulty.
const strategy = strategyFor(state, owner);
const maxPosture = opts.maxPosture ?? strategy.punishPosture ?? 0.35;
const minConfidence = opts.minConfidence ?? strategy.punishConfidence ?? 0.25;
return r.posture <= maxPosture && r.confidence >= minConfidence;
}
/* ============================================================
MODE β the smoothed stance the AI actually acts on
`posture` is the raw read and is far too jumpy to drive investment decisions directly: it moves
every time a wave walks past. `adaptMode` is the damped version β the same 0..1 scale, but it
only moves when the read has genuinely shifted, and then only slowly.
BOTH damping terms are load-bearing, and for different reasons:
dead band Two adaptive AIs facing each other form a feedback loop β A goes economy, B raids,
A goes military, B turtles, A goes economy β and an undamped controller oscillates
in it forever instead of settling. The band means small reads change nothing.
rate limit Even a real shift is absorbed over many think cycles rather than in one, so the
opponent stays LEGIBLE. A player has to be able to notice "it's turtling now" and
respond; an AI that reconfigures itself instantly is not a harder opponent, it is
an unreadable one. This is the same argument engine/aiDifficulty.js makes for
Easy's counterEvery: 0, applied to stance instead of composition.
Confidence gates the whole thing: an unscouted enemy cannot move the mode at all, so the AI
hedges at neutral and goes looking rather than committing to a stance it cannot justify.
============================================================ */
export const ADAPT_NEUTRAL = 0.5; // no opinion β hedge, build for both
export const ADAPT_DEAD_BAND = 0.15; // the read must differ from the current mode by this to move it
export const ADAPT_RATE = 0.04; // β¦and then moves at most this far per think cycle
/**
* Step `owner`'s stance toward what it currently believes, and return the new value. Called once
* per think cycle from engine/ai.js, right after updateIntel, so every phase in a cycle acts on one
* stance. `adaptivity` (engine/aiDifficulty.js) scales the rate: 0 pins the mode at neutral forever,
* which is how Easy stays a fixed, learnable opponent.
* @param {State} state @param {string} [owner] @param {number} [adaptivity] @returns {number}
*/
export function updateAdaptMode(state, owner = "ai", adaptivity = 1) {
const controller = owner === "ai" ? state.ai : state.playerAi;
if (!controller) return ADAPT_NEUTRAL;
const cur = controller.adaptMode == null ? ADAPT_NEUTRAL : controller.adaptMode;
if (adaptivity <= 0) { controller.adaptMode = ADAPT_NEUTRAL; return ADAPT_NEUTRAL; }
const r = readEnemy(state, owner);
// An unknown or barely-scouted enemy pulls toward neutral, never toward a stance: "I have not
// looked" must not read as "they are peaceful" here either.
const target = r.posture == null ? ADAPT_NEUTRAL
: ADAPT_NEUTRAL + (r.posture - ADAPT_NEUTRAL) * r.confidence;
const gap = target - cur;
// Both damping terms are evolvable multipliers on the shipped constants β a genome can breed a
// twitchy reactor or a stubborn one β and both default to 1, i.e. exactly today's behaviour.
const strategy = strategyFor(state, owner);
const band = ADAPT_DEAD_BAND * (strategy.adaptBandMult ?? 1);
const rate = ADAPT_RATE * (strategy.adaptRateMult ?? 1);
if (Math.abs(gap) <= band) { controller.adaptMode = cur; return cur; }
const step = Math.min(Math.abs(gap), rate * adaptivity) * (gap < 0 ? -1 : 1);
const next = Math.min(1, Math.max(0, cur + step));
controller.adaptMode = next;
return next;
}
// How far the mode is allowed to swing static defence. 0.6x against a visibly greedy opponent
// (turrets are ore not spent on punishing them) up to 1.6x against one that is massing (wall up).
// Bounded deliberately: adaptation changes TEMPO AND INVESTMENT, never identity β a Rusher that
// adapts into a turtle is not a Rusher, and the archetype has to stay recognisable.
const DEFENCE_SWING = 0.6;
/**
* The multiplier the adapted stance applies to static defence, composed onto the archetype's own
* turretCount and the strategy's turretCountMult exactly like every other layer in this engine.
* 1 at neutral, so an AI with no opinion β and every Easy AI, pinned at neutral β is byte-identical
* to one that never had a mode at all.
* @param {State} state @param {string} [owner] @returns {number}
*/
export function adaptDefenceMult(state, owner = "ai") {
const controller = owner === "ai" ? state.ai : state.playerAi;
const mode = controller && controller.adaptMode != null ? controller.adaptMode : ADAPT_NEUTRAL;
const strategy = strategyFor(state, owner);
const swing = DEFENCE_SWING * (strategy.defenceSwingMult ?? 1);
return 1 + (mode - ADAPT_NEUTRAL) * 2 * swing;
}
|