diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ffee926..9fa60d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export * from './sim/types.js'; export * from './sim/ruleset.js'; export * from './sim/sim.js'; export * from './sim/ai.js'; +export * from './sim/vision.js'; // Client<->server wire protocol (shared by @bships/server and @bships/client). export * from './protocol.js'; diff --git a/packages/core/src/sim/ai.ts b/packages/core/src/sim/ai.ts index 2129f76..f49cf95 100644 --- a/packages/core/src/sim/ai.ts +++ b/packages/core/src/sim/ai.ts @@ -70,6 +70,7 @@ import type { TeamId, } from './types.js'; import { enemyTeam, sortedNumericKeys } from './types.js'; +import { isVisibleToTeamFog } from './vision.js'; import type { AiMemory } from './types.js'; // --------------------------------------------------------------------------- @@ -308,7 +309,7 @@ export function computeAiCommands( // is the core of the AI-mirror stalemate (both sides disengage at 40% and // full-heal forever). Floored at half the retreat threshold so a bot still // flees a losing race at deep HP. Deterministic scan, no rng. - const weakestFoe = weakestEnemyShipFraction(state, ship, team); + const weakestFoe = weakestEnemyShipFraction(state, ruleset, ship, team); const killCommit = weakestFoe !== null && weakestFoe < hpFraction && @@ -477,7 +478,7 @@ export function computeAiCommands( let targetX = laneCorridorX(ruleset, laneId, enemyHq.x); let targetY = enemyHq.y; if (rng.next() < tuning.microQuality) { - const target = pickCombatTarget(state, ship, team); + const target = pickCombatTarget(state, ruleset, ship, team); if (target) { // Step from the ship through the target and a little beyond, toward the // HQ, so the attack-move advances through the brawl rather than stalling. @@ -492,7 +493,7 @@ export function computeAiCommands( // takes incidental chip and never falls). Targeting the structure // directly (not the distant HQ point) is what makes carried weapons fire // at it. Runs for ALL difficulties so every match can end. - const siege = pickSiegeTarget(state, ship, team); + const siege = pickSiegeTarget(state, ruleset, ship, team); if (siege) { targetX = siege.x; targetY = siege.y; @@ -502,9 +503,9 @@ export function computeAiCommands( // Below the micro gate the bot still sieges when no fight is nearby — the // micro gate only governs the finer "aim past the brawl" step, not whether // the bot bothers to attack the structures blocking its push. - const near = pickCombatTarget(state, ship, team); + const near = pickCombatTarget(state, ruleset, ship, team); if (!near) { - const siege = pickSiegeTarget(state, ship, team); + const siege = pickSiegeTarget(state, ruleset, ship, team); if (siege) { targetX = siege.x; targetY = siege.y; @@ -1090,6 +1091,7 @@ function maybeResearch( */ function pickCombatTarget( state: SimState, + ruleset: Ruleset, ship: ShipEntity, team: TeamId, ): Combatant | null { @@ -1104,7 +1106,7 @@ function pickCombatTarget( const e = state.entities[id]; if (!e || (e.kind !== 'ship' && e.kind !== 'creep')) continue; if (e.dead || e.team === null || e.team === team) continue; - if (!visibleToTeam(e, team)) continue; + if (!visibleToTeam(state, ruleset, e, team)) continue; const d = dist(ship.x, ship.y, e.x, e.y); if (d > radius) continue; if (e.kind === 'ship') { @@ -1147,12 +1149,12 @@ const FINISH_HP_FRACTION = 0.45; * none. Feeds the KILL-COMMIT retreat suppression (see the stance block). * Ascending-id scan; no rng. */ -function weakestEnemyShipFraction(state: SimState, ship: ShipEntity, team: TeamId): number | null { +function weakestEnemyShipFraction(state: SimState, ruleset: Ruleset, ship: ShipEntity, team: TeamId): number | null { let weakest: number | null = null; for (const id of sortedNumericKeys(state.entities)) { const e = state.entities[id]; if (!e || e.kind !== 'ship' || e.dead || e.team === null || e.team === team) continue; - if (!visibleToTeam(e, team)) continue; + if (!visibleToTeam(state, ruleset, e, team)) continue; if (dist(ship.x, ship.y, e.x, e.y) > AGGRO_TARGET_RADIUS) continue; const frac = e.maxHp > 0 ? e.hp / e.maxHp : 1; if (weakest === null || frac < weakest) weakest = frac; @@ -1181,6 +1183,7 @@ const SIEGE_TARGET_RADIUS = 2200; */ function pickSiegeTarget( state: SimState, + ruleset: Ruleset, ship: ShipEntity, team: TeamId, ): StructureEntity | null { @@ -1193,7 +1196,7 @@ function pickSiegeTarget( if (!e || e.kind !== 'structure' || e.dead) continue; if (e.team === null || e.team === team) continue; // own/neutral: skip if (e.role !== 'tower' && e.role !== 'hq') continue; - if (!visibleToTeam(e, team)) continue; + if (!visibleToTeam(state, ruleset, e, team)) continue; const d = dist(ship.x, ship.y, e.x, e.y); if (d > SIEGE_TARGET_RADIUS) continue; if (e.role === 'tower') { @@ -1218,9 +1221,14 @@ function pickSiegeTarget( */ const ENGAGE_PUSH_THROUGH = 400; -/** A team's vision over an entity, the way a human's targeting sees it. */ -function visibleToTeam(target: Entity, team: TeamId): boolean { - return 'vision' in target ? target.vision[team] : true; +/** + * A team's vision over an entity, the way a human's targeting sees it: + * invisibility flags AND the shared sight-circle fog (vision.ts) — the bot + * may only react to what its team actually sees (owner-reported fix: the AI + * used to see through the fog of war). Memoized per (state, tick). + */ +function visibleToTeam(state: SimState, ruleset: Ruleset, target: Entity, team: TeamId): boolean { + return isVisibleToTeamFog(state, ruleset, target, team); } // --- Use abilities: learn a sensible hero build + cast offensive skills ------- @@ -1299,7 +1307,7 @@ function maybeCastOffensive( for (const id of sortedNumericKeys(state.entities)) { const e = state.entities[id]; if (!e || e.dead || e.team === null || e.team === team) continue; - if (!visibleToTeam(e, team)) continue; + if (!visibleToTeam(state, ruleset, e, team)) continue; const d = dist(ship.x, ship.y, e.x, e.y); if (d > ABILITY_CAST_RADIUS) continue; if (e.kind === 'ship') { diff --git a/packages/core/src/sim/combat.ts b/packages/core/src/sim/combat.ts index 81e4e42..0507243 100644 --- a/packages/core/src/sim/combat.ts +++ b/packages/core/src/sim/combat.ts @@ -61,6 +61,7 @@ import { dist } from '../math.js'; import { breakInvisibilityOnAction } from './specials.js'; +import { isVisibleToTeamFog } from './vision.js'; import { allocEntityId, isCombatant, @@ -196,8 +197,16 @@ function armorFactor(ruleset: Ruleset, armor: number): number { // Target validity // --------------------------------------------------------------------------- -function visibleToTeam(target: Entity, team: TeamId): boolean { - return 'vision' in target ? target.vision[team] : true; +/** + * Team-fog target validity (owner-reported fix: weapons could acquire + * targets INTO the fog — ranges reach 2500u vs 1800u max sight, and the + * invisibility flags alone gated nothing at distance). A unit may only + * acquire/cast at what its TEAM currently sees: the sim-owned invisibility + * flag AND the shared sight-circle fog (structures are public map + * knowledge). Memoized per (state, tick) — see vision.ts. + */ +function visibleToTeam(state: SimState, ruleset: Ruleset, target: Entity, team: TeamId): boolean { + return isVisibleToTeamFog(state, ruleset, target, team); } function matchesFilter(filter: TargetFilter, target: Combatant): boolean { @@ -248,7 +257,7 @@ function isValidWeaponTarget( if (weapon.rangeUnits !== null && dist(x, y, target.x, target.y) > weapon.rangeUnits) { return false; } - return visibleToTeam(target, team); + return visibleToTeam(state, ruleset, target, team); } // --------------------------------------------------------------------------- @@ -624,7 +633,7 @@ export function castStormBolt( ) { return fail('invalidTarget'); } - if (!visibleToTeam(target, caster.team)) return fail('targetNotVisible'); + if (!visibleToTeam(state, ruleset, target, caster.team)) return fail('targetNotVisible'); if ( weapon.rangeUnits !== null && dist(caster.x, caster.y, target.x, target.y) > weapon.rangeUnits @@ -737,7 +746,7 @@ function selectAttackTarget( target.team !== team && matchesFilter(attack.targets, target) && !isInvulnerableTarget(state, ruleset, target) && - visibleToTeam(target, team) && + visibleToTeam(state, ruleset, target, team) && dist(attacker.x, attacker.y, target.x, target.y) <= attack.rangeUnits; if (isUnitEntity(attacker)) { const order = attacker.order; diff --git a/packages/core/src/sim/movement.ts b/packages/core/src/sim/movement.ts index aeb3d1d..e7c5c61 100644 --- a/packages/core/src/sim/movement.ts +++ b/packages/core/src/sim/movement.ts @@ -792,7 +792,7 @@ const DOCK_APPROACH_LOCAL_GOAL_CELLS = 1; * coast-hugging start is not a false positive. On a stub mask (isWater always * true) this is always false — open-sea movement is unchanged. Pure arithmetic * + isWater: deterministic. */ -function segmentCrossesLand(mask: WaterMask, x0: number, y0: number, x1: number, y1: number): boolean { +export function segmentCrossesLand(mask: WaterMask, x0: number, y0: number, x1: number, y1: number): boolean { const dx = x1 - x0; const dy = y1 - y0; const len = Math.sqrt(dx * dx + dy * dy); diff --git a/packages/core/src/sim/specials.ts b/packages/core/src/sim/specials.ts index 025f524..3ce30c0 100644 --- a/packages/core/src/sim/specials.ts +++ b/packages/core/src/sim/specials.ts @@ -94,6 +94,7 @@ import { rollInt, sortedNumericKeys, } from './types.js'; +import { GEM_TRUE_SIGHT_ITEM_ID, GEM_TRUE_SIGHT_RADIUS, invalidateVisionMemo } from './vision.js'; import type { AbilitySpec, CastAbilityCommand, @@ -140,8 +141,6 @@ const REPAIR_BAY_SERVICE_TICKS = 30; * stock gemt radius live here as PROVISIONAL constants (SEMANTICS §5, * confidence medium) — open question to move into the Ruleset. */ -const GEM_TRUE_SIGHT_ITEM_ID = 'I00F'; -const GEM_TRUE_SIGHT_RADIUS = 900; /** * Warhead WeaponSpecs always carry a projectile speed (dummy umvs 200-400); @@ -194,10 +193,13 @@ interface DetectorPoint { /** * Recompute entity.vision for every unit from invisibility statuses, * detectors and detection zones. Exported for tests; stepSpecials calls it - * after all status/position changes of this phase. Fog-of-war is not - * modeled — a non-invisible unit is visible to both teams. + * after all status/position changes of this phase. These flags track ONLY + * invisibility-vs-detection; the sight-radius FOG layer is vision.ts + * (composed on top by combat targeting, the AI scans and the server + * snapshot filter). */ export function recomputeVisibility(state: SimState, ruleset: Ruleset): void { + invalidateVisionMemo(state); // fresh fog verdicts for the combat phase const detectors: Record = { south: [], north: [] }; for (const id of sortedNumericKeys(state.entities)) { diff --git a/packages/core/src/sim/vision.ts b/packages/core/src/sim/vision.ts new file mode 100644 index 0000000..b5652c9 --- /dev/null +++ b/packages/core/src/sim/vision.ts @@ -0,0 +1,268 @@ +/** + * Per-team sight-radius FOG OF WAR — one shared model for every consumer. + * + * The sim's `entity.vision` flags cover ONLY invisibility-vs-detection + * (specials.recomputeVisibility). This module adds the fog layer on top: + * which points/entities a TEAM can currently see with its live units' sight + * radii + its active detection zones. It was born in packages/server + * (snapshot filtering — the security boundary) and moved here so the SIM + * plays by the same rules the humans see (owner-reported 2026-07-09: "the AI + * seems to see through fog of war" — it did; the AI brain and the auto-fire + * acquisition consulted only the invisibility flags, and weapon ranges reach + * 2500u while sight tops out at 1800u, so long-range weapons sniped INTO the + * fog. In WC3 a unit cannot acquire a target its owner cannot see — long + * -range fire needs a spotter). + * + * Consumers: + * - packages/server snapshot fog (via the re-exporting server/visibility.ts). + * - combat.ts auto-fire / cast / stop-to-engage target validity. + * - ai.ts enemy-unit scans (aggro, kill-commit, offensive casts). + * Structures are ALWAYS visible (placement is public map knowledge — the + * documented v1 divergence), so structure scans stay ungated. + * + * `teamVisionOf` memoizes per (state, tick): recomputing the circles is O(n) + * and several systems ask for them each tick. The memo is a WeakMap keyed on + * the SimState object, never stored IN the state — a pure derivation, so + * hashState/replays are untouched (same pattern as movement.ts fieldToPoint). + */ + +import { sortedNumericKeys } from './types.js'; +import { segmentCrossesLand } from './movement.js'; +import type { Entity, Projectile, Ruleset, SimState, TeamId, WaterMask } from './types.js'; + +/** + * Goblin Scout Crew carrier true sight (usable item 'gemt' analogue) — + * PROVISIONAL constants (SEMANTICS §5, confidence medium; open question to + * move into the Ruleset). Canonical home: this module; specials.ts imports + * them for its detector collection. + */ +export const GEM_TRUE_SIGHT_ITEM_ID = 'I00F'; +export const GEM_TRUE_SIGHT_RADIUS = 900; + +/** One circular vision/detection source in world units. */ +export interface SightCircle { + x: number; + y: number; + radius: number; +} + +/** Everything team T can see with, recomputed from scratch each tick. */ +export interface TeamVision { + readonly team: TeamId; + /** Fog sight sources: live friendly entities + the team's detection zones. */ + readonly sight: readonly SightCircle[]; + /** True-sight sources (mirrors specials.ts' detector collection). */ + readonly detectors: readonly SightCircle[]; + /** + * The land mask for line-of-sight blocking: BSP's land is CLIFFS + * (owner-confirmed 2026-07-09: "you shouldn't be able to see over the land + * to the other side — they were mountains/cliffs in the original"), so a + * sight circle does NOT penetrate land. The open-sea stub mask (no cells) + * makes LOS a no-op, keeping legacy open-water tests unchanged. + */ + readonly mask: WaterMask | undefined; +} + +/** True when (x, y) lies inside any of the circles (inclusive boundary), + * ignoring land line-of-sight — the raw radius test. Prefer `coveredSight` + * for anything gameplay-visible. */ +export function coveredBy(circles: readonly SightCircle[], x: number, y: number): boolean { + for (const c of circles) { + const dx = c.x - x; + const dy = c.y - y; + if (dx * dx + dy * dy <= c.radius * c.radius) return true; + } + return false; +} + +/** + * True when (x, y) is inside some circle AND the sight line from that + * circle's center does not cross land (cliffs block vision). The radius test + * runs first so the segment sampling only happens for nearby sources. + */ +export function coveredSight( + circles: readonly SightCircle[], + mask: WaterMask | undefined, + x: number, + y: number, +): boolean { + // No real mask (open-sea stub, or a synthetic test ruleset without a map): + // LOS is a no-op and coverage is the plain radius test. + const los = mask !== undefined && mask.cells.length > 0; + for (const c of circles) { + const dx = c.x - x; + const dy = c.y - y; + if (dx * dx + dy * dy > c.radius * c.radius) continue; + if (!los || !segmentCrossesLand(mask, c.x, c.y, x, y)) return true; + } + return false; +} + +/** + * Collect team T's sight sources and true-sight detectors from the current + * state. Ascending entity-id iteration for determinism (output order never + * affects the boolean results, but keeps payload diffs reproducible). + */ +export function computeTeamVision(state: SimState, ruleset: Ruleset, team: TeamId): TeamVision { + const sight: SightCircle[] = []; + const detectors: SightCircle[] = []; + + for (const id of sortedNumericKeys(state.entities)) { + const e = state.entities[id]; + if (!e || e.dead || e.team !== team) continue; + + if (e.kind === 'ward') { + // Expired-but-not-yet-removed wards see nothing (mirrors specials). + if (e.expiresAtTick !== null && state.tick >= e.expiresAtTick) continue; + if (e.sightRadius > 0) sight.push({ x: e.x, y: e.y, radius: e.sightRadius }); + if (e.detectionRadius !== null && e.detectionRadius > 0) { + detectors.push({ x: e.x, y: e.y, radius: e.detectionRadius }); + } + continue; + } + + if (e.kind === 'ship') { + // Submerged subs swap typeId, but both forms live in ruleset.ships. + const spec = ruleset.ships[e.typeId]; + const sightRadius = spec?.sightRadius ?? 0; + if (sightRadius > 0) sight.push({ x: e.x, y: e.y, radius: sightRadius }); + const detectionRadius = spec?.detectionRadius ?? null; + if (detectionRadius !== null && detectionRadius > 0) { + detectors.push({ x: e.x, y: e.y, radius: detectionRadius }); + } + // Carrier true sight (Goblin Scout Crew) — constants shared with + // specials.ts' detector collection. + const player = state.players[e.owner]; + if (player?.inventory.some((item) => item !== null && item.itemId === GEM_TRUE_SIGHT_ITEM_ID)) { + detectors.push({ x: e.x, y: e.y, radius: GEM_TRUE_SIGHT_RADIUS }); + } + } else { + // creep / structure / summon + const spec = ruleset.unitTypes[e.typeId]; + const sightRadius = spec?.sightRadius ?? 0; + if (sightRadius > 0) sight.push({ x: e.x, y: e.y, radius: sightRadius }); + const detectionRadius = spec?.detectionRadius ?? null; + if (detectionRadius !== null && detectionRadius > 0) { + detectors.push({ x: e.x, y: e.y, radius: detectionRadius }); + } + } + } + + for (const zone of state.detectionZones) { + if (zone.team === team && zone.expiresAtTick > state.tick) { + sight.push({ x: zone.x, y: zone.y, radius: zone.radius }); + detectors.push({ x: zone.x, y: zone.y, radius: zone.radius }); + } + } + + return { team, sight, detectors, mask: ruleset.map?.waterMask }; +} + +/** Per-(state, tick) memo of both teams' vision — see the module doc. The + * per-entity verdict map exists because combat re-validates the same + * candidates per weapon per tick and the AI re-scans them per think: the + * circles + LOS math runs ONCE per (entity, team, tick). Entities do not + * move within a tick after specials ran, so the verdict is stable. */ +interface VisionMemo { + tick: number; + byTeam: Partial>; + verdicts: Partial>>; +} + +const VISION_MEMO = new WeakMap(); + +function memoOf(state: SimState): VisionMemo { + let memo = VISION_MEMO.get(state); + if (memo === undefined || memo.tick !== state.tick) { + memo = { tick: state.tick, byTeam: {}, verdicts: {} }; + VISION_MEMO.set(state, memo); + } + return memo; +} + +/** + * Drop the memo for this state — called by specials.recomputeVisibility after + * the movement phase settled positions, so combat (which runs next) always + * judges fog on THIS tick's positions rather than reusing verdicts the + * pre-step AI thinks computed on last tick's. Deterministic: the invalidation + * point is a fixed phase boundary of stepTick. + */ +export function invalidateVisionMemo(state: SimState): void { + VISION_MEMO.delete(state); +} + +/** Memoized computeTeamVision for the state's CURRENT tick. */ +export function teamVisionOf(state: SimState, ruleset: Ruleset, team: TeamId): TeamVision { + const memo = memoOf(state); + let vision = memo.byTeam[team]; + if (vision === undefined) { + vision = computeTeamVision(state, ruleset, team); + memo.byTeam[team] = vision; + } + return vision; +} + +/** + * The inclusion rule (module doc above). Serves double duty: the server's + * snapshot security predicate AND the sim's targeting/AI fog gate — an + * entity for which this returns false must neither be SENT to the team nor + * ACTED ON by the team's units. + */ +export function isEntityVisible(vision: TeamVision, entity: Entity): boolean { + // Structures: placement is public map knowledge (documented v1 divergence: + // live HP is sent too). + if (entity.kind === 'structure') return true; + if (entity.team === vision.team) return true; + + if (entity.kind === 'ward') { + if (entity.invisible && !coveredSight(vision.detectors, vision.mask, entity.x, entity.y)) { + return false; + } + return coveredSight(vision.sight, vision.mask, entity.x, entity.y); + } + + // Enemy ship/creep/summon: sim-owned invisibility flag AND fog check + // (radius + cliffs line of sight). + if (!entity.vision[vision.team]) return false; + return coveredSight(vision.sight, vision.mask, entity.x, entity.y); +} + +/** Memoized one-call form of the fog gate for sim-internal consumers: the + * full circles + LOS math runs once per (entity, team, tick). */ +export function isVisibleToTeamFog( + state: SimState, + ruleset: Ruleset, + target: Entity, + team: TeamId, +): boolean { + const memo = memoOf(state); + let verdicts = memo.verdicts[team]; + if (verdicts === undefined) { + verdicts = new Map(); + memo.verdicts[team] = verdicts; + } + const cached = verdicts.get(target.id); + if (cached !== undefined) return cached; + const verdict = isEntityVisible(teamVisionOf(state, ruleset, team), target); + verdicts.set(target.id, verdict); + return verdict; +} + +/** All entities team T may receive this tick, ascending id order. */ +export function collectVisibleEntities(state: SimState, vision: TeamVision): Entity[] { + const out: Entity[] = []; + for (const id of sortedNumericKeys(state.entities)) { + const e = state.entities[id]; + if (!e || e.dead) continue; + if (isEntityVisible(vision, e)) out.push(e); + } + return out; +} + +/** Projectiles: own-team always, enemy only when inside sight range. */ +export function isProjectileVisible(vision: TeamVision, projectile: Projectile): boolean { + return ( + projectile.team === vision.team || + coveredSight(vision.sight, vision.mask, projectile.x, projectile.y) + ); +} diff --git a/packages/core/test/fog.test.ts b/packages/core/test/fog.test.ts new file mode 100644 index 0000000..c9fb66c --- /dev/null +++ b/packages/core/test/fog.test.ts @@ -0,0 +1,215 @@ +/** + * Fog of war + cliffs line-of-sight (owner-reported 2026-07-09: "the AI seems + * to see through fog of war" / "you shouldn't be able to see over the land — + * they were mountains/cliffs in the original"). + * + * Proves, on the REAL compiled ruleset + terrain: + * 1. A long-range weapon cannot ACQUIRE a target the owning team does not + * see (weapon range 2500u > max sight 1800u — no sniping into the fog), + * and a friendly spotter near the target restores acquisition. + * 2. Land blocks sight (cliffs): an enemy within sight RADIUS but across a + * land ridge is invisible to the team — and the AI's aggro/kill-commit + * scans ignore it. + * 3. The same enemy at the same distance over open water IS visible. + */ + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { compileClassicRuleset } from '../src/sim/ruleset.js'; +import { createMatch, stepTick } from '../src/sim/sim.js'; +import { computeAiCommands } from '../src/sim/ai.js'; +import { isVisibleToTeamFog, teamVisionOf } from '../src/sim/vision.js'; +import { segmentCrossesLand } from '../src/sim/movement.js'; +import { isWater } from '../src/sim/types.js'; +import type { RawDataFiles, Ruleset, ShipEntity, SimState } from '../src/sim/types.js'; + +function loadJson(name: string): T { + const url = new URL(`../../../data/json/${name}`, import.meta.url); + return JSON.parse(readFileSync(url, 'utf8')) as T; +} + +function loadRawWithTerrain(): RawDataFiles { + return { + weapons: loadJson('weapons.json'), + equipment: loadJson('equipment.json'), + ships: loadJson('ships.json'), + upgradeCurves: loadJson('upgrade-curves.json'), + scriptRules: loadJson('script-rules.json'), + mapLayout: loadJson('map-layout.json'), + gameplayConstants: loadJson('gameplay-constants.json'), + terrain: loadJson('terrain.json'), + units: loadJson('units.json'), + abilities: loadJson('abilities.json'), + items: loadJson('items.json'), + buffs: loadJson('buffs.json'), + strings: loadJson('strings.json'), + }; +} + +const ruleset: Ruleset = compileClassicRuleset(loadRawWithTerrain()); +const SOUTH = 2; +const SOUTH_SPOTTER = 3; +const NORTH = 7; + +function shipOf(state: SimState, slot: number): ShipEntity { + const player = state.players[slot]; + if (!player || player.shipId === null) throw new Error(`no ship for slot ${slot}`); + const ship = state.entities[player.shipId]; + if (!ship || ship.kind !== 'ship') throw new Error(`slot ${slot} entity is not a ship`); + return ship; +} + +/** The longest-ranged auto-fire item weapon (Sniper Crew, 2500u). Item + * weapons are keyed by item id in ruleset.weapons and auto-fire via the + * phoenixFire inventory scan. */ +function sniperItemId(): { itemId: string; range: number } { + let best: { itemId: string; range: number } | null = null; + for (const [itemId, w] of Object.entries(ruleset.weapons)) { + if (w.mechanic !== 'phoenixFire' || w.rangeUnits === null) continue; + if (!w.targets.ships) continue; + if (best === null || w.rangeUnits > best.range) best = { itemId, range: w.rangeUnits }; + } + if (!best) throw new Error('no ranged auto-fire item weapons compiled'); + return best; +} + +/** Find a water-cell pair: dist in [min,max], LOS blocked (or clear) as asked. */ +function findPair(blocked: boolean, minD: number, maxD: number): { a: { x: number; y: number }; b: { x: number; y: number } } { + const mask = ruleset.map.waterMask; + const { cols, rows, cellSizeX, cellSizeY, bounds, cells } = mask; + const world = (c: number, r: number) => ({ + x: bounds.minX + (c + 0.5) * cellSizeX, + y: bounds.maxY - (r + 0.5) * cellSizeY, + }); + for (let r = 2; r < rows - 2; r += 2) { + for (let c = 2; c < cols - 2; c += 2) { + if (cells[r * cols + c] !== 1) continue; + const a = world(c, r); + const reach = Math.ceil(maxD / 128) + 1; + for (let dr = -reach; dr <= reach; dr += 2) { + for (let dc = -reach; dc <= reach; dc += 2) { + const nc = c + dc; + const nr = r + dr; + if (nc < 2 || nc >= cols - 2 || nr < 2 || nr >= rows - 2) continue; + if (cells[nr * cols + nc] !== 1) continue; + const b = world(nc, nr); + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < minD || d > maxD) continue; + if (segmentCrossesLand(mask, a.x, a.y, b.x, b.y) === blocked) return { a, b }; + } + } + } + } + throw new Error(`no ${blocked ? 'blocked' : 'clear'} pair in [${minD}, ${maxD}]`); +} + +function makeState(): SimState { + return createMatch(ruleset, 42, [ + { slot: SOUTH, control: 'user' }, + { slot: SOUTH_SPOTTER, control: 'user' }, + { slot: NORTH, control: 'user' }, + ]); +} + +/** Park a ship at (x, y) with an idle order. */ +function park(ship: ShipEntity, x: number, y: number): void { + ship.x = x; + ship.y = y; + ship.order = { type: 'idle' }; +} + +const FAR_CORNER = { x: -5000, y: -7000 }; // out-of-the-way water for the spotter + +describe('fog of war: no acquisition without team sight', () => { + it('a 2500u sniper does NOT fire at an enemy 2000u away in the fog; a spotter restores it', () => { + const sniper = sniperItemId(); + expect(sniper.range).toBeGreaterThanOrEqual(2000); + + const clear = findPair(false, 1900, 2100); + const state = makeState(); + const me = shipOf(state, SOUTH); + const spotter = shipOf(state, SOUTH_SPOTTER); + const foe = shipOf(state, NORTH); + park(me, clear.a.x, clear.a.y); + park(foe, clear.b.x, clear.b.y); + park(spotter, FAR_CORNER.x, FAR_CORNER.y); // far away: no spotting yet + const player = state.players[SOUTH]; + if (!player) throw new Error('no south player'); + player.inventory[0] = { itemId: sniper.itemId, charges: null, readyAtTick: 0 }; + + // Sanity: the foe is beyond every south sight radius but within weapon range. + const dist = Math.hypot(me.x - foe.x, me.y - foe.y); + expect(dist).toBeGreaterThan(1800); + expect(dist).toBeLessThan(sniper.range); + expect(isVisibleToTeamFog(state, ruleset, foe, 'south')).toBe(false); + + let hits = 0; + for (let t = 0; t < 60; t++) { + for (const ev of stepTick(state, ruleset)) { + if (ev.type === 'hit' && ev.targetEntityId === foe.id) hits++; + } + } + expect(hits).toBe(0); // no sniping into the fog + + // Sail the spotter next to the foe: the team now SEES it -> fire resumes. + park(spotter, foe.x + 400, foe.y); + expect(isVisibleToTeamFog(state, ruleset, foe, 'south')).toBe(true); + for (let t = 0; t < 120 && hits === 0; t++) { + for (const ev of stepTick(state, ruleset)) { + if (ev.type === 'hit' && ev.targetEntityId === foe.id) hits++; + } + } + expect(hits).toBeGreaterThan(0); // spotted -> the sniper fires + }); +}); + +describe('cliffs: land blocks line of sight', () => { + it('an enemy across a land ridge is invisible even inside the sight radius', () => { + const blocked = findPair(true, 500, 900); + const state = makeState(); + const me = shipOf(state, SOUTH); + const foe = shipOf(state, NORTH); + park(me, blocked.a.x, blocked.a.y); + park(foe, blocked.b.x, blocked.b.y); + park(shipOf(state, SOUTH_SPOTTER), FAR_CORNER.x, FAR_CORNER.y); + stepTick(state, ruleset); // refresh vision flags/memo tick + + const d = Math.hypot(me.x - foe.x, me.y - foe.y); + expect(d).toBeLessThan(1100); // well inside a start ship's sight radius + expect(isWater(ruleset.map.waterMask, foe.x, foe.y)).toBe(true); + expect(isVisibleToTeamFog(state, ruleset, foe, 'south')).toBe(false); // cliffs block + + // Same distance over OPEN water at the clear pair -> visible. + const clear = findPair(false, 500, 900); + park(me, clear.a.x, clear.a.y); + park(foe, clear.b.x, clear.b.y); + stepTick(state, ruleset); + expect(isVisibleToTeamFog(state, ruleset, foe, 'south')).toBe(true); + }); + + it('the AI aggro scan ignores a wounded enemy hiding behind the ridge', () => { + const blocked = findPair(true, 500, 900); + const state = createMatch(ruleset, 42, [ + { slot: SOUTH, control: 'computer', ai: { difficulty: 'hard' } }, + { slot: NORTH, control: 'user' }, + ]); + const bot = shipOf(state, SOUTH); + const foe = shipOf(state, NORTH); + park(bot, blocked.a.x, blocked.a.y); + park(foe, blocked.b.x, blocked.b.y); + foe.hp = Math.floor(foe.maxHp * 0.2); // juicy kill-commit bait + stepTick(state, ruleset); + + const mem = state.aiMemory[SOUTH]; + if (!mem) throw new Error('no ai memory for the bot'); + mem.nextThinkTick = state.tick; // force a think now + const commands = computeAiCommands(state, ruleset, SOUTH, mem); + // The bot must not target the ship it cannot see (attackTarget on foe.id). + const targeted = commands.some((c) => c.type === 'attackTarget' && c.targetId === foe.id); + expect(targeted).toBe(false); + + // Vision-model cross-check: south team sight does not cover the foe. + expect(teamVisionOf(state, ruleset, 'south').sight.length).toBeGreaterThan(0); + expect(isVisibleToTeamFog(state, ruleset, foe, 'south')).toBe(false); + }); +}); diff --git a/packages/server/src/snapshot.ts b/packages/server/src/snapshot.ts index 72c6030..d22182a 100644 --- a/packages/server/src/snapshot.ts +++ b/packages/server/src/snapshot.ts @@ -24,7 +24,7 @@ import type { Status, TeamId, } from '@bships/core'; -import { computeTeamVision, collectVisibleEntities, coveredBy, isProjectileVisible } from './visibility.js'; +import { computeTeamVision, collectVisibleEntities, coveredSight, isProjectileVisible } from './visibility.js'; import type { TeamVision } from './visibility.js'; /** Round to 0.1 (display precision; shrinks payloads). */ @@ -268,7 +268,7 @@ export function filterEventsForSeat( if ( playerTeam(ev.victimPlayer) === team || visibleIds.has(ev.entityId) || - coveredBy(vision.sight, ev.x, ev.y) + coveredSight(vision.sight, vision.mask, ev.x, ev.y) ) { out.push(ev); break; diff --git a/packages/server/src/visibility.ts b/packages/server/src/visibility.ts index cdfafa2..642cbbd 100644 --- a/packages/server/src/visibility.ts +++ b/packages/server/src/visibility.ts @@ -1,156 +1,21 @@ /** * Per-team sight-radius fog of war — THE security boundary of the server. * - * The sim's `entity.vision` flags cover ONLY invisibility-vs-detection - * (specials.recomputeVisibility: "Fog-of-war is not modeled — a non-invisible - * unit is visible to both teams"). This module adds the fog layer on top, - * per team, each tick: - * - * - Sight sources of team T: every live entity of team T (sightRadius from - * ShipSpec / UnitTypeSpec / WardEntity.sightRadius) plus T's active - * `state.detectionZones` (flares grant area vision). - * - Structures: ALWAYS included for both teams — placement is public map - * knowledge. Live HP rides along: an accepted v1 divergence from WC3 fog - * memory (the original shows the last-seen state of a fogged building). - * - Own-team units/wards/summons: always included. - * - Enemy units (ship/creep/summon): included iff `entity.vision[T]` - * (invisibility check, sim-owned) AND inside some T sight source. - * - Enemy wards: included iff (!ward.invisible OR covered by a T detector — - * mirrors specials' detector collection) AND inside T sight range. - * - Projectiles: included iff own-team OR inside T sight range. - * - Ground items: not in snapshots v1 (documented gap; pickups still work - * blind via quest regions). - */ - -import { sortedNumericKeys } from '@bships/core'; -import type { Entity, Projectile, Ruleset, SimState, TeamId } from '@bships/core'; - -/** One circular vision/detection source in world units. */ -export interface SightCircle { - x: number; - y: number; - radius: number; -} - -/** Everything team T can see with, recomputed from scratch each tick. */ -export interface TeamVision { - readonly team: TeamId; - /** Fog sight sources: live friendly entities + the team's detection zones. */ - readonly sight: readonly SightCircle[]; - /** True-sight sources (mirrors specials.ts' detector collection). */ - readonly detectors: readonly SightCircle[]; -} - -/** - * Goblin Scout Crew carrier true sight — MIRRORS the provisional constants - * in core/src/sim/specials.ts (GEM_TRUE_SIGHT_*, not exported there; open - * question to migrate into the Ruleset). Keep in sync. - */ -const GEM_TRUE_SIGHT_ITEM_ID = 'I00F'; -const GEM_TRUE_SIGHT_RADIUS = 900; - -/** True when (x, y) lies inside any of the circles (inclusive boundary). */ -export function coveredBy(circles: readonly SightCircle[], x: number, y: number): boolean { - for (const c of circles) { - const dx = c.x - x; - const dy = c.y - y; - if (dx * dx + dy * dy <= c.radius * c.radius) return true; - } - return false; -} - -/** - * Collect team T's sight sources and true-sight detectors from the current - * state. Ascending entity-id iteration for determinism (output order never - * affects the boolean results, but keeps payload diffs reproducible). + * The implementation moved into @bships/core (sim/vision.ts) so the SIM's + * own targeting/AI plays by the same fog rules this module enforces on + * snapshots (owner-reported: the AI used to see through the fog). This + * module re-exports the shared model under its original server-side path; + * the inclusion rule is unchanged: an entity for which `isEntityVisible` + * returns false MUST NOT appear anywhere in the team's snapshot payload. */ -export function computeTeamVision(state: SimState, ruleset: Ruleset, team: TeamId): TeamVision { - const sight: SightCircle[] = []; - const detectors: SightCircle[] = []; - - for (const id of sortedNumericKeys(state.entities)) { - const e = state.entities[id]; - if (!e || e.dead || e.team !== team) continue; - - if (e.kind === 'ward') { - // Expired-but-not-yet-removed wards see nothing (mirrors specials). - if (e.expiresAtTick !== null && state.tick >= e.expiresAtTick) continue; - if (e.sightRadius > 0) sight.push({ x: e.x, y: e.y, radius: e.sightRadius }); - if (e.detectionRadius !== null && e.detectionRadius > 0) { - detectors.push({ x: e.x, y: e.y, radius: e.detectionRadius }); - } - continue; - } - - if (e.kind === 'ship') { - // Submerged subs swap typeId, but both forms live in ruleset.ships. - const spec = ruleset.ships[e.typeId]; - const sightRadius = spec?.sightRadius ?? 0; - if (sightRadius > 0) sight.push({ x: e.x, y: e.y, radius: sightRadius }); - const detectionRadius = spec?.detectionRadius ?? null; - if (detectionRadius !== null && detectionRadius > 0) { - detectors.push({ x: e.x, y: e.y, radius: detectionRadius }); - } - // Carrier true sight (Goblin Scout Crew) — see constant doc above. - const player = state.players[e.owner]; - if (player?.inventory.some((item) => item !== null && item.itemId === GEM_TRUE_SIGHT_ITEM_ID)) { - detectors.push({ x: e.x, y: e.y, radius: GEM_TRUE_SIGHT_RADIUS }); - } - } else { - // creep / structure / summon - const spec = ruleset.unitTypes[e.typeId]; - const sightRadius = spec?.sightRadius ?? 0; - if (sightRadius > 0) sight.push({ x: e.x, y: e.y, radius: sightRadius }); - const detectionRadius = spec?.detectionRadius ?? null; - if (detectionRadius !== null && detectionRadius > 0) { - detectors.push({ x: e.x, y: e.y, radius: detectionRadius }); - } - } - } - - for (const zone of state.detectionZones) { - if (zone.team === team && zone.expiresAtTick > state.tick) { - sight.push({ x: zone.x, y: zone.y, radius: zone.radius }); - detectors.push({ x: zone.x, y: zone.y, radius: zone.radius }); - } - } - - return { team, sight, detectors }; -} - -/** - * The inclusion rule (module doc above). This is the security predicate — - * an entity for which this returns false MUST NOT appear anywhere in the - * team's snapshot payload. - */ -export function isEntityVisible(vision: TeamVision, entity: Entity): boolean { - // Structures: placement is public map knowledge (documented v1 divergence: - // live HP is sent too). - if (entity.kind === 'structure') return true; - if (entity.team === vision.team) return true; - - if (entity.kind === 'ward') { - if (entity.invisible && !coveredBy(vision.detectors, entity.x, entity.y)) return false; - return coveredBy(vision.sight, entity.x, entity.y); - } - - // Enemy ship/creep/summon: sim-owned invisibility flag AND fog check. - if (!entity.vision[vision.team]) return false; - return coveredBy(vision.sight, entity.x, entity.y); -} - -/** All entities team T may receive this tick, ascending id order. */ -export function collectVisibleEntities(state: SimState, vision: TeamVision): Entity[] { - const out: Entity[] = []; - for (const id of sortedNumericKeys(state.entities)) { - const e = state.entities[id]; - if (!e || e.dead) continue; - if (isEntityVisible(vision, e)) out.push(e); - } - return out; -} -/** Projectiles: own-team always, enemy only when inside sight range. */ -export function isProjectileVisible(vision: TeamVision, projectile: Projectile): boolean { - return projectile.team === vision.team || coveredBy(vision.sight, projectile.x, projectile.y); -} +export { + computeTeamVision, + coveredSight, + teamVisionOf, + coveredBy, + isEntityVisible, + collectVisibleEntities, + isProjectileVisible, +} from '@bships/core'; +export type { SightCircle, TeamVision } from '@bships/core';