Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
34 changes: 21 additions & 13 deletions packages/core/src/sim/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -1090,6 +1091,7 @@ function maybeResearch(
*/
function pickCombatTarget(
state: SimState,
ruleset: Ruleset,
ship: ShipEntity,
team: TeamId,
): Combatant | null {
Expand All @@ -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') {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1181,6 +1183,7 @@ const SIEGE_TARGET_RADIUS = 2200;
*/
function pickSiegeTarget(
state: SimState,
ruleset: Ruleset,
ship: ShipEntity,
team: TeamId,
): StructureEntity | null {
Expand All @@ -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') {
Expand All @@ -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 -------
Expand Down Expand Up @@ -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') {
Expand Down
19 changes: 14 additions & 5 deletions packages/core/src/sim/combat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@

import { dist } from '../math.js';
import { breakInvisibilityOnAction } from './specials.js';
import { isVisibleToTeamFog } from './vision.js';
import {
allocEntityId,
isCombatant,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/sim/movement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/sim/specials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<TeamId, DetectorPoint[]> = { south: [], north: [] };

for (const id of sortedNumericKeys(state.entities)) {
Expand Down
Loading
Loading