diff --git a/TODO.md b/TODO.md
index 477acdf7..177693e4 100644
--- a/TODO.md
+++ b/TODO.md
@@ -7,7 +7,7 @@ plans live under [docs/](docs/) (committed); the long-range direction is the str
keep it current when controls/features change. Last consolidated 2026-06-04.
**Build:** `scripts/build-client.ps1` (Windows) or `scripts/build-client.sh` (Linux) — publishes shared libs + bundled server + Unity player.
-**Test:** `./scripts/run-tests.sh` — currently **1413 server + 154 client passing** (2026-08-03). Locale parity (en/de) is enforced by a test.
+**Test:** `./scripts/run-tests.sh` — currently **1419 server + 154 client passing** (2026-08-03). Locale parity (en/de) is enforced by a test.
CI runs two tiers: PRs skip the tests marked `[Trait("Category", "Slow")]`; pushes to `main` and the release workflow run the full suite. CI builds/runs
tests in Release, and a per-test duration guardrail (`scripts/check-test-durations.py`, PRs only) fails the gate when a non-Slow test exceeds 120 s.
**Conventions:** English docs/comments; in-game text bilingual DE+EN; commit to `main` with the
@@ -102,6 +102,36 @@ Per-item detail lives in the dated work log below. **Since 2026-07 versions are
---
+### ★ Enemy health bars + real aiming: crosshair shots, AutoAim world rule, ship-weapon enforcement (#692, #693, #694, 2026-08-03, branch feat/health-bars-and-aiming — LOCAL, no PR)
+Combat never missed and never showed enemy health. **Health bars (#692):** every damageable entity
+(machines/drones/bandits, creatures incl. titans, space drones/UFOs/cruisers/bandit ships) draws a
+floating green→amber→red bar (companions: friendly cyan) while "in combat" (~6 s after a hull drop)
+or under the crosshair/fire lock — `ScreenLabelLayer` grew a pooled `WorldBar` primitive beside the
+nameplate pool, fed per-frame by `WorldEntities`/`CreatureView`/`SpaceView` through the shared policy
+in the new `EnemyHealthBars` helper (lerped fill, id cleanup on despawn). No protocol change —
+`Hull/HullMax` were already on the wire. Client toggle `ShowEnemyHealthBars` (comfort section,
+`ui.settings.show_enemy_health`). **Aiming (#693):** on foot, `AttackNearestEnemy` no longer picks
+the nearest target in a full 360° — the crosshair entity (analytic ray-vs-sphere over the replicated
+entities + a voxel terrain march; entity meshes have no colliders) always wins, auto-aim otherwise
+acquires only inside a ~±35° forward cone, and melee sweeps ~±60°. New world rule `GameRules.AutoAim`
+(default ON; `--auto-aim`, live admin row in the tech menu, `SetWorldRulesIntent.AutoAim`): OFF means
+only what's actually under the crosshair can be hit — misses fly a straight tracer into the terrain.
+In space the ±75° soft-lock tightened to ~±30° (AutoAim ON) or a true boresight ray (OFF). The
+crosshair tints hostile-red over a target and flashes a hit marker when your own shot lands (hull-drop
+attribution via `LastShotTargetId`). Server-authoritative: `AttackEntityIntent`/`FireWeaponIntent`
+carry the aim direction (contractless-additive — zero vector = old client = legacy behaviour), and the
+server validates angle (generous anti-cheat tolerances), ranged line-of-sight (`HasLineOfSight` — no
+shooting through walls) and a space firing arc. **Ship-weapon gaps (#694):** `weapon_cooldown` and
+`weapon_energy` from `data/ship_modules.json` are now enforced server-side (per-player cooldown map;
+lazily-regenerating reactor-fed energy pool — never throttles honest fire, drains modded spam), and
+the client reads range/cooldown from the fitted module instead of hardcoding 45/0.45 s — the
+`laser_cannon_2` finally uses its range 70. Existing worlds keep AutoAim ON automatically (missing
+JSON field deserializes to the default — no rules lift needed). Tests: 6 new `AimValidationTests`
+(legacy zero-dir, cone reject, manual crosshair line, wall block, space arc, server cooldown), CLI
+parse + live-edit coverage; fire-loop tests tick the cooldown. 1375 server + 147 client green.
+World-creation UI deliberately has NO AutoAim row (both columns are flush with the footer) — the
+live admin row covers it.
+
### ★ Space rocks come in sizes and flavors: seeded mineral families, water ice included (#687, 2026-08-03, branch feat/mini-asteroid-variety)
Every mineable space rock was the same clone: a fixed r=2 sphere with a titanium core and an
iron/copper/stone shell. Rocks now roll a seeded FAMILY — stony (~40 %), metallic (~25 %),
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/ClientSettings.cs b/client/Assets/BlocksBeyondTheStars/Scripts/ClientSettings.cs
index 0e47e051..0b4c2c08 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/ClientSettings.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/ClientSettings.cs
@@ -292,6 +292,10 @@ public sealed class ClientSettings
/// always shows until the tutorial is finished or skipped; this mutes the optional coaching.
public bool VegaHints = true;
+ /// Show floating health bars over enemies and creatures in combat (#692) — planet surface
+ /// and space flight alike. Purely cosmetic (the values are replicated either way); off hides them.
+ public bool ShowEnemyHealthBars = true;
+
// Comfort / wellbeing (playtime). Purely client-side: the session timer counts real wall-clock from
// the moment you enter a world; the reminder is VEGA gently suggesting a break (a real-world nudge, not
// an in-fiction event). Both default on but unobtrusive.
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/CraftingTechShipUI.cs b/client/Assets/BlocksBeyondTheStars/Scripts/CraftingTechShipUI.cs
index e3769961..b3ea12b7 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/CraftingTechShipUI.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/CraftingTechShipUI.cs
@@ -1531,6 +1531,20 @@ void RuleRow(string label, string current, System.Action send)
UiKit.AddText(keepShipBtn.transform, 560, 0, 200, 78, keepShip ? L("ui.toggle.on") : L("ui.toggle.off"), 22,
keepShip ? UiKit.Ok : UiKit.CyanDim, TextAnchor.MiddleLeft, FontStyle.Bold);
y += 96f;
+
+ // Auto-aim (world option, #693): when on (default) weapons acquire targets in a forward cone by
+ // themselves; when off only what is actually under the crosshair can be hit — for everyone in
+ // this world. The server enforces the admin gate and validates shots accordingly.
+ bool autoAim = rules?.AutoAim ?? true;
+ var autoAimBtn = UiKit.AddButton(_listContent, 0, y, 780, 78, string.Empty, () =>
+ {
+ Game?.Network?.SendSetWorldRules(autoAim: autoAim ? "Off" : "On");
+ Invoke(nameof(RebuildList), 0.35f);
+ });
+ UiKit.AddText(autoAimBtn.transform, 16, 0, 520, 78, L("ui.worldopt.auto_aim"), 24, UiKit.TextCol, TextAnchor.MiddleLeft, FontStyle.Bold);
+ UiKit.AddText(autoAimBtn.transform, 560, 0, 200, 78, autoAim ? L("ui.toggle.on") : L("ui.toggle.off"), 22,
+ autoAim ? UiKit.Ok : UiKit.CyanDim, TextAnchor.MiddleLeft, FontStyle.Bold);
+ y += 96f;
y += 16f;
// VEGA advisor hints on/off — mutes the ship AI's optional coaching (onboarding chip stays).
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/CreatureView.cs b/client/Assets/BlocksBeyondTheStars/Scripts/CreatureView.cs
index 25d0e0e5..343c48e8 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/CreatureView.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/CreatureView.cs
@@ -61,6 +61,7 @@ private void Update()
var seen = _seenScratch;
seen.Clear();
+ var cam = Camera.main; // for the floating health bars (#692)
foreach (var c in Game.Creatures)
{
seen.Add(c.Id);
@@ -216,6 +217,14 @@ private void Update()
entry.PrevHull = c.Hull;
entry.PrevHostile = c.Hostile;
+
+ // Floating health bar (#692): sits just above where a companion nameplate would hang, height
+ // scaled with the creature's size; companions read friendly cyan, wild fauna the health ramp.
+ float barHeight = 1.5f * Mathf.Clamp(c.Size, 0.4f, 8f) + 0.7f;
+ EnemyHealthBars.Push(Game, cam, c.Id,
+ entry.Root.transform.position + Vector3.up * barHeight,
+ c.Hull, c.HullMax, friendly: !string.IsNullOrEmpty(c.OwnerId),
+ fadeStart: 18f, fadeEnd: 28f);
}
if (_creatures.Count > seen.Count)
@@ -238,6 +247,7 @@ private void Update()
if (e.Zzz != null) Destroy(e.Zzz); // sleep label is under the game root too
Destroy(e.Root);
_creatures.Remove(id);
+ EnemyHealthBars.Forget(id);
}
}
}
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs b/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs
new file mode 100644
index 00000000..2dfd0cf9
--- /dev/null
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs
@@ -0,0 +1,92 @@
+// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games)
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text.
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace BlocksBeyondTheStars.Client
+{
+ ///
+ /// Floating health bars over damageable entities (#692) — planet enemies/bandits, creatures and
+ /// space hostiles all funnel through once per entity per frame. The helper owns
+ /// the shared policy so every caller behaves identically:
+ /// - a bar shows while the entity is "in combat" (took damage in the last few seconds) or is the
+ /// current crosshair/fire target — never permanently, so herds don't become a wall of gauges;
+ /// - the fill lerps toward the replicated value (snapshots arrive at 0.15–0.5 s cadence);
+ /// - colour ramps green→amber→red with remaining health (companions read friendly cyan instead);
+ /// - a hull drop attributable to the local player's latest shot flashes the HUD hit marker (#693).
+ /// The "Enemy health bars" client setting turns the bars off; hit attribution still runs.
+ ///
+ public static class EnemyHealthBars
+ {
+ private const float ShowSeconds = 6f; // how long a damaged entity keeps its bar
+ private const float LerpPerSecond = 1.2f; // fill fraction change per second toward the snapshot
+
+ private static readonly Dictionary _lastHull = new Dictionary();
+ private static readonly Dictionary _combatUntil = new Dictionary();
+ private static readonly Dictionary _shownFrac = new Dictionary();
+
+ /// Feeds one entity's replicated state for this frame and draws its bar when the policy
+ /// says so. is the world-space point above the body;
+ /// marks the caller's own current target (space fire lock) on top of the on-foot crosshair aim.
+ public static void Push(GameBootstrap game, Camera cam, string id, Vector3 anchor,
+ float hull, float hullMax, bool friendly,
+ float fadeStart, float fadeEnd, bool targeted = false)
+ {
+ if (game == null || cam == null || string.IsNullOrEmpty(id) || hullMax <= 1f)
+ {
+ return; // hullMax 1 = the sentinel stations/drops use — nothing worth a gauge
+ }
+
+ // Damage detection: a hull drop marks the entity "in combat" and, when our own latest shot
+ // went there, flashes the hit marker. Runs even with bars disabled so aiming feedback stays.
+ if (_lastHull.TryGetValue(id, out var prev) && hull < prev - 0.01f)
+ {
+ _combatUntil[id] = Time.time + ShowSeconds;
+ if (game.LastShotTargetId == id && Time.time - game.LastShotTime < 0.6f)
+ {
+ HudUi.Instance?.ShowHitMarker();
+ }
+ }
+
+ _lastHull[id] = hull;
+
+ if (game.Settings != null && !game.Settings.ShowEnemyHealthBars)
+ {
+ return;
+ }
+
+ bool aimed = targeted || game.AimedEnemyId == id;
+ bool inCombat = _combatUntil.TryGetValue(id, out var until) && Time.time < until;
+ if (!aimed && !inCombat)
+ {
+ _shownFrac.Remove(id); // next appearance snaps to the live value instead of lerping in
+ return;
+ }
+
+ float target = hullMax > 0f ? Mathf.Clamp01(hull / hullMax) : 0f;
+ float shown = _shownFrac.TryGetValue(id, out var s)
+ ? Mathf.MoveTowards(s, target, Time.deltaTime * LerpPerSecond)
+ : target;
+ _shownFrac[id] = shown;
+
+ var col = friendly ? UiKit.Cyan : Ramp(shown);
+ ScreenLabelLayer.Instance.WorldBar(cam, anchor, shown, col, 46f, fadeStart, fadeEnd);
+ }
+
+ /// Drops an entity's bookkeeping when it despawns/dies, so ids never accumulate.
+ public static void Forget(string id)
+ {
+ _lastHull.Remove(id);
+ _combatUntil.Remove(id);
+ _shownFrac.Remove(id);
+ }
+
+ /// The speeder-gauge colour convention: green while healthy, amber when bruised, red when
+ /// close to breaking — reads at a glance for kids.
+ private static Color Ramp(float frac)
+ => frac > 0.5f ? new Color(0.35f, 0.9f, 0.4f)
+ : frac > 0.25f ? new Color(1f, 0.75f, 0.25f)
+ : new Color(1f, 0.35f, 0.3f);
+ }
+}
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs.meta b/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs.meta
new file mode 100644
index 00000000..770aff1c
--- /dev/null
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 18be9bb63be44fe890e8d4112e2457b0
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs b/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs
index 660c723f..a8b045dd 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs
@@ -493,6 +493,20 @@ public bool LandedShipCovers(int x, int y, int z)
public bool SpaceSkipLaunch { get; private set; } // entered space already airborne (helm) → no take-off anim
public NetCombatEntity[] PlanetEnemies { get; private set; } = System.Array.Empty();
+ // --- Crosshair enemy aiming (#693): published by PlayerController every frame ---
+
+ /// Id of the enemy/creature currently under the crosshair (null = none) — drives the
+ /// crosshair's hostile tint and keeps that entity's health bar always visible.
+ public string AimedEnemyId { get; set; }
+
+ /// Whether the active world's rules mandate auto-aim (defaults to ON before rules arrive).
+ public bool AutoAimOn => Rules == null || Rules.AutoAim;
+
+ /// The entity id of the local player's most recent shot + when it left — lets the entity
+ /// views attribute an observed hull drop to "my hit" and flash the crosshair hit marker.
+ public string LastShotTargetId { get; set; }
+ public float LastShotTime { get; set; } = -999f;
+
/// Live procedural creatures near the player (fauna), with their species descriptor.
public NetCreature[] Creatures { get; private set; } = System.Array.Empty();
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/HudUi.cs b/client/Assets/BlocksBeyondTheStars/Scripts/HudUi.cs
index 355670cd..9d80d9de 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/HudUi.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/HudUi.cs
@@ -47,6 +47,13 @@ public sealed class HudUi : MonoBehaviour
private Canvas _canvas;
private GameObject _crosshair, _locationPanel, _vitalsPanel, _shipRows;
+ // Crosshair state (#693): hostile tint while an enemy is under the reticle + the hit-marker flash.
+ private static readonly Color HostileAim = new Color(1f, 0.4f, 0.35f, 0.95f);
+ private static readonly Color HitMarkerCol = new Color(1f, 0.85f, 0.4f, 0.95f);
+ private Image _crossV, _crossH;
+ private GameObject _hitMarker;
+ private float _hitMarkerTimer;
+
/// Set while a scope draws its own reticle (see ); hides the HUD
/// crosshair for as long as it is up.
public static bool SuppressCrosshair;
@@ -131,6 +138,7 @@ private void LateUpdate()
if (show)
{
+ UpdateCrosshairState(Time.deltaTime); // per frame: aim tint must not lag the reticle
RefreshCompass(); // per frame: blips counter-rotate with the camera, throttling would judder
_refreshTimer -= Time.deltaTime;
@@ -1234,14 +1242,76 @@ private bool HoldingScanner()
private static Image Panel(Transform parent, float x, float y, float w, float h)
=> UiKit.AddPanel(parent, x, y, w, h, new Color(0.05f, 0.12f, 0.24f, 0.82f));
- private static void MakeCrosshair(RectTransform parent)
+ private void MakeCrosshair(RectTransform parent)
{
var v = new GameObject("v", typeof(RectTransform)); v.transform.SetParent(parent, false);
var vr = v.GetComponent(); vr.anchorMin = vr.anchorMax = new Vector2(0.5f, 0.5f); vr.sizeDelta = new Vector2(2, 18);
- v.AddComponent().color = UiKit.Cyan;
+ _crossV = v.AddComponent(); _crossV.color = UiKit.Cyan;
var hh = new GameObject("h", typeof(RectTransform)); hh.transform.SetParent(parent, false);
var hr = hh.GetComponent(); hr.anchorMin = hr.anchorMax = new Vector2(0.5f, 0.5f); hr.sizeDelta = new Vector2(18, 2);
- hh.AddComponent().color = UiKit.Cyan;
+ _crossH = hh.AddComponent(); _crossH.color = UiKit.Cyan;
+
+ // Hit marker (#693): four diagonal ticks around the reticle, flashed briefly when one of the
+ // local player's shots visibly lands (the entity views attribute the hull drop and call
+ // ShowHitMarker). Inactive by default.
+ _hitMarker = new GameObject("hits", typeof(RectTransform));
+ _hitMarker.transform.SetParent(parent, false);
+ var hm = _hitMarker.GetComponent();
+ hm.anchorMin = hm.anchorMax = new Vector2(0.5f, 0.5f);
+ hm.sizeDelta = Vector2.zero;
+ for (int i = 0; i < 4; i++)
+ {
+ var tick = new GameObject("t" + i, typeof(RectTransform));
+ tick.transform.SetParent(_hitMarker.transform, false);
+ var tr = tick.GetComponent();
+ tr.anchorMin = tr.anchorMax = new Vector2(0.5f, 0.5f);
+ tr.sizeDelta = new Vector2(2.5f, 9f);
+ float ang = 45f + i * 90f;
+ tr.localRotation = Quaternion.Euler(0f, 0f, ang);
+ tr.anchoredPosition = Quaternion.Euler(0f, 0f, ang) * new Vector2(0f, 13f);
+ tick.AddComponent().color = HitMarkerCol;
+ }
+
+ _hitMarker.SetActive(false);
+ }
+
+ /// Tints the reticle hostile-red while an enemy sits under it, and runs the hit-marker
+ /// flash timer. Called every frame from .
+ private void UpdateCrosshairState(float dt)
+ {
+ if (_crossV == null)
+ {
+ return;
+ }
+
+ var col = Game.AimedEnemyId != null ? HostileAim : UiKit.Cyan;
+ if (_crossV.color != col)
+ {
+ _crossV.color = col;
+ _crossH.color = col;
+ }
+
+ if (_hitMarkerTimer > 0f)
+ {
+ _hitMarkerTimer -= dt;
+ if (_hitMarkerTimer <= 0f && _hitMarker != null)
+ {
+ _hitMarker.SetActive(false);
+ }
+ }
+ }
+
+ /// Flashes the crosshair hit marker (#693) — called by the entity views when a hull drop is
+ /// attributable to the local player's latest shot.
+ public void ShowHitMarker()
+ {
+ if (_hitMarker == null)
+ {
+ return;
+ }
+
+ _hitMarkerTimer = 0.25f;
+ _hitMarker.SetActive(true);
}
}
}
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs b/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs
index 45b63ab9..f7945c2f 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs
@@ -421,6 +421,7 @@ private void Update()
HandleDrillAudio();
UpdateGearPeriodically();
SendMovement();
+ UpdateEnemyAim();
// Publish local pose for the HUD minimap/compass.
if (Game != null)
@@ -431,6 +432,12 @@ private void Update()
}
}
+ // Crosshair aiming (#693): auto-aim acquires inside a forward cone only (never behind the back);
+ // a melee swing sweeps a wider arc; with the AutoAim world rule OFF a ranged shot needs a genuine
+ // crosshair hit — nothing under the reticle means the shot misses.
+ private const float AutoAimCone = 0.819f; // cos ~35°
+ private const float MeleeCone = 0.5f; // cos ~60°
+
private void AttackNearestEnemy()
{
if (Game?.Network == null)
@@ -439,8 +446,6 @@ private void AttackNearestEnemy()
}
PlayWeaponSound();
- string nearest = null;
- Vector3 nearestPos = default;
// Reach follows the equipped weapon: a ranged weapon must let you hit at its full range, not the bare
// melee reach — otherwise a "gun" only ever fires point-blank (where the enemy's own bite already
@@ -449,51 +454,50 @@ private void AttackNearestEnemy()
float reach = heldTool != null && heldTool.Kind == BlocksBeyondTheStars.Shared.Definitions.ToolKind.Weapon
? Mathf.Max(6f, heldTool.Range)
: 6f;
- float bestSq = reach * reach;
- foreach (var e in Game.PlanetEnemies)
+ var kind = HeldWeaponFx();
+ bool melee = kind == WeaponFxKind.Melee;
+
+ // The entity under the crosshair always wins — it is what the player is looking at.
+ string targetId = null;
+ Vector3 targetPos = default;
+ if (AimEnemy(reach, out var aimId, out var aimPos, out float terrainDist))
{
- var ep = Game.ScenePos(e.X, e.Y, e.Z); // seam-aware (longitude wraps)
- float d = (ep - transform.position).sqrMagnitude;
- if (d < bestSq)
- {
- bestSq = d;
- nearest = e.Id;
- nearestPos = ep;
- }
+ targetId = aimId;
+ targetPos = aimPos;
}
-
- // Creatures (fauna) are attackable too — the server shares the hit path.
- foreach (var c in Game.Creatures)
+ else if (melee || Game.AutoAimOn)
{
- var cp = Game.ScenePos(c.X, c.Y, c.Z); // seam-aware (longitude wraps)
- float d = (cp - transform.position).sqrMagnitude;
- if (d < bestSq)
- {
- bestSq = d;
- nearest = c.Id;
- nearestPos = cp;
- }
+ // Auto-aim (and every melee swing): nearest target inside the forward cone. The old
+ // 360° nearest-anywhere selection is gone — no more kills behind your back.
+ targetId = BestConeTarget(reach, melee ? MeleeCone : AutoAimCone, out targetPos);
}
- if (nearest != null)
+ var ct = Camera != null ? Camera.transform : transform;
+ if (targetId != null)
{
- Game.Network.SendAttackEntity(nearest);
+ var f = ct.forward;
+ Game.Network.SendAttackEntity(targetId,
+ new BlocksBeyondTheStars.Shared.Geometry.Vector3f(f.x, f.y, f.z));
+ Game.LastShotTargetId = targetId;
+ Game.LastShotTime = Time.time;
}
if (Weapons != null && Camera != null)
{
- var ct = Camera.transform;
var from = ct.position + ct.forward * 0.4f - ct.up * 0.15f;
var col = WeaponColor();
- var kind = HeldWeaponFx();
if (kind == WeaponFxKind.Melee)
{
// A melee slash sweeps whether or not it connects (whiff still reads).
Weapons.MeleeArc(from, ct.forward, ct.up, col);
}
- else if (nearest != null)
+ else
{
- var target = nearestPos + Vector3.up * 0.4f;
+ // A hit flies to the body; a miss still leaves the muzzle and dies on the terrain
+ // (or at max range) — with manual aiming, "wide" has to read as wide.
+ var target = targetId != null
+ ? targetPos + Vector3.up * 0.4f
+ : ct.position + ct.forward * Mathf.Min(reach, terrainDist);
if (kind == WeaponFxKind.Projectile)
{
Weapons.Projectile(from, target, col); // kinetic bolt that flies + bursts
@@ -506,6 +510,173 @@ private void AttackNearestEnemy()
}
}
+ /// Finds the enemy/creature under the crosshair (#693): an analytic ray-vs-sphere sweep over
+ /// the replicated entities — their meshes deliberately carry no colliders — occluded by terrain via a
+ /// voxel march. Hitboxes are deliberately generous (kid-friendly forgiveness). Also reports how far
+ /// the ray flies before hitting terrain, for the miss tracer.
+ private bool AimEnemy(float maxRange, out string id, out Vector3 pos, out float terrainDist)
+ {
+ id = null;
+ pos = default;
+ terrainDist = maxRange;
+ if (Game == null || Camera == null)
+ {
+ return false;
+ }
+
+ Vector3 o = Camera.transform.position;
+ Vector3 dir = Camera.transform.forward;
+ terrainDist = TerrainDistance(o, dir, maxRange);
+
+ float best = terrainDist + 0.5f; // a body right at the wall still counts
+ foreach (var e in Game.PlanetEnemies)
+ {
+ var basePos = Game.ScenePos(e.X, e.Y, e.Z); // seam-aware (longitude wraps)
+ var center = basePos + Vector3.up * 0.9f;
+ float r = 1.1f * Mathf.Max(1f, e.Scale);
+ if (RayHitsSphere(o, dir, center, r, out float d) && d < best)
+ {
+ best = d;
+ id = e.Id;
+ pos = basePos;
+ }
+ }
+
+ foreach (var c in Game.Creatures)
+ {
+ float size = Mathf.Clamp(c.Size, 0.4f, 8f);
+ var basePos = Game.ScenePos(c.X, c.Y, c.Z);
+ var center = basePos + Vector3.up * (0.6f * size);
+ float r = Mathf.Max(0.8f, 0.9f * size);
+ if (RayHitsSphere(o, dir, center, r, out float d) && d < best)
+ {
+ best = d;
+ id = c.Id;
+ pos = basePos;
+ }
+ }
+
+ return id != null;
+ }
+
+ /// Nearest attackable entity inside the camera-forward cone (auto-aim / melee sweep).
+ /// Point-blank targets (< 1.5 blocks) ignore the cone — something chewing on your boots is hittable
+ /// even while you look past it.
+ private string BestConeTarget(float reach, float cone, out Vector3 pos)
+ {
+ string bestId = null;
+ Vector3 bestPos = default; // out params can't be captured by the local function below
+ float bestSq = reach * reach;
+ Vector3 eye = Camera != null ? Camera.transform.position : transform.position;
+ Vector3 fwd = Camera != null ? Camera.transform.forward : transform.forward;
+
+ void Consider(string cid, Vector3 p)
+ {
+ var to = p + Vector3.up * 0.9f - eye;
+ float d = to.sqrMagnitude;
+ if (d >= bestSq || d < 0.0001f)
+ {
+ return;
+ }
+
+ if (d > 2.25f && Vector3.Dot(to.normalized, fwd) < cone)
+ {
+ return;
+ }
+
+ bestSq = d;
+ bestId = cid;
+ bestPos = p;
+ }
+
+ foreach (var e in Game.PlanetEnemies)
+ {
+ Consider(e.Id, Game.ScenePos(e.X, e.Y, e.Z));
+ }
+
+ // Creatures (fauna) are attackable too — the server shares the hit path.
+ foreach (var c in Game.Creatures)
+ {
+ Consider(c.Id, Game.ScenePos(c.X, c.Y, c.Z));
+ }
+
+ pos = bestPos;
+ return bestId;
+ }
+
+ /// Distance the aim ray travels before hitting solid terrain (voxel DDA like
+ /// , but with a caller-chosen range — weapon range exceeds block reach).
+ /// Fluids are passed through, matching the block-aim behaviour.
+ private float TerrainDistance(Vector3 o, Vector3 dir, float maxDist)
+ {
+ if (Game?.World == null)
+ {
+ return maxDist;
+ }
+
+ int x = Mathf.FloorToInt(o.x), y = Mathf.FloorToInt(o.y), z = Mathf.FloorToInt(o.z);
+ int sx = dir.x >= 0 ? 1 : -1, sy = dir.y >= 0 ? 1 : -1, sz = dir.z >= 0 ? 1 : -1;
+ float invx = Mathf.Abs(dir.x) > 1e-6f ? 1f / Mathf.Abs(dir.x) : float.PositiveInfinity;
+ float invy = Mathf.Abs(dir.y) > 1e-6f ? 1f / Mathf.Abs(dir.y) : float.PositiveInfinity;
+ float invz = Mathf.Abs(dir.z) > 1e-6f ? 1f / Mathf.Abs(dir.z) : float.PositiveInfinity;
+ float tMaxX = float.IsInfinity(invx) ? float.PositiveInfinity : (dir.x > 0 ? (x + 1 - o.x) : (o.x - x)) * invx;
+ float tMaxY = float.IsInfinity(invy) ? float.PositiveInfinity : (dir.y > 0 ? (y + 1 - o.y) : (o.y - y)) * invy;
+ float tMaxZ = float.IsInfinity(invz) ? float.PositiveInfinity : (dir.z > 0 ? (z + 1 - o.z) : (o.z - z)) * invz;
+
+ float t = 0f;
+ for (int i = 0; i < 160 && t <= maxDist; i++)
+ {
+ var id = Game.World.GetBlock(x, y, z);
+ if (!id.IsAir && !IsFluidBlock(id))
+ {
+ return t;
+ }
+
+ if (tMaxX <= tMaxY && tMaxX <= tMaxZ) { x += sx; t = tMaxX; tMaxX += invx; }
+ else if (tMaxY <= tMaxZ) { y += sy; t = tMaxY; tMaxY += invy; }
+ else { z += sz; t = tMaxZ; tMaxZ += invz; }
+ }
+
+ return maxDist;
+ }
+
+ /// Ray-vs-sphere with the hit reported at the centre plane — plenty for ordering targets
+ /// against each other and the terrain at gameplay scales.
+ private static bool RayHitsSphere(Vector3 o, Vector3 dir, Vector3 center, float radius, out float dist)
+ {
+ dist = 0f;
+ Vector3 to = center - o;
+ float along = Vector3.Dot(to, dir);
+ if (along < 0f)
+ {
+ return false;
+ }
+
+ if (to.sqrMagnitude - along * along > radius * radius)
+ {
+ return false;
+ }
+
+ dist = along;
+ return true;
+ }
+
+ /// Publishes which enemy sits under the crosshair (every frame) — the HUD tints the reticle
+ /// and the health-bar layer keeps that entity's bar visible.
+ private void UpdateEnemyAim()
+ {
+ if (Game == null)
+ {
+ return;
+ }
+
+ var heldTool = Game.Content?.GetItem(Game.ItemInSlot(Game.SelectedHotbarSlot))?.Tool;
+ float reach = heldTool != null && heldTool.Kind == BlocksBeyondTheStars.Shared.Definitions.ToolKind.Weapon
+ ? Mathf.Max(6f, heldTool.Range)
+ : 6f;
+ Game.AimedEnemyId = AimEnemy(reach, out var id, out _, out _) ? id : null;
+ }
+
private enum WeaponFxKind { Beam, Projectile, Melee }
/// Classifies the held weapon's effect: kinetic guns fire a flying bolt, energy guns an
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/ScreenLabelLayer.cs b/client/Assets/BlocksBeyondTheStars/Scripts/ScreenLabelLayer.cs
index 24149c45..03b2db05 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/ScreenLabelLayer.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/ScreenLabelLayer.cs
@@ -25,6 +25,11 @@ public sealed class ScreenLabelLayer : MonoBehaviour
private int _used;
private int _frame = -1;
+ // Health/progress bars (#692) share the layer's projection + pooling lifecycle, so entity health
+ // bars and nameplates stay in the same visual system (and one distance-fade convention).
+ private readonly List _barPool = new List();
+ private int _barUsed;
+
private sealed class Entry
{
public RectTransform Rt;
@@ -32,6 +37,13 @@ private sealed class Entry
public Text Shadow;
}
+ private sealed class BarEntry
+ {
+ public RectTransform Rt;
+ public Image Track;
+ public Image Fill;
+ }
+
/// Lazily creates (or returns) the singleton layer.
public static ScreenLabelLayer Instance
{
@@ -101,6 +113,52 @@ public void World(Camera cam, Vector3 world, string text, Color color, bool bold
e.Rt.gameObject.SetActive(true);
}
+ /// Pushes a horizontal bar (track + fill) anchored to a world position — the world-space
+ /// sibling of 's vitals. Same per-frame contract and distance fade as
+ /// ; callers re-push every frame from LateUpdate.
+ public void WorldBar(Camera cam, Vector3 world, float frac, Color color,
+ float width = 46f, float fadeStart = 0f, float fadeEnd = 0f)
+ {
+ if (cam == null)
+ {
+ return;
+ }
+
+ var sp = cam.WorldToScreenPoint(world);
+ if (sp.z <= 0f)
+ {
+ return; // behind the camera
+ }
+
+ float alpha = 1f;
+ if (fadeEnd > 0f)
+ {
+ float dist = Vector3.Distance(cam.transform.position, world);
+ if (dist >= fadeEnd)
+ {
+ return;
+ }
+
+ if (dist > fadeStart && fadeEnd > fadeStart)
+ {
+ alpha = 1f - (dist - fadeStart) / (fadeEnd - fadeStart);
+ }
+ }
+
+ BeginFrameIfNeeded();
+ var e = AcquireBar();
+ RectTransformUtility.ScreenPointToLocalPointInRectangle(_root, new Vector2(sp.x, sp.y), null, out var local);
+ e.Rt.anchoredPosition = local;
+ e.Rt.sizeDelta = new Vector2(width, 5f);
+ var track = new Color(0.03f, 0.07f, 0.13f, 0.9f * alpha); // the HUD vitals' dark track
+ e.Track.color = track;
+ var fill = color;
+ fill.a *= alpha;
+ e.Fill.color = fill;
+ e.Fill.fillAmount = Mathf.Clamp01(frac);
+ e.Rt.gameObject.SetActive(true);
+ }
+
private void BeginFrameIfNeeded()
{
if (_frame == Time.frameCount)
@@ -110,6 +168,7 @@ private void BeginFrameIfNeeded()
_frame = Time.frameCount;
_used = 0;
+ _barUsed = 0;
}
private Entry Acquire()
@@ -134,6 +193,43 @@ private Entry Acquire()
return e;
}
+ private BarEntry AcquireBar()
+ {
+ if (_barUsed < _barPool.Count)
+ {
+ return _barPool[_barUsed++];
+ }
+
+ var go = new GameObject("WorldBar", typeof(RectTransform));
+ go.transform.SetParent(_root, false);
+ var rt = go.GetComponent();
+ rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(0.5f, 0.5f);
+ rt.sizeDelta = new Vector2(46f, 5f);
+
+ var track = go.AddComponent();
+ track.sprite = UiKit.SolidSprite;
+ track.raycastTarget = false;
+
+ var fillGo = new GameObject("Fill", typeof(RectTransform));
+ fillGo.transform.SetParent(rt, false);
+ var fr = fillGo.GetComponent();
+ fr.anchorMin = Vector2.zero;
+ fr.anchorMax = Vector2.one;
+ fr.offsetMin = new Vector2(0.5f, 0.5f);
+ fr.offsetMax = new Vector2(-0.5f, -0.5f);
+ var fill = fillGo.AddComponent();
+ fill.sprite = UiKit.SolidSprite;
+ fill.type = Image.Type.Filled;
+ fill.fillMethod = Image.FillMethod.Horizontal;
+ fill.fillOrigin = (int)Image.OriginHorizontal.Left;
+ fill.raycastTarget = false;
+
+ var e = new BarEntry { Rt = rt, Track = track, Fill = fill };
+ _barPool.Add(e);
+ _barUsed++;
+ return e;
+ }
+
private static Text MakeText(RectTransform parent, Vector2 offset, Color color)
{
var go = new GameObject("T", typeof(RectTransform));
@@ -169,7 +265,16 @@ private IEnumerator FinalizeLoop()
}
}
+ for (int i = _barUsed; i < _barPool.Count; i++)
+ {
+ if (_barPool[i].Rt.gameObject.activeSelf)
+ {
+ _barPool[i].Rt.gameObject.SetActive(false);
+ }
+ }
+
_used = 0; // next producer call starts a fresh frame
+ _barUsed = 0;
}
}
}
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs
index a5eb9dd6..0781181f 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs
@@ -294,8 +294,28 @@ private sealed class RemoteAvatar { public GameObject Root; public GameObject Sh
private GameObject _systemsBarRoot;
private int _builtSystemCount = -1;
private const string FlightWeapon = "ship_laser_basic";
- private const float WeaponRange = 45f; // matches ship_laser_basic weapon_range
- private const float FireRate = 0.45f; // seconds between shots
+ private const float WeaponRange = 45f; // fallback when the module data is unavailable
+ private const float FireRate = 0.45f; // fallback seconds between shots
+
+ // #693: with the AutoAim world rule ON the laser acquires targets in a tight forward cone; OFF
+ // requires a genuine boresight line — the nose ray must pass through the target's body.
+ private const float SpaceAutoAimCone = 0.866f; // cos ~30° (was effectively ±75°)
+
+ /// The fitted module's real range (#694 — the client used to hardcode 45, crippling the
+ /// laser_cannon_2's range advantage of 70).
+ private float WeaponRangeFor(string weaponKey)
+ {
+ var stats = Game.Content?.GetShipModule(weaponKey)?.Stats;
+ return stats != null && stats.TryGetValue("weapon_range", out var v) ? (float)v : WeaponRange;
+ }
+
+ /// The fitted module's real fire cadence — the server enforces it now (#694), so firing
+ /// faster client-side would only get shots swallowed.
+ private float WeaponCooldownFor(string weaponKey)
+ {
+ var stats = Game.Content?.GetShipModule(weaponKey)?.Stats;
+ return stats != null && stats.TryGetValue("weapon_cooldown", out var v) ? (float)v : FireRate;
+ }
private struct ShipSystem { public string Label; public string Kind; public string WeaponKey; }
@@ -1228,11 +1248,11 @@ private void UpdateCruise()
var sys = _systems[_selectedSystem];
if (sys.Kind == "laser")
{
- var target = BestFireTarget();
+ var target = BestFireTarget(sys.WeaponKey);
_fireTargetId = target?.Id;
if (target != null && _fireCd <= 0f && InputMap.PrimaryHeld())
{
- _fireCd = FireRate;
+ _fireCd = WeaponCooldownFor(sys.WeaponKey);
FireAt(target, sys.WeaponKey);
}
}
@@ -1543,9 +1563,10 @@ private void ActivateTractor(BlocksBeyondTheStars.Networking.Messages.NetCombatE
ClientAudio.Instance?.Cue("scan_ping");
}
- /// The best entity to fire on: the nearest asteroid/hostile within weapon range that's
- /// roughly ahead of the ship (so you aim by pointing the nose at it).
- private BlocksBeyondTheStars.Networking.Messages.NetCombatEntity BestFireTarget()
+ /// The best entity to fire on (#693). AutoAim rule ON: the nearest asteroid/hostile within
+ /// range inside a tight forward cone (point the nose at it). AutoAim OFF: only a target the nose ray
+ /// genuinely passes through counts — line it up or the trigger stays cold.
+ private BlocksBeyondTheStars.Networking.Messages.NetCombatEntity BestFireTarget(string weaponKey)
{
var space = Game.Space;
if (space == null || _ship == null)
@@ -1553,10 +1574,13 @@ private BlocksBeyondTheStars.Networking.Messages.NetCombatEntity BestFireTarget(
return null;
}
+ float range = WeaponRangeFor(weaponKey);
Vector3 shipPos = _ship.transform.localPosition;
Vector3 fwd = _ship.transform.localRotation * Vector3.forward;
+ bool autoAim = Game.AutoAimOn;
BlocksBeyondTheStars.Networking.Messages.NetCombatEntity best = null;
- float bestScore = 0.25f; // require at least this much forward alignment
+ float bestScore = 0f; // auto-aim: alignment/distance score
+ float bestDist = range; // boresight: nearest body the ray pierces
foreach (var e in space.Entities)
{
if (e.Kind != "Asteroid" && e.Kind != "Drone" && e.Kind != "Ufo" && e.Kind != "Cruiser" && e.Kind != "BanditShip")
@@ -1566,17 +1590,43 @@ private BlocksBeyondTheStars.Networking.Messages.NetCombatEntity BestFireTarget(
Vector3 to = new Vector3(e.X, e.Y, e.Z) - shipPos;
float dist = to.magnitude;
- if (dist > WeaponRange || dist < 0.001f)
+ if (dist > range || dist < 0.001f)
{
continue;
}
- float align = Vector3.Dot(to / dist, fwd); // 1 = dead ahead
- float score = align - (dist / WeaponRange) * 0.25f; // prefer aligned + close
- if (score > bestScore)
+ float align = Vector3.Dot(to / dist, fwd); // 1 = dead ahead
+ if (autoAim)
{
- bestScore = score;
- best = e;
+ if (align < SpaceAutoAimCone)
+ {
+ continue;
+ }
+
+ float score = align - (dist / range) * 0.25f; // prefer aligned + close
+ if (score > bestScore)
+ {
+ bestScore = score;
+ best = e;
+ }
+ }
+ else
+ {
+ // Boresight: perpendicular distance of the nose ray from the body's centre must be
+ // inside its (generous) radius. Nearest pierced body wins.
+ float along = align * dist;
+ if (along <= 0f || along >= bestDist)
+ {
+ continue;
+ }
+
+ float missSq = dist * dist - along * along;
+ float radius = 2.5f * Mathf.Max(1f, e.Scale);
+ if (missSq <= radius * radius)
+ {
+ bestDist = along;
+ best = e;
+ }
}
}
@@ -1587,7 +1637,11 @@ private BlocksBeyondTheStars.Networking.Messages.NetCombatEntity BestFireTarget(
/// reads amber; combat reads cyan.
private void FireAt(BlocksBeyondTheStars.Networking.Messages.NetCombatEntity target, string weaponKey)
{
- Game.Network?.SendFireWeapon(weaponKey, target.Id);
+ Vector3 fwd = _ship.transform.localRotation * Vector3.forward;
+ Game.Network?.SendFireWeapon(weaponKey, target.Id,
+ new BlocksBeyondTheStars.Shared.Geometry.Vector3f(fwd.x, fwd.y, fwd.z));
+ Game.LastShotTargetId = target.Id;
+ Game.LastShotTime = Time.time;
bool mining = target.Kind == "Asteroid";
Color col = mining ? new Color(1f, 0.7f, 0.25f) : new Color(0.45f, 1f, 1f);
@@ -3581,6 +3635,7 @@ private void SyncEntities()
Destroy(_entities[id]);
_entities.Remove(id);
+ EnemyHealthBars.Forget(id);
}
}
}
@@ -4186,6 +4241,38 @@ private void LateUpdate()
if (_phase == Phase.Cruise)
{
DrawRemoteNameplates();
+ DrawEntityHealthBars();
+ }
+ }
+
+ /// Floating health bars over space hostiles (#692): drones, UFOs, cruisers and bandit
+ /// ships — asteroids (mining targets) and stations are excluded. The shared policy in
+ /// shows a bar while an entity is in combat or is the current fire
+ /// lock, so a quiet field stays clean. Fade band matches the pilot nameplates.
+ private void DrawEntityHealthBars()
+ {
+ var space = Game.Space;
+ if (Camera == null || space == null)
+ {
+ return;
+ }
+
+ foreach (var e in space.Entities)
+ {
+ if (e.Kind != "Drone" && e.Kind != "Ufo" && e.Kind != "Cruiser" && e.Kind != "BanditShip")
+ {
+ continue;
+ }
+
+ if (!_entities.TryGetValue(e.Id, out var go) || go == null)
+ {
+ continue;
+ }
+
+ float height = 2f * Mathf.Max(1f, e.Scale);
+ EnemyHealthBars.Push(Game, Camera, e.Id, go.transform.position + Vector3.up * height,
+ e.Hull, e.HullMax, friendly: false, fadeStart: 90f, fadeEnd: 140f,
+ targeted: e.Id == _fireTargetId);
}
}
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/UiSettings.cs b/client/Assets/BlocksBeyondTheStars/Scripts/UiSettings.cs
index a2d8b9a9..b92d9ab9 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/UiSettings.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/UiSettings.cs
@@ -164,6 +164,7 @@ private void Rebuild()
Head(ref y, L("ui.settings.comfort"));
Toggle(ref y, L("ui.settings.auto_stow"), S.AutoStowOnBoard, () => { S.AutoStowOnBoard = !S.AutoStowOnBoard; Rebuild(); });
+ Toggle(ref y, L("ui.settings.show_enemy_health"), S.ShowEnemyHealthBars, () => { S.ShowEnemyHealthBars = !S.ShowEnemyHealthBars; Rebuild(); });
Toggle(ref y, L("ui.settings.show_session_time"), S.ShowSessionTime, () => { S.ShowSessionTime = !S.ShowSessionTime; Rebuild(); });
// Chat overlay: fade out on its own (default), stay up, or never show unprompted (#636). The
// in-game toggle key does the same thing for one session without opening this menu.
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/UiWorldOptions.cs b/client/Assets/BlocksBeyondTheStars/Scripts/UiWorldOptions.cs
index 567f6c09..d5dfe11e 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/UiWorldOptions.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/UiWorldOptions.cs
@@ -101,6 +101,9 @@ void Row(bool leftCol, string label, string[] steps, System.Func get, Syste
var onOff = new[] { shell.L("ui.toggle.off"), shell.L("ui.toggle.on") };
Row(true, shell.L("ui.worldopt.space_combat"), onOff, () => opt.SpaceCombat ? 1 : 0, v => opt.SpaceCombat = v == 1);
Row(true, shell.L("ui.worldopt.keep_ship"), onOff, () => opt.KeepShip ? 1 : 0, v => opt.KeepShip = v == 1);
+ // Auto-aim (#693) intentionally has NO creation row: both columns already end flush with the
+ // footer. New worlds start with the server default (ON); the world admin flips it live in the
+ // in-game world-rules panel, and scripts can pass --auto-aim false at launch.
// Right column: the generated world.
UiKit.AddText(main.transform, rx, ry, 700f, 24f, shell.L("ui.worldopt.col_world"), 16, UiKit.Cyan, TextAnchor.MiddleLeft, FontStyle.Bold);
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/WorldCreationOptions.cs b/client/Assets/BlocksBeyondTheStars/Scripts/WorldCreationOptions.cs
index fe626866..29023bbd 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/WorldCreationOptions.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/WorldCreationOptions.cs
@@ -72,6 +72,10 @@ public sealed class WorldCreationOptions
/// combat intact to base; OFF leaves it a wreck the owner must repair before flying again.
public bool KeepShip = true;
+ /// Auto-aim (world rule #693, default ON = server default): ON lets weapons acquire targets
+ /// in a forward cone by themselves; OFF means only what is under the crosshair can be hit.
+ public bool AutoAim = true;
+
// Story (P8 world option): which story pack runs + how fast it unfolds.
public int Story = 0; // 0 = Default pack, 1 = None (sandbox)
public int StoryDensity = 1; // 0 Sparse · 1 Normal · 2 Dense
@@ -106,7 +110,7 @@ public void CopyFrom(WorldCreationOptions other)
Vaults = other.Vaults; Stations = other.Stations; Exotic = other.Exotic; UniverseSize = other.UniverseSize;
StationTemplates = other.StationTemplates; SettlementTemplates = other.SettlementTemplates;
Oxygen = other.Oxygen; Hunger = other.Hunger; Hazards = other.Hazards; DeathPenalty = other.DeathPenalty;
- SpaceCombat = other.SpaceCombat; KeepShip = other.KeepShip;
+ SpaceCombat = other.SpaceCombat; KeepShip = other.KeepShip; AutoAim = other.AutoAim;
Story = other.Story; StoryDensity = other.StoryDensity;
StartPlanetType = other.StartPlanetType;
PlanetTypes.Clear();
@@ -188,6 +192,7 @@ public string ToArgs()
// defaults both to Off, so we emit the overrides whenever the toggle is on (= the panel default).
if (SpaceCombat) { Arg("space-combat", "PvE"); Arg("ship-weapons", "NpcsOnly"); }
if (!KeepShip) Arg("keep-ship", "false");
+ if (!AutoAim) Arg("auto-aim", "false");
if (Story == 1) Arg("story", "none"); // sandbox (no story)
if (StoryDensity != 1) Arg("story-density", StoryDensitySteps[StoryDensity]);
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs b/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs
index 085d0640..2f93150c 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs
@@ -70,6 +70,7 @@ private void Update()
var seen = _seenScratch;
seen.Clear();
+ var cam = Camera.main; // for the floating health bars (#692)
foreach (var e in Game.PlanetEnemies)
{
seen.Add(e.Id);
@@ -158,6 +159,11 @@ private void Update()
}
en.PrevHull = e.Hull;
+
+ // Floating health bar over machines + bandits (#692); also attributes hull drops to the
+ // local player's latest shot for the crosshair hit marker. Same fade band as NPC nameplates.
+ EnemyHealthBars.Push(Game, cam, e.Id, en.Root.transform.position + Vector3.up * 2.1f,
+ e.Hull, e.HullMax, friendly: false, fadeStart: 18f, fadeEnd: 28f);
}
// Remove enemies whose entity is gone (killed / out of range).
@@ -177,6 +183,7 @@ private void Update()
{
Destroy(_enemies[id].Root);
_enemies.Remove(id);
+ EnemyHealthBars.Forget(id);
}
}
}
diff --git a/data/locales/de.json b/data/locales/de.json
index c39b48df..5f413298 100644
--- a/data/locales/de.json
+++ b/data/locales/de.json
@@ -1434,6 +1434,7 @@
"ui.settings.camera_motion": "Kamerabewegung (Wippen/Wackeln)",
"ui.settings.comfort": "Komfort & Wohlbefinden",
"ui.settings.auto_stow": "Beim Anbordgehen automatisch einlagern",
+ "ui.settings.show_enemy_health": "Lebensbalken über Gegnern",
"ui.settings.show_session_time": "Spielzeit anzeigen",
"ui.settings.chat_visibility": "Chat-Anzeige",
"ui.settings.chat_visibility.auto": "Ausblenden",
@@ -2015,6 +2016,7 @@
"ui.worldopt.bandits": "Banditen (Räuber & Lager)",
"ui.worldopt.space_combat": "Raumkampf (Gegner bekämpfbar)",
"ui.worldopt.keep_ship": "Schiff bei Zerstörung behalten",
+ "ui.worldopt.auto_aim": "Automatisches Zielen",
"ui.worldopt.instant_travel": "Instant Travel",
"ui.worldopt.flora": "Flora-Dichte",
"ui.worldopt.ore": "Erzreichtum",
diff --git a/data/locales/en.json b/data/locales/en.json
index 83a49846..88623f6f 100644
--- a/data/locales/en.json
+++ b/data/locales/en.json
@@ -1433,6 +1433,7 @@
"ui.settings.camera_motion": "Camera motion (bob/shake)",
"ui.settings.comfort": "Comfort & wellbeing",
"ui.settings.auto_stow": "Auto-stow into cargo on boarding",
+ "ui.settings.show_enemy_health": "Enemy health bars",
"ui.settings.show_session_time": "Show playtime",
"ui.settings.chat_visibility": "Chat display",
"ui.settings.chat_visibility.auto": "Fade out",
@@ -2014,6 +2015,7 @@
"ui.worldopt.bandits": "Bandits (robbers & camps)",
"ui.worldopt.space_combat": "Space combat (fightable enemies)",
"ui.worldopt.keep_ship": "Keep ship when destroyed",
+ "ui.worldopt.auto_aim": "Auto-aim",
"ui.worldopt.instant_travel": "Instant Travel",
"ui.worldopt.flora": "Flora density",
"ui.worldopt.ore": "Ore richness",
diff --git a/docs/user/USER_MANUAL.md b/docs/user/USER_MANUAL.md
index 377bd615..93d0f98e 100644
--- a/docs/user/USER_MANUAL.md
+++ b/docs/user/USER_MANUAL.md
@@ -65,7 +65,7 @@ Last updated: 2026-07-04.
| **Right-click** | Place the selected hotbar block (or **use** the selected gadget, e.g. the terrain scanner) |
| **Mouse wheel** | Cycle hotbar slot |
| **1 – 9** | Select hotbar slot |
-| **F** | Attack the nearest creature / swing the held tool |
+| **F** | Attack with the held tool/weapon — hits what's **under your crosshair** (the reticle turns red over a target; with **auto-aim** on, the nearest enemy in front of you is acquired automatically) |
| **R** | Repair the targeted wreck breach with the selected hotbar block (see §5 → Wrecks); with a **shaped block** selected: rotate its placement orientation (see §5 → Craftable block shapes) |
| **L** | Toggle the suit headlamp (requires a `suit_lamp`) |
| **G** | Loot the nearest container |
@@ -310,6 +310,21 @@ separate unlock; admins can still disable it through server world rules.
### Space flight & combat
- Fly within local space instances; asteroids + NPC drones can damage hull/shield. Whether you can lose your
ship is set by the world's **"Keep ship on death"** rule (see *Repairing your own ship* above).
+- **Aiming**: the ship laser acquires the best target roughly **ahead of the nose** (the centre dot lights up
+ cyan on lock). Weapon **range and fire rate come from the fitted module** — bigger cannons genuinely reach
+ further.
+
+### Aiming & enemy health bars
+- Damaged enemies (and the one under your crosshair) show a small **health bar** that ramps
+ green → amber → red; tamed companions show a friendly cyan bar. Turn bars off under
+ **Settings → Comfort → "Enemy health bars"**.
+- When one of **your** shots lands, the crosshair flashes a **hit marker**.
+- **Auto-aim** is a **world rule** (default **on**): weapons pick a target in a forward cone by
+ themselves — kid- and gamepad-friendly. The world admin can turn it **off** in the in-game
+ **world rules** panel (or create the world with `--auto-aim false`): then only what is actually
+ **under the crosshair** (on foot) or **on the ship's boresight** (in space) can be hit — misses
+ really miss. Shots are server-validated either way, including line-of-sight (no shooting through
+ walls).
### Asteroid belts
- In worlds created with belts (the default for new worlds), a system's landable asteroids orbit
diff --git a/src/BlocksBeyondTheStars.Client.Core/NetworkClient.cs b/src/BlocksBeyondTheStars.Client.Core/NetworkClient.cs
index e544bfc5..1a0702c8 100644
--- a/src/BlocksBeyondTheStars.Client.Core/NetworkClient.cs
+++ b/src/BlocksBeyondTheStars.Client.Core/NetworkClient.cs
@@ -194,7 +194,8 @@ public void Join(string playerName, string? password = null, string locale = "en
/// World admin: live-edits the gameplay world options (empty fields = unchanged).
public void SendSetWorldRules(string creatures = "", string planetEnemies = "", string spaceNpcs = "", string ufos = "",
- string bandits = "", string instantTravel = "", string keepInventory = "", string keepShip = "", string hazards = "")
+ string bandits = "", string instantTravel = "", string keepInventory = "", string keepShip = "", string hazards = "",
+ string autoAim = "")
=> Send(new SetWorldRulesIntent
{
CreatureAbundance = creatures,
@@ -206,6 +207,7 @@ public void SendSetWorldRules(string creatures = "", string planetEnemies = "",
KeepInventoryOnDeath = keepInventory,
KeepShipOnDeath = keepShip,
EnvironmentalHazards = hazards,
+ AutoAim = autoAim,
});
/// Hyperjump into a (possibly unvisited) star system, arriving in flight mode there.
@@ -336,10 +338,15 @@ public void SendLeaveSpace(string destinationBodyId, int padIndex = -1)
/// Asks the server for a body's fixed landing pads + their live occupancy (the pad chooser).
public void SendRequestLandingPads(string bodyId) => Send(new RequestLandingPadsIntent { BodyId = bodyId });
- public void SendFireWeapon(string weaponKey, string targetEntityId)
- => Send(new FireWeaponIntent { WeaponKey = weaponKey, TargetEntityId = targetEntityId });
+ /// Fires a ship weapon at a space entity; the direction is the ship's nose at the moment of
+ /// firing so the server can enforce the firing arc (#693). Zero = legacy no-arc behaviour.
+ public void SendFireWeapon(string weaponKey, string targetEntityId, Vector3f dir = default)
+ => Send(new FireWeaponIntent { WeaponKey = weaponKey, TargetEntityId = targetEntityId, DirX = dir.X, DirY = dir.Y, DirZ = dir.Z });
- public void SendAttackEntity(string entityId) => Send(new AttackEntityIntent { EntityId = entityId });
+ /// Attacks a planet enemy/creature; the direction is the camera ray of the shot so the
+ /// server can validate the claimed target against it (#693). Zero = legacy no-aim behaviour.
+ public void SendAttackEntity(string entityId, Vector3f dir = default)
+ => Send(new AttackEntityIntent { EntityId = entityId, DirX = dir.X, DirY = dir.Y, DirZ = dir.Z });
/// Asks the server to save the world + players to disk now (explicit save).
public void SendSaveGame() => Send(new SaveGameIntent());
diff --git a/src/BlocksBeyondTheStars.GameServer/GameServer.cs b/src/BlocksBeyondTheStars.GameServer/GameServer.cs
index 844cc29d..5da342d4 100644
--- a/src/BlocksBeyondTheStars.GameServer/GameServer.cs
+++ b/src/BlocksBeyondTheStars.GameServer/GameServer.cs
@@ -646,6 +646,9 @@ public void Travel(string playerId, string destinationBodyId)
/// Test hook: toggle the Instant Travel world rule.
public void SetInstantTravelForTest(bool on) => Rules.InstantTravel = on;
+ /// Test hook: flips the AutoAim world rule (#693) without a session/admin round-trip.
+ public void SetAutoAimForTest(bool on) => Rules.AutoAim = on;
+
/// Test hook for the travel-screen quick-travel path (gated by the Instant Travel rule). Returns
/// whether the player ended up at the destination (i.e. the travel was allowed).
public bool QuickTravelForTest(string playerId, string destinationBodyId)
@@ -4562,6 +4565,7 @@ private void SendRules(PlayerSession session)
AlienUfos = r.AlienUfos.ToString(),
Bandits = r.Bandits.ToString(),
InstantTravel = r.InstantTravel,
+ AutoAim = r.AutoAim,
VoiceChatEnabled = _config.VoiceChatEnabled,
});
}
@@ -4613,6 +4617,11 @@ static void Apply(string value, System.Action set)
Rules.EnvironmentalHazards = hz;
}
+ if (!string.IsNullOrEmpty(intent.AutoAim))
+ {
+ Rules.AutoAim = intent.AutoAim.Equals("On", System.StringComparison.OrdinalIgnoreCase);
+ }
+
_meta.RulesOverride = Rules.Clone(); // the world owns its rules — persist the edit
_repo.SaveMetadata(_meta);
@@ -4626,7 +4635,8 @@ static void Apply(string value, System.Action set)
_log.Info($"World rules updated by '{session.State.Name}': creatures={Rules.CreatureAbundance}, " +
$"planet={Rules.PlanetEnemies}, space={Rules.SpaceNpcEnemies}, ufos={Rules.AlienUfos}, " +
- $"bandits={Rules.Bandits}, instantTravel={Rules.InstantTravel}, hazards={Rules.EnvironmentalHazards}.");
+ $"bandits={Rules.Bandits}, instantTravel={Rules.InstantTravel}, hazards={Rules.EnvironmentalHazards}, " +
+ $"autoAim={Rules.AutoAim}.");
}
/// Rearranges the player's personal inventory by swapping two slots (B58 — customising the quick-bar,
diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs b/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs
index 295a099e..2dcacb30 100644
--- a/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs
+++ b/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs
@@ -425,8 +425,10 @@ private void SpawnPlanetEnemyNear(Shared.State.PlayerState player, bool asDrone)
private const float WreckCouplingRange = 64f; // bias spawns to a wreck within this of the player (P5)
private const int ScanDroneHover = 4; // blocks the flying scan-drone floats above the surface (P4)
- /// Player attacks a planet enemy or creature with the held tool/weapon. Server resolves the hit.
- public void AttackEntity(string playerId, string entityId)
+ /// Player attacks a planet enemy or creature with the held tool/weapon. Server resolves the hit.
+ /// The optional aim direction is the client's camera ray at the moment of firing (#693); a zero vector
+ /// (older client) skips the aim validation.
+ public void AttackEntity(string playerId, string entityId, float dirX = 0f, float dirY = 0f, float dirZ = 0f)
{
var session = FindSessionByPlayerId(playerId);
if (session is null)
@@ -434,21 +436,22 @@ public void AttackEntity(string playerId, string entityId)
return;
}
+ var dir = new Vector3f(dirX, dirY, dirZ);
if (_planetEnemies.FirstOrDefault(e => e.Id == entityId) is { } enemy)
{
- AttackCombatEntity(session, enemy, _planetEnemies, isCreature: false);
+ AttackCombatEntity(session, enemy, _planetEnemies, isCreature: false, dir);
return;
}
if (_creatures.FirstOrDefault(e => e.Id == entityId) is { } creature)
{
- AttackCombatEntity(session, creature, _creatures, isCreature: true);
+ AttackCombatEntity(session, creature, _creatures, isCreature: true, dir);
return;
}
if (_bandits.FirstOrDefault(e => e.Id == entityId) is { } bandit)
{
- AttackCombatEntity(session, bandit, _bandits, isCreature: false);
+ AttackCombatEntity(session, bandit, _bandits, isCreature: false, dir);
return;
}
@@ -461,7 +464,7 @@ public void AttackEntity(string playerId, string entityId)
private const double MeleeCooldown = 1.5; // melee weapons swing at most this often (B44)
private readonly Dictionary _meleeReadyAt = new(); // playerId → uptime the next melee swing is allowed
- private void AttackCombatEntity(PlayerSession session, CombatEntity target, List list, bool isCreature)
+ private void AttackCombatEntity(PlayerSession session, CombatEntity target, List list, bool isCreature, Vector3f aimDir = default)
{
var p = session.State;
var tool = ActiveTool(p);
@@ -495,6 +498,11 @@ private void AttackCombatEntity(PlayerSession session, CombatEntity target, List
return;
}
+ if (!ValidateAim(session, target, tool, isWeapon, aimDir))
+ {
+ return;
+ }
+
// Energy weapons (laser/plasma) draw suit energy per shot.
if (isWeapon && tool.EnergyPerUse > 0f)
{
@@ -566,13 +574,78 @@ private void AttackCombatEntity(PlayerSession session, CombatEntity target, List
}
}
+ /// Validates the client's claimed aim against the claimed target (#693). Anti-cheat guardrail,
+ /// not a precision hitbox: latency + interpolation mean the client's view lags the server's, so every
+ /// tolerance is generous — the client already did the precise crosshair test. A zero direction (older
+ /// client, or a melee swing) skips the angle checks entirely. With AutoAim ON the target only has to sit
+ /// in a wide forward cone; with AutoAim OFF the crosshair ray must actually pass near the target's body.
+ /// Ranged weapons additionally need a clear sightline — no shooting through walls.
+ private bool ValidateAim(PlayerSession session, CombatEntity target, ToolProperties tool, bool isWeapon, Vector3f aimDir)
+ {
+ float dirLenSq = aimDir.X * aimDir.X + aimDir.Y * aimDir.Y + aimDir.Z * aimDir.Z;
+ if (dirLenSq < 0.0001f)
+ {
+ return true; // no aim data (older client) — keep the legacy range-only behaviour
+ }
+
+ bool ranged = isWeapon && tool.Range > EnemyAttackReach;
+ var p = session.State;
+
+ // Ranged shots respect walls: the same voxel sightline that gates enemy bites (glass blocks it too).
+ if (ranged && !HasLineOfSight(p.Position, target.Position))
+ {
+ Reject(session, "attack", "No clear line of fire.");
+ return false;
+ }
+
+ const float eye = 1.5f; // matches HasLineOfSight/the client camera height
+ var dst = Unwrapped(p.Position, target.Position);
+ float tx = dst.X - p.Position.X;
+ float ty = (dst.Y + 0.9f) - (p.Position.Y + eye); // aim roughly at the body, not the feet
+ float tz = dst.Z - p.Position.Z;
+ float dist = (float)System.Math.Sqrt(tx * tx + ty * ty + tz * tz);
+ if (dist < 0.75f)
+ {
+ return true; // point-blank — any angle is honest
+ }
+
+ float dirLen = (float)System.Math.Sqrt(dirLenSq);
+ float dot = (aimDir.X * tx + aimDir.Y * ty + aimDir.Z * tz) / (dirLen * dist);
+
+ // Ray-precision only for ranged manual aiming; melee and auto-aim keep a wide forward cone.
+ if (!Rules.AutoAim && ranged)
+ {
+ // Perpendicular miss distance of the crosshair ray from the target's body centre, with a
+ // body-size + distance-scaled corridor (≈6° plus the body itself).
+ float along = System.Math.Max(0f, dot) * dist;
+ float missSq = dist * dist - along * along;
+ float scale = System.Math.Max(1f, System.Math.Max(target.Scale, target.SizeScale));
+ float allowed = 1.5f * scale + 0.1f * dist;
+ if (dot <= 0f || missSq > allowed * allowed)
+ {
+ Reject(session, "attack", "Shot went wide.");
+ return false;
+ }
+
+ return true;
+ }
+
+ if (dot < 0.35f) // ~70° half-angle: forgiving even for a swirling melee fight, but never behind the back
+ {
+ Reject(session, "attack", "Target is not in front of you.");
+ return false;
+ }
+
+ return true;
+ }
+
// Bandits ride the planet-enemy wire (same list message), so client targeting/health bars/defeat
// handling work unchanged — the client tells them apart by the Kind string.
private void BroadcastPlanetEnemies()
=> BroadcastToWorld(new PlanetEnemyList { Enemies = _planetEnemies.Concat(_bandits).Select(ToNet).ToArray() });
private void HandleAttackEntity(PlayerSession session, AttackEntityIntent intent)
- => AttackEntity(session.State.PlayerId, intent.EntityId);
+ => AttackEntity(session.State.PlayerId, intent.EntityId, intent.DirX, intent.DirY, intent.DirZ);
// ---------------- Test hooks ----------------
diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs
index 47619ab4..95943f19 100644
--- a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs
+++ b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs
@@ -245,7 +245,17 @@ public sealed partial class GameServer
// weapon_class: 0 = mining tool (breaks asteroids, can't hit hostiles), 1 = combat weapon (hits
// hostiles; breaks asteroids only where AsteroidDestruction allows weapons), 2 = dual laser (does both —
// the starter ship laser, so one weapon mines AND fights).
- private readonly record struct WeaponSpec(float Damage, float Range, double Cooldown, bool IsCombat, bool CanMine);
+ private readonly record struct WeaponSpec(float Damage, float Range, double Cooldown, float Energy, bool IsCombat, bool CanMine);
+
+ // #694: ship weapons are rate-limited and energy-gated SERVER-side now (both stats existed in
+ // data/ship_modules.json but were never enforced — the only limit was the client's local fire timer).
+ private readonly Dictionary _shipWeaponReadyAt = new(); // "playerId|weaponKey" → uptime next shot is allowed
+
+ // Ship energy is a lazily-regenerating pool fed by the reactor's energy_production: capacity = a few
+ // seconds of production, refill = production per second. With the stock reactor it never throttles
+ // legitimate fire (production far outpaces any weapon's draw) — it exists so weapon_energy is real
+ // and modded rapid-fire clients drain dry instead of firing forever.
+ private readonly Dictionary _shipEnergyByPlayer = new();
/// True while the player is flying in a space instance.
public bool InSpace(string playerId) => _playerInstance.ContainsKey(playerId);
@@ -738,7 +748,7 @@ private void SpawnGuardianGauntlet(SpaceInstance instance)
// ---------------- Weapons ----------------
/// Fires a built ship weapon at a target entity. Server-authoritative: validates rules, range and resolves the hit.
- public void FireWeapon(string playerId, string weaponKey, string targetId)
+ public void FireWeapon(string playerId, string weaponKey, string targetId, float dirX = 0f, float dirY = 0f, float dirZ = 0f)
{
var session = FindSessionByPlayerId(playerId);
@@ -760,6 +770,16 @@ public void FireWeapon(string playerId, string weaponKey, string targetId)
return;
}
+ // #694: the module's fire rate is authoritative now (it was client-only before). A small slack
+ // absorbs network jitter so an honest client firing exactly on cadence never gets rejected.
+ // The cooldown is only COMMITTED once the shot actually fires (below) — a rejected shot
+ // (bad target/arc/rules) must not eat the cycle.
+ string cdKey = playerId + "|" + weaponKey;
+ if (weapon.Cooldown > 0.0 && _shipWeaponReadyAt.TryGetValue(cdKey, out var readyAt) && _uptime < readyAt)
+ {
+ return; // still cycling — swallow silently (no reject spam while the trigger is held)
+ }
+
var target = instance.Entities.FirstOrDefault(e => e.Id == targetId);
if (target is null)
{
@@ -773,12 +793,29 @@ public void FireWeapon(string playerId, string weaponKey, string targetId)
return;
}
+ if (!ValidateSpaceAim(session, instance, target, dirX, dirY, dirZ))
+ {
+ return;
+ }
+
if (!WeaponAllowedAgainst(weapon, target, out var reason))
{
RejectSpace(session, reason);
return;
}
+ // #694: weapon_energy draws from the reactor-fed pool (was defined in the module data but unused).
+ if (!TryDrawShipEnergy(playerId, weapon.Energy))
+ {
+ RejectSpace(session, "Not enough ship energy to fire.");
+ return;
+ }
+
+ if (weapon.Cooldown > 0.0)
+ {
+ _shipWeaponReadyAt[cdKey] = _uptime + weapon.Cooldown * 0.95;
+ }
+
target.Hull -= weapon.Damage;
// item 20 S3: a voxel ore asteroid carves down to match its hull as you shoot it (visible depletion).
@@ -850,6 +887,55 @@ public void FireWeapon(string playerId, string weaponKey, string targetId)
BroadcastSpaceState(instance);
}
+ /// Validates the client's reported firing direction (the ship's nose) against the claimed
+ /// target (#693). Mirrors the on-foot ValidateAim: generous tolerances (latency, drifting
+ /// targets), zero direction = older client = skip. AutoAim ON needs the target roughly ahead;
+ /// AutoAim OFF needs a genuine boresight line — the nose ray must pass near the target's body.
+ private bool ValidateSpaceAim(PlayerSession? session, SpaceInstance instance, CombatEntity target, float dirX, float dirY, float dirZ)
+ {
+ float dirLenSq = dirX * dirX + dirY * dirY + dirZ * dirZ;
+ if (dirLenSq < 0.0001f)
+ {
+ return true; // no aim data (older client) — keep the legacy range-only behaviour
+ }
+
+ float tx = target.Position.X - instance.ShipPosition.X;
+ float ty = target.Position.Y - instance.ShipPosition.Y;
+ float tz = target.Position.Z - instance.ShipPosition.Z;
+ float dist = (float)System.Math.Sqrt(tx * tx + ty * ty + tz * tz);
+ if (dist < 3f)
+ {
+ return true; // point-blank
+ }
+
+ float dirLen = (float)System.Math.Sqrt(dirLenSq);
+ float dot = (dirX * tx + dirY * ty + dirZ * tz) / (dirLen * dist);
+
+ if (!Rules.AutoAim)
+ {
+ // Boresight: perpendicular miss distance of the nose ray from the target's centre, allowing
+ // the body itself plus a distance-scaled corridor (space entities are big and drift fast).
+ float along = System.Math.Max(0f, dot) * dist;
+ float missSq = dist * dist - along * along;
+ float allowed = 2.5f * System.Math.Max(1f, target.Scale) + 0.1f * dist;
+ if (dot <= 0f || missSq > allowed * allowed)
+ {
+ RejectSpace(session, "Shot went wide — line up the target.");
+ return false;
+ }
+
+ return true;
+ }
+
+ if (dot < 0.5f) // ~60°: server-side guardrail above the client's ~±30° acquisition cone
+ {
+ RejectSpace(session, "Target is outside the firing arc.");
+ return false;
+ }
+
+ return true;
+ }
+
private const int LargeAsteroidTier = 2;
private const int AsteroidSplitCount = 2;
@@ -968,11 +1054,56 @@ private bool TryGetWeapon(string moduleKey, out WeaponSpec spec)
Damage: (float)def.Stats.GetValueOrDefault("weapon_damage", 10),
Range: (float)def.Stats.GetValueOrDefault("weapon_range", 50),
Cooldown: def.Stats.GetValueOrDefault("weapon_cooldown", 1.0),
+ Energy: (float)def.Stats.GetValueOrDefault("weapon_energy", 0),
IsCombat: weaponClass >= 1, // combat weapons + dual lasers can hit hostiles
CanMine: weaponClass == 0 || weaponClass == 2); // mining tools + dual lasers can break asteroids
return true;
}
+ /// Total reactor output of the current ship (energy per second) — feeds the weapon-energy pool.
+ private float ShipEnergyProduction()
+ {
+ float prod = 0f;
+ foreach (var key in _ship.Modules)
+ {
+ if (_content.GetShipModule(key) is { } m)
+ {
+ prod += (float)m.Stats.GetValueOrDefault("energy_production", 0);
+ }
+ }
+
+ return prod;
+ }
+
+ /// Tries to draw from the player's lazily-regenerating ship-energy
+ /// pool (#694). Capacity is ~3 s of reactor output; refill happens on access, so no per-tick work.
+ private bool TryDrawShipEnergy(string playerId, float amount)
+ {
+ if (amount <= 0f)
+ {
+ return true;
+ }
+
+ float production = ShipEnergyProduction();
+ if (production <= 0f)
+ {
+ return true; // no reactor data on this ship — never lock the trigger over a missing stat
+ }
+
+ float capacity = System.Math.Max(amount, production * 3f);
+ var pool = _shipEnergyByPlayer.TryGetValue(playerId, out var state)
+ ? System.Math.Min(capacity, state.Energy + (float)((_uptime - state.Time) * production))
+ : capacity;
+ if (pool < amount)
+ {
+ _shipEnergyByPlayer[playerId] = (_uptime, pool);
+ return false;
+ }
+
+ _shipEnergyByPlayer[playerId] = (_uptime, pool - amount);
+ return true;
+ }
+
// ---------------- Ship flight (position in the instance) ----------------
private const float ShipCollisionRadius = 3f;
@@ -1850,5 +1981,5 @@ private void HandleLeaveSpace(PlayerSession session, LeaveSpaceIntent intent)
}
private void HandleFireWeapon(PlayerSession session, FireWeaponIntent intent)
- => FireWeapon(session.State.PlayerId, intent.WeaponKey, intent.TargetEntityId);
+ => FireWeapon(session.State.PlayerId, intent.WeaponKey, intent.TargetEntityId, intent.DirX, intent.DirY, intent.DirZ);
}
diff --git a/src/BlocksBeyondTheStars.Networking/Messages.cs b/src/BlocksBeyondTheStars.Networking/Messages.cs
index 01bb26bf..14e1970f 100644
--- a/src/BlocksBeyondTheStars.Networking/Messages.cs
+++ b/src/BlocksBeyondTheStars.Networking/Messages.cs
@@ -250,17 +250,28 @@ public sealed class TravelIntent
public int PadIndex { get; set; } = -1;
}
-/// Client fires a built ship weapon at a space entity. The server validates and resolves the hit.
+/// Client fires a built ship weapon at a space entity. The server validates and resolves the hit.
+/// Contractless-additive aim fields (#693): the ship's forward direction at the moment of firing, so the
+/// server can enforce a firing arc. An all-zero direction (older client) skips the arc check.
public sealed class FireWeaponIntent
{
public string WeaponKey { get; set; } = string.Empty;
public string TargetEntityId { get; set; } = string.Empty;
+ public float DirX { get; set; }
+ public float DirY { get; set; }
+ public float DirZ { get; set; }
}
-/// Client attacks a planet enemy with the held tool/weapon. The server resolves the hit.
+/// Client attacks a planet enemy with the held tool/weapon. The server resolves the hit.
+/// Contractless-additive aim fields (#693): the camera-ray direction of the shot, so the server can
+/// validate the claimed target against where the player was actually looking (angle + line of sight).
+/// An all-zero direction (older client) skips those checks.
public sealed class AttackEntityIntent
{
public string EntityId { get; set; } = string.Empty;
+ public float DirX { get; set; }
+ public float DirY { get; set; }
+ public float DirZ { get; set; }
}
/// Client eats/uses a consumable item (food heals, poison harms). The server applies it.
@@ -867,6 +878,10 @@ public sealed class ServerRules
/// false it is limited to bodies the player has already landed on (default).
public bool InstantTravel { get; set; }
+ /// Auto-aim world option (#693): when true (default) weapons acquire targets in a forward cone
+ /// automatically; when false only the entity under the crosshair can be hit (manual aiming).
+ public bool AutoAim { get; set; } = true;
+
/// Whether the server accepts/relays live voice chat (opt-in; default off on dedicated servers).
/// When false the client keeps voice capture disabled and shows voice comms as unavailable.
public bool VoiceChatEnabled { get; set; }
@@ -895,6 +910,9 @@ public sealed class SetWorldRulesIntent
/// Environmental-hazards tier: "Off"/"Light"/"Normal"/"Hard" to set it, empty to leave
/// unchanged (#670) — the live switch for the temperature survival hazard.
public string EnvironmentalHazards { get; set; } = string.Empty;
+
+ /// Auto-aim toggle (#693): "On"/"Off" to set it, empty to leave unchanged.
+ public string AutoAim { get; set; } = string.Empty;
}
// --- Missions ---
diff --git a/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs b/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs
index 0f9db79a..b02175c8 100644
--- a/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs
+++ b/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs
@@ -197,6 +197,12 @@ public sealed class GameRules
/// world admin.
public bool InstantTravel { get; set; }
+ /// Auto-aim (world option, default ON — issue #693): when ON, weapons acquire the best target in
+ /// a forward cone automatically (kid/gamepad-friendly); when OFF the shot only hits what is actually under
+ /// the crosshair, and the server validates the shot's aim direction strictly. Live-editable by the world
+ /// admin. Old saves deserialize without the field and keep the ON default — no start-up lift needed.
+ public bool AutoAim { get; set; } = true;
+
/// Whether crafting consumes materials / needs stations (false in Creative).
public bool CraftingCostsMaterials => GameMode != GameMode.Creative;
diff --git a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs
index d74a64f1..d97a505c 100644
--- a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs
+++ b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs
@@ -499,6 +499,10 @@ public IReadOnlyList ApplyCommandLine(string[]? args)
case "keep-ship":
if (bool.TryParse(value, out var ks)) { Rules.KeepShipOnDeath = ks; applied.Add("keep-ship"); }
break;
+ case "auto-aim":
+ // #693: manual aiming — weapons only hit what is under the crosshair when off.
+ if (bool.TryParse(value, out var aa)) { Rules.AutoAim = aa; applied.Add("auto-aim"); }
+ break;
case "story":
Rules.StoryId = value; applied.Add("story"); // pack id, "none" for sandbox, or "default"/empty
break;
diff --git a/tests/BlocksBeyondTheStars.Tests/AimValidationTests.cs b/tests/BlocksBeyondTheStars.Tests/AimValidationTests.cs
new file mode 100644
index 00000000..cede2c8d
--- /dev/null
+++ b/tests/BlocksBeyondTheStars.Tests/AimValidationTests.cs
@@ -0,0 +1,208 @@
+// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games)
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text.
+using System.Linq;
+using BlocksBeyondTheStars.GameServer;
+using BlocksBeyondTheStars.Networking.Transport;
+using BlocksBeyondTheStars.Persistence;
+using BlocksBeyondTheStars.Shared.Configuration;
+using BlocksBeyondTheStars.Shared.Content;
+using BlocksBeyondTheStars.Shared.Geometry;
+using BlocksBeyondTheStars.Shared.Primitives;
+using BlocksBeyondTheStars.Shared.State;
+using Xunit;
+using SvGameServer = BlocksBeyondTheStars.GameServer.GameServer;
+
+namespace BlocksBeyondTheStars.Tests;
+
+///
+/// Server-side aim validation (#693): the client now reports the shot's aim direction, and the
+/// server checks the claimed target against it — a forward cone with the AutoAim world rule ON,
+/// a genuine crosshair/boresight line with it OFF, plus line-of-sight for ranged shots. A zero
+/// direction (older client) keeps the legacy range-only behaviour. Also covers the ship-weapon
+/// cooldown that #694 made authoritative.
+///
+public sealed class AimValidationTests : IDisposable
+{
+ private readonly string _root;
+ private readonly GameContent _content;
+
+ public AimValidationTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "bbts_aim_" + Guid.NewGuid().ToString("N"));
+ _content = ContentLoader.LoadFromDirectory(TestPaths.DataDir());
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
+ }
+
+ private SvGameServer Started(out SqliteWorldRepository repo, Action? rules = null)
+ {
+ repo = new SqliteWorldRepository(new SaveGamePaths(_root, "aim"));
+ var st = new LoopbackServerTransport(new LoopbackLink());
+ var config = new ServerConfig
+ {
+ WorldName = "aim",
+ Seed = 777,
+ StartPlanet = "jungle",
+ AutoSaveIntervalMinutes = 9999,
+ PlaceStarterShip = false,
+ };
+ rules?.Invoke(config.Rules);
+ var server = new SvGameServer(config, _content, st, repo);
+ server.Start();
+ return server;
+ }
+
+ /// Player at y 300 (open sky — nothing ground-snaps while no ticks run) with a machine
+ /// 10 blocks along +X. The laser pistol is ranged + energy-gated with no swing cooldown, so
+ /// repeat shots need no time advancement.
+ private static void Arrange(SvGameServer server, out string enemyId)
+ {
+ var p = server.AddLocalPlayer("Gunner");
+ p.State.AboardShip = false;
+ p.State.Position = new Vector3f(0f, 300f, 0f);
+ p.State.SuitEnergy = 100f;
+ p.State.Inventory.SetSlot(0, new ItemStack("laser_pistol", 1));
+ p.State.SelectedHotbarSlot = 0;
+ server.SpawnPlanetEnemyAtForTest(new Vector3f(10f, 300f, 0f));
+ enemyId = server.PlanetEnemies[^1].Id;
+ }
+
+ [Fact]
+ public void LegacyShot_WithoutAimDirection_StillHits()
+ {
+ var server = Started(out var repo);
+ using (repo)
+ {
+ Arrange(server, out var enemyId);
+ var enemy = server.PlanetEnemies.First(e => e.Id == enemyId);
+ server.AttackEntity("Gunner", enemyId); // zero direction = pre-#693 client
+ Assert.True(enemy.Hull < enemy.HullMax, "a legacy client's shot must keep working");
+ }
+ }
+
+ [Fact]
+ public void AutoAim_RejectsATargetBehindTheBack()
+ {
+ var server = Started(out var repo);
+ using (repo)
+ {
+ Arrange(server, out var enemyId);
+ var enemy = server.PlanetEnemies.First(e => e.Id == enemyId);
+
+ server.AttackEntity("Gunner", enemyId, dirX: -1f, dirY: 0f, dirZ: 0f); // looking AWAY
+ Assert.Equal(enemy.HullMax, enemy.Hull);
+
+ server.AttackEntity("Gunner", enemyId, dirX: 1f, dirY: 0f, dirZ: 0f); // looking at it
+ Assert.True(enemy.Hull < enemy.HullMax);
+ }
+ }
+
+ [Fact]
+ public void ManualAim_OnlyTheCrosshairLineHits()
+ {
+ var server = Started(out var repo);
+ using (repo)
+ {
+ server.SetAutoAimForTest(false);
+ Arrange(server, out var enemyId);
+ var enemy = server.PlanetEnemies.First(e => e.Id == enemyId);
+
+ // 45° off the target: inside the old auto-aim cone, but not a crosshair hit — rejected.
+ server.AttackEntity("Gunner", enemyId, dirX: 0.707f, dirY: 0f, dirZ: 0.707f);
+ Assert.Equal(enemy.HullMax, enemy.Hull);
+
+ // Dead on: the ray passes through the body — the shot lands.
+ server.AttackEntity("Gunner", enemyId, dirX: 1f, dirY: 0f, dirZ: 0f);
+ Assert.True(enemy.Hull < enemy.HullMax);
+ }
+ }
+
+ [Fact]
+ public void RangedShot_WithAimData_IsBlockedByAWall()
+ {
+ var server = Started(out var repo);
+ using (repo)
+ {
+ Arrange(server, out var enemyId);
+ var enemy = server.PlanetEnemies.First(e => e.Id == enemyId);
+
+ // A pillar squarely on the eye-line (sight runs at ~y 301.5 between the two).
+ var stone = _content.GetBlock("stone")!.NumericId;
+ server.World.SetBlock(new Vector3i(5, 300, 0), stone);
+ server.World.SetBlock(new Vector3i(5, 301, 0), stone);
+ server.World.SetBlock(new Vector3i(5, 302, 0), stone);
+
+ server.AttackEntity("Gunner", enemyId, dirX: 1f, dirY: 0f, dirZ: 0f);
+ Assert.Equal(enemy.HullMax, enemy.Hull); // no shooting through walls
+
+ // Clear the wall — the same shot lands.
+ server.World.SetBlock(new Vector3i(5, 300, 0), BlockId.Air);
+ server.World.SetBlock(new Vector3i(5, 301, 0), BlockId.Air);
+ server.World.SetBlock(new Vector3i(5, 302, 0), BlockId.Air);
+ server.AttackEntity("Gunner", enemyId, dirX: 1f, dirY: 0f, dirZ: 0f);
+ Assert.True(enemy.Hull < enemy.HullMax);
+ }
+ }
+
+ [Fact]
+ public void ShipWeapon_HonoursFiringArc_WithAimData()
+ {
+ var server = Started(out var repo, r =>
+ {
+ r.FreeSpaceFlight = true;
+ r.SpaceCombat = SpaceCombatMode.PvE;
+ r.SpaceNpcEnemies = AlienActivity.Rare; // 1 drone
+ r.ShipWeapons = ShipWeaponMode.NpcsOnly;
+ });
+ using (repo)
+ {
+ server.AddLocalPlayer("Gunner");
+ server.Ship.Modules.Add("ship_cannon_1");
+ server.EnterSpace("Gunner");
+
+ var drone = server.SpaceEntitiesFor("Gunner").First(e => e.Kind == CombatEntityKind.Drone);
+ server.ShipMove("Gunner", drone.Position.X, drone.Position.Y, drone.Position.Z - 10f);
+
+ // Nose pointing away from the drone: outside the arc — rejected, and the cooldown is NOT eaten.
+ server.FireWeapon("Gunner", "ship_cannon_1", drone.Id, dirX: 0f, dirY: 0f, dirZ: -1f);
+ Assert.Equal(drone.HullMax, drone.Hull);
+
+ // Nose on the drone: the shot lands.
+ server.FireWeapon("Gunner", "ship_cannon_1", drone.Id, dirX: 0f, dirY: 0f, dirZ: 1f);
+ Assert.True(drone.Hull < drone.HullMax);
+ }
+ }
+
+ [Fact]
+ public void ShipWeapon_CooldownIsServerEnforced()
+ {
+ var server = Started(out var repo, r =>
+ {
+ r.FreeSpaceFlight = true;
+ r.SpaceCombat = SpaceCombatMode.PvE;
+ r.SpaceNpcEnemies = AlienActivity.Rare; // 1 drone, hull 40
+ r.ShipWeapons = ShipWeaponMode.NpcsOnly;
+ });
+ using (repo)
+ {
+ server.AddLocalPlayer("Gunner");
+ server.Ship.Modules.Add("ship_cannon_1"); // 20 dmg, 1.0 s cooldown
+ server.EnterSpace("Gunner");
+
+ var drone = server.SpaceEntitiesFor("Gunner").First(e => e.Kind == CombatEntityKind.Drone);
+ server.ShipMove("Gunner", drone.Position.X, drone.Position.Y, drone.Position.Z);
+
+ server.FireWeapon("Gunner", "ship_cannon_1", drone.Id); // 40 -> 20
+ server.FireWeapon("Gunner", "ship_cannon_1", drone.Id); // still cycling — swallowed (#694)
+ Assert.Equal(drone.HullMax - 20f, drone.Hull);
+
+ server.TickForTest(1.1); // cooldown cycles
+ server.FireWeapon("Gunner", "ship_cannon_1", drone.Id); // 20 -> 0: destroyed
+ Assert.DoesNotContain(server.SpaceEntitiesFor("Gunner"), e => e.Id == drone.Id);
+ }
+ }
+}
diff --git a/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs b/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs
index 06e4c4ec..44cc307e 100644
--- a/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs
+++ b/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs
@@ -161,6 +161,18 @@ public void ApplyCommandLine_OverridesShipWeaponsAndKeepRules()
Assert.Contains("keep-inventory", applied);
}
+ [Fact]
+ public void ApplyCommandLine_OverridesAutoAim()
+ {
+ // #693: auto-aim is a world rule — ON by default, --auto-aim false mandates manual aiming.
+ Assert.True(new ServerConfig().Rules.AutoAim);
+
+ var config = new ServerConfig();
+ var applied = config.ApplyCommandLine(new[] { "--auto-aim", "false" });
+ Assert.False(config.Rules.AutoAim);
+ Assert.Contains("auto-aim", applied);
+ }
+
[Fact]
public void ApplyCommandLine_OverridesStructureTemplateOptions()
{
diff --git a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs
index 5525c3c0..a93271d9 100644
--- a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs
+++ b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs
@@ -622,11 +622,13 @@ public void AsteroidBreaker_BreaksAsteroidsDown_AndEventuallyYieldsLoot()
server.EnterSpace("Pilot");
// Keep breaking the nearest asteroid: large -> medium -> small -> mineral drops.
+ // The tick lets the weapon's server-enforced cooldown cycle between shots (#694).
for (int i = 0; i < 16 && pilot.State.Inventory.CountOf("iron_ore") == 0; i++)
{
var a = server.SpaceEntitiesFor("Pilot").FirstOrDefault(e => e.Kind == CombatEntityKind.Asteroid);
if (a is null) break;
server.FireWeapon("Pilot", "asteroid_breaker", a.Id);
+ server.TickForTest(2.0);
}
Assert.True(pilot.State.Inventory.CountOf("iron_ore") >= 5, "Breaking asteroids down should eventually drop ore.");
@@ -659,8 +661,10 @@ public void Shoot_CarvesVoxelAsteroid_ThenDestroysIt()
Assert.True(blocksAfter < blocksBefore, "shooting should carve voxel blocks off the asteroid");
// Keep firing until it's destroyed → its structure is gone and ore was banked.
+ // The tick lets the weapon's server-enforced cooldown cycle between shots (#694).
for (int i = 0; i < 16 && server.SpaceEntitiesFor("Pilot").Any(e => e.Id == ast.Id); i++)
{
+ server.TickForTest(2.0);
server.FireWeapon("Pilot", "asteroid_breaker", ast.Id);
}
@@ -714,6 +718,7 @@ public void ShipCannon_DestroysDrone_WhenWeaponsAllowed()
var drone = server.SpaceEntitiesFor("Pilot").First(e => e.Kind == CombatEntityKind.Drone);
server.ShipMove("Pilot", drone.Position.X, drone.Position.Y, drone.Position.Z); // close to fire (range from ship)
server.FireWeapon("Pilot", "ship_cannon_1", drone.Id); // 40 -> 20
+ server.TickForTest(1.1); // let the cannon's server-enforced cooldown cycle (#694)
server.FireWeapon("Pilot", "ship_cannon_1", drone.Id); // destroyed
Assert.DoesNotContain(server.SpaceEntitiesFor("Pilot"), e => e.Id == drone.Id);
@@ -1062,10 +1067,12 @@ public void TractorBeam_PullsSalvageDrops_IntoCargo()
server.Ship.Modules.Add("tractor_beam"); // with a tractor, the smallest chunks float as salvage
server.EnterSpace("Pilot");
- // Break asteroids down until a floating salvage drop appears.
+ // Break asteroids down until a floating salvage drop appears. The tick (BEFORE the shot, so a
+ // fresh drop is never passively swept up mid-loop) lets the server-enforced cooldown cycle (#694).
CombatEntity? drop = null;
for (int i = 0; i < 24 && drop == null; i++)
{
+ server.TickForTest(2.0);
var a = server.SpaceEntitiesFor("Pilot").FirstOrDefault(e => e.Kind == CombatEntityKind.Asteroid);
if (a != null)
{
@@ -1102,9 +1109,11 @@ public void TractorBeam_AimedPull_CollectsDrop_BeyondPassiveRange()
server.Ship.Modules.Add("tractor_beam");
server.EnterSpace("Pilot");
+ // Tick BEFORE the shot (cooldown cycles, and a fresh drop is never passively swept mid-loop, #694).
CombatEntity? drop = null;
for (int i = 0; i < 24 && drop == null; i++)
{
+ server.TickForTest(2.0);
var a = server.SpaceEntitiesFor("Pilot").FirstOrDefault(e => e.Kind == CombatEntityKind.Asteroid);
if (a != null)
{
diff --git a/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs b/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs
index 0a4c8d4a..cb49f8d8 100644
--- a/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs
+++ b/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs
@@ -229,7 +229,7 @@ public void CreatureAbundance_IsLiveEditable_ByTheWorldAdmin_AndPersists()
server.Start();
JoinAndDrain(server, client, "Admin"); // the first player becomes the world admin
- client.Send(NetCodec.Encode(new SetWorldRulesIntent { CreatureAbundance = "Off", PlanetEnemies = "Extreme" }),
+ client.Send(NetCodec.Encode(new SetWorldRulesIntent { CreatureAbundance = "Off", PlanetEnemies = "Extreme", AutoAim = "Off" }),
DeliveryMode.ReliableOrdered);
server.Tick(0.1);
@@ -238,6 +238,7 @@ public void CreatureAbundance_IsLiveEditable_ByTheWorldAdmin_AndPersists()
Assert.NotNull(meta?.RulesOverride);
Assert.Equal(AlienActivity.Off, meta!.RulesOverride!.CreatureAbundance);
Assert.Equal(AlienActivity.Extreme, meta.RulesOverride.PlanetEnemies);
+ Assert.False(meta.RulesOverride.AutoAim); // #693: the aim rule rides the same live-edit path
}
[Fact]