From e729e6d6d57dda3d5fe2c381009624403cf3c4ee Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 00:59:15 +0200 Subject: [PATCH 01/45] Mining campaign: target and cut map Operator, 2026-09-25: target the chunk, hit it with a weapon whose damage suits the ore, and roll a weighted loot table from damage type, composition and weapon stats. First cut slims the belt simulation, which probes show is the heaviest per-tick cost and a data race. Co-Authored-By: Claude Opus 5.5 --- docs/mining-cut.md | 816 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 816 insertions(+) create mode 100644 docs/mining-cut.md diff --git a/docs/mining-cut.md b/docs/mining-cut.md new file mode 100644 index 000000000..064d55e63 --- /dev/null +++ b/docs/mining-cut.md @@ -0,0 +1,816 @@ +# Mining: Target and Cut Map + +Date: 2026-09-25 + +Status: target and cut map, from an Imagination pass. Nothing here has landed. The first half is the +**target** (the ends). The second half is the **cut map** (the means). When the campaign closes, split the target +out as `docs/mining-target.md` and keep this file as history. + +Anchors are against `codex/fire-control-12` HEAD `8bd25f6f`, read from a clean worktree of that commit. Claims +marked **(probe)** were measured by running code; claims marked **(read)** come from source at the cited anchor. +Probes live in the session scratchpad and are not kept in the repo: + +- `miningprobe`: a console build against `Aetheria.Shared` and `tools/AetherDb` (CultLib `45c2f40`, CultMath + `v0.2.4`). It opens the catalog and a copy of the operator's real save (`GameData/run.cc`, 2026-09-22), reads + `PlanetSettings` and `GameplaySettings` from `Assets/Resources/Settings.asset` through `AuthoredSettings`, builds + the densest saved zone as a real `Zone` (entities stripped so the belt cost is isolated), and times + `Zone.Update` over 3000 ticks at dt 1/60. Run ten times. +- `miningcensus`: walks every stored document in `GameData/Aetheria.cc`. +- `sensorbench`: the inner loop of `Sensor.Execute` (`Behaviors/Sensor.cs:163-189`) against a `ReactiveDictionary` + carrying the one `ObserveReplace` subscriber `Entity.Activate` installs (`Entity.cs:237-253`). +- The legacy record: `scratchpad/legacy-0305.json` and `legacy-0414.json` (decoded `AetherDB.msgpack` history and + `Legacy/AetherDB.2021-04-14.msgpack`), and the deleted `GameData/SimpleCommodityData/*.json` at `c6ffcad3^`. + +**Scope: mining only.** Fire control is mid-rewrite on this branch (Cut 12.3 and 12.4 of +`docs/fire-control-cut.md`, being worked in the main tree by other agents). Cuts 3 and 4 below edit `FireControl.cs` +and `Entity.cs` and must land after fire-control Cut 12 closes, or rebase onto it. Cut 1 and Cut 2 touch no +fire-control code. §Overlap names every shared line. + +--- + +# Part I: Target + +## Operator's words (2026-09-25) + +On the asteroid simulation: + +> "Calculating the positions and sampling all those gravity wells for every asteroid was literally the heaviest +> operation in the game, which is why it got explicit multi threading" +> +> "Would be nice if we could slim that down" + +The campaign: + +> "Make that the mining campaign, then. You target the chunk, hit it with a weapon that does the right kind of +> damage for the ore you want to extract, and each chunk rolls its loot table with a weight determined by some +> arcane combination of the damage type, the chunk's composition, and the stats of the weapon you hit it with." + +## Standing rulings this campaign obeys (not re-litigated) + +- **Same inputs.** "Player and AI are controlling the same ship with the same inputs" (`docs/locomotion-cut.md` + R2, branch `codex/locomotion`). A chunk becomes a target through the same slot, the same writer and the same + fire path for both. +- **Reciprocal formulas.** "When the formula is unintuitive, we don't make the handle more opaque for correctness, + we fix the formula. Plenty of core gameplay formulas are reciprocal just for this reason." Every weapon stat in + the loot weight reads as a benefit. +- **WeaponModifiers are labels, not behaviour** (fire-control Cut 12 ruling). The loot roll reads weapon data + fields (`WeaponData.DamageType`, `Damage`, `Penetration`), never `WeaponModifiers`. +- **Provenance** (`docs/item-provenance-target.md`): mined ore enters the economy as a lot in the one + `ProvenanceLedger`. Aetheria grows no second provenance owner. Branding is derived, never stored. +- **Content audits check pre-breach history.** Absence from `GameData/Aetheria.cc` is not absence. Recoverable + content is an operator review item (Cut 7). +- **A shot is decided at Commit and performed at arrival** (fire-control R4). The shot's dice are its own + (Cut 6b, 9.1: `MixSeed(CombatSeed, ShotId)`); nothing reads a shared stream. +- Delete before adding. A gap goes to its owner. A new persisted field on an existing document is nullable, or + has a default that the MessagePack nil rule already produces. + +## Objective + +A player or an AI targets an asteroid chunk, fires the weapon whose damage type suits the ore it wants, and each +hit yields ore into its cargo, weighted by the chunk's composition, the damage type and the weapon's stats. Every +unit is born as a lot with honest provenance: extracted, here, from this belt. The belt simulation behind it costs +nothing per tick. + +## Invariants + +1. **A chunk's pose is a pure function of time.** Position and rotation are evaluated when something asks: the + renderer, targeting, range, hit resolution. Nothing stores a pose. There is one orbit function, and if a GPU copy + is ever added, a parity test pins it to that function. +2. **Zone.Update does no per-asteroid work** and starts no threads. +3. **One target slot.** A ship has one current target, which is either a ship or a chunk, set by one writer path + that both the player and the AI call. The fire path reads the slot, not a parallel channel. +4. **One fire path.** A shot at a chunk is a `PendingShot` that goes through `FireControl.Fire`, `Step`, `Commit` + and `Apply`, priced from the same factor functions a shot at a ship uses: Accuracy, PSensor, Sigma, PSpread and + the exact Gaussian mass over a silhouette. A chunk's silhouette is its disc. +5. **The loot is decided at Commit.** The yield and the ore picks come from the shot's own generator, in a fixed + draw order after the hit draw. Apply only performs them. +6. **Every mined unit carries a lot.** The lot is `Extracted` from this zone and this belt, and stacks merge only + within one lot. A mined unit never exists without a ledger entry. +7. **A chunk never yields more than its authored total**, whatever weapon breaks it. +8. **Composition is derived, never stored** (recommended in Q7): a pure function of the belt's identity and the + catalog's ore table. + +## Canonical implementations + +- Orbit evaluation: `OrbitData.Evaluate` (`ZoneData.cs`) and `Zone.GetOrbitPosition` (`Zone.cs:182-217`). Chunk + pose gets one owner beside them. +- The hit model: `FireControl` (`FireControl.cs`), Cut 12 shape. +- Cargo: `EquippedCargoBay.TryStore(SimpleCommodity)` (`Entity.cs:1808-1861`). Pickup already uses + `Entity.CargoBays.Any(c => c.TryStore(item))` (`Gameplay/ShieldManager.cs:44`). +- Provenance: `ProvenanceLedger`, `Lot`, `Extracted` (`Provenance.cs`); mints through `ItemManager`. +- Deterministic dice: `FireControl.MixSeed` (`FireControl.cs:461`) and `Zone.CombatSeed` (`Zone.cs:60`). + +## Not consumers + +- `AetheriaEve` and the old daemon rebuild: taxidermy. +- Njordr: shape precedent only (`Extracted { source, at }`, `njordr-engine/src/lib.rs:98`), adopted as local typed + state, as the provenance campaign already did. +- `Agents/Tasks/Survey.cs`, `HaulingTask.cs`, `StationTowing.cs`: data-only task classes with no reader. They are + not touched. +- The commented-out 2020 `MiningController` (`399b933a`, deleted in `c6ffcad3`): design intent only. It drove + `MiningTool` through a `Switch`. The operator's model replaces that tool. + +## Out of scope + +- Backward economy generation, routes and piracy (next scope, `docs/three-gates-scope.md:141-145`). This campaign + writes the first real `Extracted` lots, and generation will later synthesize more of the same shape. +- Refining, crafting from ore, prices for ore (every live simple commodity has `Price 0` **(probe)**). +- AI cargo offload at a home station (the 2020 controller's `GoHome`). An AI miner with full cargo stops mining. +- Electronic-warfare clutter ("a ship ... can float in a debris field or asteroid belt and pass as junk", + `docs/three-gates-scope.md:155-157`). This is parked there. Q2's recommendation keeps the door open, because a + chunk enters through the shared detection gate. +- Orbits beyond belts. `Zone.Update` still steps the planet orbits (`:156-161`), which cost about 31 us/tick in the + probe zone. That is cheap and out of scope. + +--- + +## 0b. Identity, lifecycle, authority + +| Kind | What names it | What happens to it over time | Who decides | +|---|---|---|---| +| **Chunk** | `ChunkId = (CultRecordKey Belt, int Index)`. `Belt` is the `AsteroidBeltData` record key in the run store; `Index` indexes its `Asteroids` array. Stable across save/continue, because `RunSave.Commit` writes the run store's whole view, bodies included (`SavedGame.cs:97-99`), and nothing rewrites `Asteroids` after generation. | Born at zone generation (`ZoneGenerator.cs:141-153`). Lives for the run. Removed with every `BodyData` by `RunSave.Clear` (`SavedGame.cs:124`). | `ZoneGenerator` writes `Asteroids` once. Nothing else may write it. | +| **Chunk pose** (position, rotation, velocity) | Derived: `(ChunkId, zone time)`. | Never stored. Evaluated on demand. | One function on `AsteroidBelt` (Cut 1). Forbidden writers: any stored pose array, any per-tick loop. | +| **Chunk wear** (damage taken, broken-until time) | Keyed by `ChunkId` inside the owning `Zone`. | Damage accumulates at Apply. When it reaches the chunk's hitpoints, the chunk breaks with `RespawnAt = zone time + AsteroidRespawnTime(size)`. At `time >= RespawnAt` it is whole again, read lazily with no timer. Persisted in `ZonePack` (new nullable key 6) with the zone's own `Time`, and pruned of expired entries at pack. | `FireControl.Apply` through one `Zone` method is the only writer. `Zone` owns the storage. | +| **Chunk composition** | Derived: `(belt identity, ChunkId, catalog ore table)`. | Never stored (recommended, Q7). A retune of the catalog retunes every belt. | The catalog author, through per-ore data on `SimpleCommodityData`. The derivation function is the only reader of that data for belts. | +| **Ore affinity and abundance** | Per ore: new nullable fields on `SimpleCommodityData` (keys 12 and 13; keys 6, 7, 8 and 11 are retired and must not be reused, `ItemData.cs:304`). | Authored in Studio. Changes with the catalog. | Catalog author. | +| **Global loot tunables** | `GameplaySettings` / `PlanetSettings` fields (Cut 5). | Authored. Today they live in `Settings.asset`. They move with the settings-globals campaign (`docs/settings-globals-cut.md`, mapped, not landed). | Catalog author. | +| **Mined lot** | `LotId` in the run's `ProvenanceLedger`. `Lot { Design = the commodity, Origin = Extracted { Zone, Commodity, Body }, Quality, Roles = null }`. | Minted at the first extraction from a (zone index, belt, commodity) source (Q6). Reused for later units from that source. Collected by `Reachable` when no instance reaches it. A later extraction after collection mints a fresh lot. | `ItemManager.ExtractedLot(zone, belt, commodity)` is the only writer. Its source→lot index is derived at load, not persisted. | +| **Mined unit** | A `SimpleCommodity` instance, `Data` = commodity, `Lot` = the source lot (`Lot` moves to the `ItemInstance` base, key 11). | Stored in cargo. Stacks merge only within the same `Data` and `Lot`. A split copies `Lot`. | `EquippedCargoBay` owns placement. The mint owns `Lot`. | +| **Current target** | `Entity.Target` (runtime; not packed, `EntitySerializer.cs` has no target field **(read)**). | Set by targeting inputs or AI state. Cleared by the existing entity-removal paths. A broken chunk stays held and prices 0 until retargeted (Cut 3). | Player: the `ActionGameManager` targeting handlers. AI: the Minion states. Both write the one slot. | +| **Mining shot** | `ShotId` (`Zone.NextShotId`), as every shot. | Fire → Commit → arrival. Never serialized (`FireControl.cs:852-855`). | `FireControl`. | + +No cell is empty. The cells marked "recommended" depend on the rulings below and are rewritten if a ruling goes the +other way. + +--- + +## Operator questions, in the order they block cuts + +Self delivers these one at a time (memory: pace operator questions). Cut 1 needs no ruling. The CPU-versus-GPU +belt renderer is a stated default: CPU, one orbit function, so no parity test is needed. Reopen it on request. + +**Q1 (blocks Cut 2). What becomes of `MiningTool`, `ResourceScanner` and the Drill Bit?** +The operator's model is "hit it with a weapon". `MiningTool` (`Behaviors/MiningTool.cs`, 76 lines) is a separate +damage channel with its own DPS, efficiency, penetration and range. Nothing assigns its target (`AsteroidBelt` and +`Asteroid` have no writer), and it throws `KeyNotFoundException` if it ever executes with an unset belt +(`:59` indexes before the `IsSet` check at `:60`). No live item carries it. The legacy catalog did: **Drill Bit** +(GearData; `MiningToolData` DPS 1-2.5, efficiency 2-5, penetration 1-5, range 5-20) and **Resource Scanner** +(`ResourceScannerData` range 100-200, minimum density 25-5, scan duration 5-2) are in both `legacy-0305` and +`legacy-0414` **(probe)**. +- A: Delete `MiningTool` and `MineAsteroid`. Retire union tag 26 (comment, do not reuse). Restore the Drill Bit in + Cut 7 as a short-range weapon, a `ConstantWeaponData` beam at Kinetic, penetration high and range 5-20, so + content keeps its meaning through the one fire path. +- B: Keep `MiningTool` as a second extraction channel beside weapons. +- **Recommended: A.** B is a second hit path with its own dice and its own yield rule, which is exactly the + split Cut 3 of fire control removed. `ResourceScanner` is Q2's business. + +**Q2 (blocks Cut 3). How does a chunk become a target?** This is the architectural fork. The options below were +costed against the live code: + +- **A. One slot, two kinds.** `Entity.Target` becomes `ReactiveProperty`, where `TargetRef` is a small + readonly value that is either an `Entity` or a `ChunkId`, with value equality. Readers that need a ship read + `Target.Value.Entity` (null for a chunk). FireControl's factor functions take a `TargetRef`, with the chunk + branch at each target-shaped read. Blast radius **(read)**: about 60 `Target` reads in 12 files + (`ActionGameManager.cs` 20, `FireControl.cs` 11, `Entity.cs` 8, `TurretController.cs` 7, `LockWeapon.cs` 5, + `EntityInstance.cs` 3, `Weapon.cs` 2, `Combat.cs`, `Minion.cs`, `Ship.cs:84`, `ActionBarSlot.cs`), plus + `PendingShot.Target` and `ShotOutcome.Target`. Most sites are mechanical (`.Entity`). +- **B. A parallel chunk slot.** `Entity.TargetChunk` (a `ReactiveProperty`) sits beside `Target`, + behind one writer that clears the other. About 15 sites. It represents one concept in two fields, and every + reader must know to check both. +- **C. Chunks become Entities.** Rejected on cost **(probe)**. One sensor-bearing entity pays **184 ns per + observed entity per tick**, which is **149 us/tick for the 809 chunks** of the probe zone. With ten ships in a + zone that is about 1.5 ms/tick, the same order as the per-asteroid gravity sampling the operator just asked to + remove (1.9 ms/tick, below). It would also add 809 entries and subscriptions to every entity's + `EntityInfoGathered`, a hull and schematic per rock, and `Entity.Update` per rock. +- **D. Promote a chunk to an Entity while targeted.** Rejected: its identity flips kind mid-shot, and a pending + shot would outlive its target's kind. + +**Recommended: A**, sequenced after fire-control Cut 12 closes. "A ship has one target, and a target is a ship or a +rock" is the one-owner sentence, and it matches the parked electronic-warfare direction: "every body presents a +signature through the shared detection interface". Consequences that come with A, named here and not decided +later: +- **Visibility.** A chunk passes the visibility gate within the shooter's chunk detection range, and its PSensor is + 1, because rocks do not hide. Recommend that range be the equipped `ResourceScanner`'s `Range`, with a + `GameplaySettings.UnaidedChunkRange` fallback. That is the `TargetingSystem`/`UnaidedTracking` precedent + (`FireControl.cs:121-125`), and it gives `ResourceScanner` a job: it is to chunks what the targeting system is to + ships. Its dead survey half (`ResourceScanner.cs:71-107`, `// TODO: Implement Scanning!`) is deleted. +- **Stance.** `StanceAllowsFire` (`Weapon.cs:101`) allows fire on a chunk. +- **Lock weapons.** Launchers cannot lock a chunk. `LockWeapon.cs:86` requires `IsHostileTo`, and rocks are not + hostile, so guided and lock launchers (GT 3K, LRMM72, SRMM72, pswarm, scorched void policy) cannot mine. Rule it + if that is wrong. +- **Blasts (fire-control 12.4).** A blast shot targeting a chunk delivers `damage × area(blast disc ∩ chunk disc) / + area(blast disc)`, which is 12.4's own area rule with the chunk disc as the only cell. Untargeted chunks near a + blast are unaffected, so there is no per-blast scan of belts. + +**Q3 (blocks Cut 2). Do chunks respawn, and does wear survive save/continue?** +Today `MineAsteroid` sets `RespawnTimers` (`Zone.cs:316`), but nothing ever decrements them, and `AsteroidExists` +(`:247`) ignores them. A broken asteroid renders at size 0 forever and can still be mined **(read)**. None of this +is persisted. +- A: Respawn after `AsteroidRespawnTime(size)` (authored 10-100 s), as an absolute `RespawnAt`, and persist wear in + `ZonePack`. +- B: Depletion is permanent for the run, persisted. +- C: No persistence. A belt heals when the zone reloads. +- **Recommended: A.** It keeps the authored curve, costs no timer, and makes save/continue honest. C turns a reload + into a belt reset. + +**Q4 (blocks Cut 5). The loot weight formula.** The operator owns this ("some arcane combination"). Proposed: + +For one hit by weapon `w` (damage type `d`, damage `D`, penetration `P`) on chunk `c` (size `s`): + +- **Units:** `n = min(D, remaining HP) / HP(s) × Yield(s)`, taken as its floor plus one Bernoulli draw on the + remainder. `HP` is the existing `AsteroidHitpoints` curve (authored 10-200). `Yield` is a new `PlanetSettings` + `ExponentialLerp`. *Why:* a chunk yields the same total however it is broken (Invariant 7). Damage is a benefit + because it gets you there faster. +- **Weight of ore `o`:** `w(o) = Affinity_o(d) × Comp_c(o)^(1/Depth)`, with `Depth = 1 + P × MiningDepthPerPenetration`. + - `Comp_c(o)`: what is in this rock (Q7). + - `Affinity_o(d)`: the right tool for the ore. It is authored per ore and per damage type, and defaults to 1. + - `Depth`: penetration digs past the common stuff. It flattens the composition toward the rare ores and never + makes the common ore impossible. Precedent: the 2020 and live rule `pow(x.Value, 1f / penetration)` + (`Zone.cs:323`). It is written as `1 + P·k` so that penetration 0, which covers 15 of 18 live weapons, reads as + the raw composition rather than dividing by zero. +- Each unit's ore is drawn from the normalized weights, in a fixed order after the hit draw. + +Worked examples. Composition is Rock .60, Water .20, Gold .15, Uranium .05. Illustrative affinities: Rock Kinetic +1.5, Water Thermal 2, Gold Electric 2, Uranium Optical 2, and 0.5 elsewhere. `MiningDepthPerPenetration = 4`. +Computed, not estimated: + +| Weapon (live stats) | d | P | Rock | Water | Gold | Uranium | +|---|---|---|---|---|---|---| +| Kinetic, pen 0 (Earp) | Kinetic | 0 | .818 | .091 | .068 | .023 | +| Autocannon | Kinetic | .25 | .687 | .132 | .115 | .066 | +| pretty pretty bang bang | Thermal | 0 | .375 | .500 | .094 | .031 | +| ChargeBlast+- | Electric | 0 | .414 | .138 | .414 | .034 | +| ColdFire | Optical | 0 | .522 | .174 | .130 | .174 | +| Optical at pen .25 (none exists) | Optical | .25 | .309 | .179 | .155 | .357 | + +So the right damage type roughly triples an ore's share, and penetration multiplies a rare ore's share further. +Alternatives to put to the operator: +- A: the formula above (Damage sets quantity; damage type and penetration set weights). +- B: A, plus `DamageSpread` as breadth: a yield multiplier `1 + DamageSpread × k`, which strips more rock per hit at + the raw composition. It is a benefit, and GT 3K, plight, pswarm and scorched void policy author it **(probe)**. +- C: A, with no penetration term (damage type and composition only). This is the simplest to read. +- **Recommended: A.** Every term is one sentence, and every weapon stat in it is a benefit. B is a clean add later, + because it changes no weight. Note: penetration is scheduled for a retune (fire-control F12-2, "Retuning + penetration is a follow up"). `MiningDepthPerPenetration` is the one knob that absorbs that retune. + +Tunables: `Affinity` and `Abundance` per ore on `SimpleCommodityData` (catalog, Studio). `MiningDepthPerPenetration` +on `GameplaySettings`. `AsteroidYield` and `ChunkCompositionVariance` on `PlanetSettings`. Hazard: none of them may be +CultMath-typed while settings are Unity-serialized (memory: Unity-serialized CultMath fields load as zero). + +**Q5 (blocks Cut 5). Is the roll per hit, or once when the chunk breaks?** +- A: Per hit, with units ∝ damage (as in Q4). Each hit's own weapon decides its ore, so switching guns mid-rock + matters, and the only state kept is HP. +- B: Once at break, weighted by the weapon that broke it. +- C: Once at break, with hits accumulating per-ore weight as they land. This needs per-chunk, per-ore state. +- **Recommended: A.** The operator's sentence ties each weight to "the weapon you hit it with". A gives continuous + feedback and keeps no memory. B lets a chip shot from the wrong gun at the end decide the whole rock. + +**Q6 (blocks Cut 5). Where does the ore go, and how big is a lot?** +- Destination. A: straight into the shooter's cargo at arrival, where a full hold loses the overflow. B: a floating + pickup. **Recommended: A.** Pickups are Unity-only (`ItemPickup` and collection by `ShieldManager.cs:40-55`), so + an AI could never collect them, which would break the same-inputs rule. +- Lot granularity. A: one lot per (zone, belt, commodity) source. B: one lot per hit. **Recommended: A.** B makes + every hit a separate cargo stack, because stacks merge only within one lot, and it grows the ledger per shot. A is + what backward generation will synthesize anyway: an extraction source at a place. +- Lot quality: 1 for extracted lots. Nothing reads a `SimpleCommodity`'s quality (price and tier read + `CraftedItemInstance` only, `ItemManager.cs:114,224`). Grade can arrive as its own ruling later. + +**Q7 (blocks Cut 5). Where does composition live?** +`BodyData.Resources` (key 4, `ZoneData.cs:53-54`) is on every body and has never had a writer since the 2020 +generator (`17673cd2`) was commented out. Its only reader is `MineAsteroid` (`Zone.cs:322-323`). No live belt has +resources **(probe: 8 belts, 0 authored)**. +- A: Composition is derived: `Comp_c(o) = Abundance_o × exp(Variance × g(hash(belt key, index, o)))`, normalized, + with `g ∈ [-1, 1]`. Nothing is stored. Existing saves work, and key 4 is retired. +- B: `ZoneGenerator` writes `BodyData.Resources` per belt at generation, and the chunk jitter is derived. Belts in + runs started before Cut 5 have empty compositions until New Game. +- C: Per-belt only, with no per-chunk variation. +- **Recommended: A.** It has one owner (the catalog) and nothing to migrate. It follows the same "derived, never + stored" spirit as branding. The operator's wording ("the chunk's composition") argues against C. If B is chosen, + its generator must seed from the belt's identity, not the shared generation stream (`ZoneGenerator.cs:48`), + because a new draw there would shift every later placement in newly generated zones. + +**Q8 (blocks Cut 7). The catalog has no Corrosive or Ionizing weapon, now or in its history.** Live weapons use +Kinetic (10), Optical (3), Electric (3) and Thermal (3) **(probe)**. `legacy-0305` and `legacy-0414` use the same +four types **(probe)**. An ore whose affinity peaks at Corrosive or Ionizing is unreachable. +- A: Author affinities over the four types that exist, and add Corrosive and Ionizing weapons when content wants + them. +- B: Author new Corrosive and Ionizing weapons in Cut 7. +- **Recommended: A**, with the gap recorded in Cut 7's review sheet. + +**Q9 (blocks Cut 6). Which AI ships mine?** Nothing assigns a `Mining` task today. `Zone.CreateAgent` +(`Zone.cs:122-129`) gives every NPC a `PatrolOrbitsTask`. +- A: In a zone with belts, an NPC whose loadout has a weapon with nonzero affinity to some belt ore gets a `Mining` + task on the nearest belt, with an authored share (`GameplaySettings.AgentMinerShare`). +- B: Only dedicated miner hulls or factions mine. This needs faction-territory roles (`docs/faction-territory-target.md:36-49`, + "extraction"). +- C: Defer AI mining. The same path is proven by a test agent only. +- **Recommended: A** for this campaign. B is the faction-territory campaign's call and can replace A's + eligibility rule without touching the path. + +--- + +# Part II: Body map + +## The belt simulation today + +**(read)** `Zone` owns belts (`Zone.cs:26`, built at `:91-92`). Each tick, `Zone.Update` (`:149-179`): +1. advances `_time` (`:151`) and clears `_updatedOrbits` (`:152`); +2. waits on last tick's belt tasks (`:153-155`); +3. steps every orbit (`:156-161`); +4. for each belt, copies `NewTransforms` into `Transforms`, copies `NewOrbitPosition` into `OrbitPosition`, and + starts one `Task.Run(UpdateAsteroidTransforms)` (`:163-168`). + +`UpdateAsteroidTransforms` (`:249-275`) evaluates every asteroid into a `float4` (x, y, rotation, damage-scaled +size). The per-asteroid gravity sample is commented out (`:272`). The shader applies height instead: `Asteroid.shader:28-32` +samples `_NebulaSurfaceHeight` per vertex and adds `_AsteroidVerticalOffset`, and the renderer sets that offset +globally (`ZoneRenderer.cs:442`). + +**Findings (probe), on the densest zone of the operator's save** (EAC-2265, radius 5418, 8 belts of 123, 29, 101, 132, +269, 99, 30 and 26 asteroids, 809 in total, 56 bodies; that save has generated 1 of its 64 zones): + +- **Cost before:** shipped `Zone.Update` with entities stripped costs **400-1760 us wall per tick** (the main + thread blocks in `t.Wait()` at `:153-154`) and **125-420 us process CPU per tick**, over 5 clean runs. +- **Cost after** (the same zone with no belt work): **31-39 us wall, 26-37 us CPU per tick.** Belts become free + per tick. +- The belt arithmetic alone, run sequentially, is 76-94 us/tick for all 809 asteroids. The threading bought no + speed: task scheduling and the blocking wait cost more than the work. +- **One asteroid on demand costs 186-224 ns** (`OrbitPeriod.Evaluate`, `OrbitData.Evaluate` and the parent orbit + lookup). A targeting query over the largest belt (269 asteroids) is about 55 us, paid when asked and not per tick. +- **The threading is a data race.** **4 of 10 probe runs died** with `InvalidOperationException: Operations that + change non-concurrent collections must have exclusive access`, thrown from `Zone.GetOrbitPosition` + (`:209`/`:216`) inside a belt task. Parallel belt tasks, and the main thread's `_updatedOrbits.Clear()` at `:152` + (which runs before the wait at `:153`), mutate the same `HashSet` and `Dictionary` unsynchronized. .NET detects + it; Unity's Mono may corrupt silently instead. Deleting the threading is a correctness fix as well as a cost fix. +- **Every pose the sim or renderer reads is exactly one tick stale.** After `Update`, `Transforms` matches the pose + at the previous tick's time with 0.0000 error, and misses the current pose by 0.209 units at the measured maximum + speed of 12.6 u/s. That comes from the double buffer (`:165`). +- **The operator's history, confirmed.** `GetHeight` for every asteroid, the commented-out line `:272`, costs + **1.9-2.1 ms/tick** over 56 bodies. That really was the heaviest per-tick operation. It is already gone from the + sim, and the shader now does the height. + +## Consumers of the belt state + +| Surface | Readers (HEAD) | Fate | +|---|---|---| +| `AsteroidBelt.Transforms` / `NewTransforms` (`Zone.cs:505-506`) | `Zone.cs:165,230,273`; `MiningTool.cs:62`; `ResourceScanner.cs:80`; `ZoneRenderer.cs:456,462,467,476`; `AsteroidBeltUI.Update` (`ZoneRenderer.cs:660-676`) | Deleted (Cut 1). The renderer fills its own buffers from the one function. | +| `AsteroidBelt.OrbitPosition` / `NewOrbitPosition` (`:508-509`) | `Zone.cs:166,256,271`; `ZoneRenderer.cs:449,451,662` | Deleted. The belt centre is `Zone.GetOrbitPosition(parent)`. `AsteroidBeltUI` already stores `_orbitParent` (`:593,613`) and never reads it. | +| `BeltUpdates` (`:46`) | `:153-155,167` | Deleted. | +| `NearestAsteroid` (`:226-245`) | none. It reads `Transforms[i].xz` (x and rotation) where it means `.xy` | Deleted. Cut 3 adds the one chunk query. | +| `AsteroidExists` (`:247`) | `MiningTool.cs:61` | Replaced by `Zone.ChunkExists` in Cut 2. | +| `RespawnTimers`, `Damage`, `MiningAccumulator` (`:510-512`) | `Zone.cs:260-264,306-318` | Replaced by the wear store (Cut 2). | +| `Zone._random` (`:33,71`) | `MineAsteroid` only (`:323-324`) | Deleted with `MineAsteroid`, Q1=A. It is a shared stream, which Cut 6b forbids for combat dice. | +| `ActionGameManager` save/continue | no belt reader. `RunSave.Capture` packs entities, orbits and planet refs only | Gains wear through `Zone.PackZone` (Cut 2). | +| `SectorRenderer.cs:84` (belt count), `MapRenderer.cs:55-73` and `MainMenu.cs:257-259` (`ShowAsteroidUI` toggles) | none read poses | Untouched. | + +## The mining path today, end to end + +Unreachable **(read)**: `MiningTool.Execute` (`MiningTool.cs:56-75`) range-checks against the stale `Transforms` and +calls `Zone.MineAsteroid` (`Zone.cs:297-335`). That accumulates damage and a per-miner accumulator, picks a resource +from the always-empty `beltData.Resources`, and mints nothing: the `SimpleCommodity` mint is commented out +(`:327-333`, `// TODO: Drop item onto the Grid`). No code assigns `MiningTool.AsteroidBelt` or `Asteroid`. The AI +`MiningController` that did (`399b933a`) was deleted in `c6ffcad3`. `Agents/Tasks/Mining.cs` has no reader, and +`AgentTask` is "owned by their Agent and not persisted" (`AgentTask.cs:10`). + +## How FireControl sees a target (what a chunk must answer) + +**(read, `FireControl.cs` at `8bd25f6f`)**: + +| Read | Where | For a chunk | +|---|---|---| +| `source.Target.Value` | `Fire` `:332` | the slot (Q2) | +| `target.Position`, `range` | `PFire` `:170-171`, `Inspect` `:246-247` | `Zone.ChunkPosition` | +| `source.VisibleEntities.Contains` | `PFire` `:173`, `Inspect` `:248` | within chunk detection range (Q2) | +| `EntityInfoGathered[target]` → `PSensor` | `PFire` `:179-180` | info 1 | +| `LockWeapon.IsLocked` | `PFire` `:175` | never locked (Q2) | +| `InArc(toTarget)` | `PFire` `:176` | unchanged | +| `target.Velocity` | `PredictedIntercept` `:135-138`, `Fire` `:349` | `Zone.ChunkVelocity`: analytic orbital velocity plus the parent `Orbit.Velocity` (`Zone.cs:160`) | +| `target.Hull`, `Silhouette(target, hull, aimed, bearing, precision)` | `Forecast` `:196-205`, `CommitProbability` `:486-487`, `Commit` `:531` | a disc of radius `r / SchematicCellSize` cells, one interval `[-R, R)`, `a = 0`, bearing-free | +| `DeviationProbability` (live position) | `:310-316` | unchanged in shape. A chunk's curvature over a 2 s flight is `v²t²/2R` ≈ 0.16 u at the probe belt, well inside any Tracking | +| `zone.Entities.Contains(target)` (gone → miss) | `Step` `:409` | `!Zone.ChunkExists` (broken) → miss | +| `Shield` | `Commit` `:552-555`, `Apply` `:570-575` | none | +| `ApplyHit` | `Apply` `:581` | `Zone.Mine(...)`: wear plus the decided loot | +| `Splash` over `zone.Entities` | `:613-642` (being replaced by 12.4) | Q2: targeted chunk only | + +`Silhouette` (`:679-726`) does three things: builds the intervals, merges them, and integrates the Gaussian. The +integration (`:710-716`) and `Span` (`:718`) are the only parts a disc needs. Cut 4 extracts that tail as the one +integrator, used by the hull path and `Silhouette.Disc` alike, so no second copy of `Phi` summation exists. + +## The catalog and the legacy record (probe) + +- **Live simple commodities (13):** Minerals: Hydrogen, Potassium, Rock. Metals: Gold, New Metals, Uranium. + Compounds: Methane, Water. Organics: Algae, Animal Products, Bacteria, Fungi. Ammo: Ammo. Every one is priced 0. +- **Legacy (30, identical in `legacy-0305` and `legacy-0414`):** the 13 live ones, plus Crystal, Silicon, Carbon, + Nitrogen (Minerals); Iron, Titanium, Copper, Lead (Metals); Ethanol, Hydrocarbons, Acid, Oxygen, Carbon Dioxide + (Compounds); Plant Matter (Organics); and Autocannon, Charged Shotgun, Flak Cannon and Auto Shotgun ammo. That is + 17 missing from live, 14 of them plausible ores. +- **Resource-distribution data does not survive.** The 2020 fields (`ResourceDensity` map layers, `Minimum`, + `Maximum`, `Exponent`, `Floor`; `17673cd2`) are the retired keys 6, 7, 8 and 11. No values remain in the deleted + JSON records at `c6ffcad3^` (for example, `Uranium.json` carries only name, mass and category) or in either + msgpack. +- **Mining gear:** Drill Bit and Resource Scanner exist in legacy only (Q1). Surface Ore Extractor and Deep Ore + Extractor are behaviourless husks in both live and legacy (`docs/item-provenance-substrate.md:62-64`), and Mining + Rights is a compound commodity. None of these are touched here. The extractors are station factory gear for + backward generation. +- **Weapons:** see Q8. The catalog has no `BodyData` (bodies are run records). + +## Provenance: how a mined lot is born + +`Lot` is on `CraftedItemInstance` at key 11 (`ItemInstance.cs:38`). The provenance map put it there on purpose, +"`SimpleCommodity` gets no lot ... Moving it to the base later with the same key is byte-identical (probe Q4), so +mining yield adds without reshaping" (`docs/item-provenance-cut.md:150-156`). `SimpleCommodity` uses key 2 +(`ItemInstance.cs:47`), so key 11 is free on it. + +The mint: +- **Who:** the shooter's shot, at Apply. +- **What:** the commodity (`Lot.Design`). `Extracted.Commodity` repeats `Lot.Design`, and the provenance cut chose + that deliberately (`Provenance.cs:116-120`). It is left alone. +- **Where:** `Extracted.Zone`, which is the run's zone index (`Array.IndexOf(Galaxy.Zones, GalaxyZone)`, the index + `savedzone-{i}` uses, `SavedGame.cs:103`), plus a new `Extracted.Body` (key 2, + `CultRecordRef`, nil on any older record). That is Njordr's "source". +- **Quality:** 1 (Q6). **Roles:** null. + +Three existing rules must learn about simple lots: +- `RunSave.Commit` collects lot roots from `OfType()` only (`SavedGame.cs:110-114`). Mined lots + would be garbage-collected at the first save unless the roots take every `ItemInstance` with `Lot != 0`. +- The stack merge (`Entity.cs:1838`) matches on `Data` only. +- The stack split (`Entity.cs:1916`) does not copy `Lot`. + +A `SimpleCommodity` with `Lot == 0` is unattributed: the ledger starts at 1 (`Provenance.cs:14`), so 0 never names a +lot. That includes every existing loadout ammo stack. Reading such a lot through the ledger's indexer still throws. + +## Overlap with work in flight + +- **Fire control (main tree, other agents).** Cuts 3 and 4 edit `FireControl.cs` at `Fire`, `Step`, `PFire`, + `Forecast`, `HitProbability`, `Inspect`, `CommitProbability`, `Commit`, `Apply`, `Silhouette`, `PendingShot` and + `ShotOutcome`, and they touch `Splash`'s successor from 12.4. They must follow Cut 12.4. Cuts 1, 2 and 5-7 do not + touch `FireControl.cs`, except Cut 5's loot-draw lines inside `Commit`, which also wait for 12.4. +- **Locomotion (`codex/locomotion`).** Its Cut 4 rewrites `MoveTo` and the heading planner. Cut 6 here drives the + approach through whatever `MoveToState` is live and adds no motion code, so it waits for, or rebases onto, + locomotion Cut 4. +- **Settings globals (`docs/settings-globals-cut.md`).** Cut 5's new tunables join `GameplaySettings` and + `PlanetSettings` wherever those live when it lands. +- The uncommitted `AetheriaInput.cs` change in the main tree ("Cycle Target Item") is fire-control work. Cut 3's + targeting handlers sit beside it in `ActionGameManager.cs:372-404`. + +--- + +# Part III: Cut map + +## Status header + +Status: cut map. Ends are owned by Part I of this document; Part III owns the means. + +Rulings (operator): none yet. Q1-Q9 open. + +Open: Q1-Q9. Cut 1 needs none of them. + +Follow-ups outside this campaign: +- `AsteroidBeltUI.Update` feeds `Quaternion.Euler(90, transform.z, 0)` a rotation in radians where the API takes + degrees (`ZoneRenderer.cs:666`). It is a presentation defect and is not fixed here. +- The planet orbit step stays per tick (`Zone.cs:156-161`). It is cheap. + +## Cut order + +| Cut | Nature | Depends on | +|---|---|---| +| 1 | Subtraction: belts evaluated on demand. No threads, no stored poses. | none | +| 2 | Subtraction plus one owner: the dead mining path goes, and chunk identity and wear get one owner | Q1, Q3 | +| 3 | Targetable chunks: the one slot, the one chunk query, the HUD | Q2; fire-control Cut 12 closed | +| 4 | Hit resolution against chunks through FireControl (damage only, no loot) | 3 | +| 5 | Composition, loot roll, provenance, deposit | 4; Q4-Q7 | +| 6 | AI mining on the same path | 5; Q9; locomotion Cut 4 | +| 7 | Content: ores, affinities, abundance, Drill Bit, Resource Scanner (operator review) | 5; Q1, Q8 | + +Cuts 1 and 2 are kept apart so Soul can falsify "nothing changed" (Cut 1) separately from "the dead path is gone +and wear has one owner" (Cut 2). + +--- + +### Cut 1. Belts are evaluated when asked + +- **Repo/branch:** Aetheria, `codex/mining` from `codex/fire-control-12` `8bd25f6f`. Depends on nothing. +- **First:** re-run `miningprobe` on the branch point and record the before numbers in the commit message (wall and + CPU per tick, and the race count over 10 runs). +- **Deletes first** (`Zone.cs` at `8bd25f6f`): + - `:9-10` `using System.Threading; using System.Threading.Tasks;` (2 lines); + - `:46` `BeltUpdates` (1); + - `:153-155`, the wait loop (3); + - `:163-168`, the per-belt copy and `Task.Run` (6); + - `:226-245` `NearestAsteroid` (20); + - `:249-275` `UpdateAsteroidTransforms` (27); + - `:505-506` and `:508-509`, the four `AsteroidBelt` pose fields, and `:517-518`, their allocations (6). + - About 65 lines in total. +- **Keeps:** `AsteroidBelt.Data`, `Radius` (`ZoneRenderer.cs:452`), and the wear dictionaries (Cut 2 replaces them). + The per-orbit step `:156-161`. +- **Adds:** on `AsteroidBelt`, one function that is the only place a chunk pose is computed: + `Pose(int index, double time, float2 parentPosition, PlanetSettings)` → (position, rotation). It is the formula of + `:269-271`, verbatim, with `time` in double exactly as `_time` is used now. There is also a size read, + `Size(int index, PlanetSettings)`, which is the rule of `:259-267` moved unchanged, and a batch + `Evaluate(Span into, double time, float2 parent, PlanetSettings)` that loops `Pose` and `Size` for the + renderer. `Zone` exposes them at its own time: `ChunkPose(CultRecordKey belt, int i)` and + `EvaluateBelt(CultRecordKey belt, Span into)`. They read `_time` (double) and + `GetOrbitPosition(parent)`, so no caller passes a float time. +- **Per-file changes:** + - `Zone.cs:149-179`: `Update` loses the belt block entirely. + - `MiningTool.cs:59-62` and `ResourceScanner.cs:80`: read `Zone.ChunkPose` instead of `Transforms`. No behaviour + change; they die or reshape in Cut 2. + - `ZoneRenderer.cs:447-477`: per visible belt, `Zone.EvaluateBelt` into a renderer-owned `float4[]` (allocated in + `LoadPlanet`, `:340-362`, sized to the belt). The matrices are built from that buffer as before. The visibility + bounds use `Zone.GetOrbitPosition(parent)` in place of `belt.OrbitPosition` (`:449-452`). + - `ZoneRenderer.cs:660-676`: `AsteroidBeltUI.Update` takes the renderer's buffer, and its bounds use its own + `_orbitParent` (`:593,613`) through `_zone.GetOrbitPosition`. +- **Behaviour change, named:** poses are read at the current time instead of one tick behind. The measured maximum + difference is 0.21 u in the probe zone. No sim reader is reachable (MiningTool has no writer). The renderer draws + the rocks where they are now. +- **Authority map:** + - Owner: `AsteroidBelt.Pose` and `AsteroidBelt.Size`. + - Inputs: the asteroid record, the parent orbit position at zone time, zone time (double), `PlanetSettings`, and + wear (size only). + - Outputs: position, rotation, size, on request. + - Derived state: every renderer buffer is display-only, owned by `ZoneRenderer`. + - Forbidden writers: any stored per-asteroid pose in `ServerShared`, any `Task` or thread in `Zone`, and any + second copy of the orbit formula. + - Shared paths: renderer, minimap (`AsteroidBeltUI`), and every sim reader, through `Zone`. + - Deletion line: the listed deletes land in the same commit as the adds. No reader of `Transforms` survives. +- **Verification:** + - builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`, each with both roots (`-p:CultLibRoot=... + -p:CultMathRoot=...`). The Unity compile is Self's or the operator's step, never batchmode against the main tree. + - tests (new `MiningCut1Tests.cs`; fixture: an `OrbitData` and an `AsteroidBeltData` upserted into a run-store + cache, with a non-degenerate belt of at least 20 asteroids at distinct distances, phases and sizes, and + authored-shape `PlanetSettings` curves, not defaults): + - `ChunkPoseIsTheOrbitAtZoneTime`: after N updates of uneven dt, every chunk's pose equals the closed-form orbit + at the zone's time, including the parent's motion. Pins no staleness. + - `ChunkPoseDoesNotDependOnUpdateCadence`: one update of 1.0 s and 60 updates of 1/60 give equal poses. Pins + pure function of time. + - `DamagedChunkShrinksAndABrokenOneHasNoSize`: pins the size rule moved from `:259-267`. + - Stryker over `AsteroidBelt` and `Zone.Chunk*`. Every survivor is triaged. + - negative: `rg -n "Task\.Run|System\.Threading|NewTransforms|\.Transforms\b|BeltUpdates|NearestAsteroid|\.OrbitPosition\b|NewOrbitPosition" Assets/Scripts/ServerShared/Zone.cs "Assets/Scripts/Zone Display" Assets/Scripts/ServerShared/Behaviors` + returns only `Zone.cs:301`, a commented line inside `MineAsteroid` that Cut 2 deletes. After Cut 2 it is empty. + Tested against `8bd25f6f`: every other hit is a line this cut deletes or rewrites. Widening it to `Assets/Scripts` + collides with `Galaxy.cs:6-7` (`System.Threading`, a legitimate use) and with `followOrbitPosition` + (`ActionGameManager.cs:841-843`). + - probe: `miningprobe` after the cut shows belt cost 0 and 0 races over 10 runs. Record the after numbers. + - operator: fly through a belt. The rocks orbit smoothly, the minimap asteroid overlay tracks them, and the map + screen shows them. +- **Operator questions:** none (the CPU renderer is the stated default). + +--- + +### Cut 2. The dead mining path goes, and chunks get one owner + +- **Repo/branch:** Aetheria, `codex/mining`. Depends on Cut 1, Q1 and Q3. Written for Q1=A and Q3=A. +- **Deletes first:** + - `Behaviors/MiningTool.cs` (76) and its `.meta`. + - `Behaviors.cs:173` `Union(26, typeof(MiningToolData))`, becoming a `// Union(26, MiningToolData): retired, do not + reuse` comment, per the file's own precedent at `:172`, `:174`. + - `Zone.cs:297-335` `MineAsteroid` (39); `:247` `AsteroidExists` (1); `:33,71` `_random` (2); `:510-512` the three + dictionaries (3). + - `ResourceScanner.cs:71-107`, the survey `Execute` (37), plus `ScanTarget`, `Asteroid`, `_scanTime` and + `_scanTarget` (`:38-59`). Its data (`Range`, `MinimumDensity`, `ScanDuration`) stays until Q2 rules. If Q2 goes + against the scanner-as-chunk-sensor, the whole behaviour parks at a tag instead + (`parked/mining-resource-scanner`), because it has legacy content. + - `PlanetSettings.MiningDifficulty` (`Settings.cs:32`). Its only reader is `MineAsteroid`. + - About 160 lines in total. +- **Adds:** + - `ChunkId` (readonly struct, `(CultRecordKey Belt, int Index)`, with value equality). + - The wear store on `Zone`: `ChunkWear { float Damage; double? BrokenUntil }` keyed by `ChunkId`. + - `Zone.ChunkExists(ChunkId)`: the index is in range and the chunk is not broken at zone time. + - `Zone.ChunkRadius(ChunkId)`: `Size` from Cut 1, reading wear. + - `Zone.Wear(ChunkId, float damage)` → whether it broke. It is the only writer, and Cut 4's Apply is its only + caller. + - Lazy respawn: an entry whose `BrokenUntil <= time` reads as absent. + - `ZonePack` key 6: `List` (belt key, index, damage, broken-until), nullable. It is written by + `PackZone` (`Zone.cs:131-142`) with expired entries pruned, and read in the constructor after belts are built + (`:91-92`). +- **Authority map:** + - Owner: `Zone` owns chunk wear. `Zone.Wear` is its only writer. + - Inputs: damage delivered at Apply, zone time, `AsteroidHitpoints` and `AsteroidRespawnTime` (`Settings.cs:29-30`). + - Outputs: `ChunkExists`, `ChunkRadius`, and the packed wear. + - Derived state: respawn is a comparison, not a timer. + - Forbidden writers: any per-tick respawn loop. `MineAsteroid`, `MiningTool` and `ResourceScanner.Execute` are + deleted. + - Shared paths: live play, save (`RunSave.Capture` → `PackZone`), continue (the `Zone` constructor), and zone + re-entry. + - Deletion line: the deletes above land before the wear store appears. +- **Verification:** + - tests: + - `WearAccumulatesAndBreaksAtHitpoints` pins the threshold (strictly `>` as `:314`, or `>=`; say which, and pin it). + - `ABrokenChunkReturnsAtItsRespawnTime` pins the lazy respawn at the authored curve. + - `WearSurvivesPackAndUnpack`: a zone round trip through `ZonePack` MessagePack keeps damage and broken-until. + - `APackOmitsExpiredWear`. + - `ARunWithoutKeySixLoadsWhole`: nil key 6 means no wear. + - negative: `rg -n "MiningTool|MineAsteroid|MiningAccumulator|RespawnTimers|MiningDifficulty|AsteroidExists" Assets/Scripts tests tools` + is empty. + - catalog: `AetherDb census` before and after is byte-identical. No live item carries tag 26 **(probe)**. + - operator: none (nothing reachable changes). + +--- + +### Cut 3. A chunk is a target + +- **Repo/branch:** Aetheria, `codex/mining`, rebased onto the fire-control Cut 12 close. Depends on Cut 2 and Q2. + Written for Q2=A. +- **Deletes first:** none of substance. Reshapes follow. +- **Adds:** + - `TargetRef` (readonly struct: `Entity Entity`, `ChunkId? Chunk`, value equality, `IsNone`). + - `Zone.ChunksNear(float2 position, float range, List into)`. It is the one chunk query: it skips belts + whose annulus (`GetOrbitPosition(parent)`, `Radius`) cannot reach, evaluates the rest on demand, and skips broken + chunks. The player's reticle pick, target cycling and the AI all call it. + - `Zone.ChunkVelocity(ChunkId)`: analytic, `2πD/T · (−sin θ, cos θ)` plus the parent `Orbit.Velocity` (`Zone.cs:160`). + - `FireControl.ChunkDetectionRange(Entity)`: the equipped `ResourceScanner`'s `Range` when active, else + `GameplaySettings.UnaidedChunkRange` (the `Tracking` shape, `FireControl.cs:121-125`). +- **Per-file changes** (anchors at `8bd25f6f`; re-anchor after the Cut 12 rebase): + - `Entity.cs:46`: `Target` becomes `ReactiveProperty`. `:201`, `:227`, `:231-234`, `:298-299` compare + and clear through `.Entity`. `:317` and `:328` (`TrySelectTargetItem`, `ResolvedTargetItem`) are entity-only, + via `.Entity`. `:1060` `TargetRange` reads the chunk position for a chunk. + - `ActionGameManager.cs:372-404`: the reticle, next and previous handlers include chunks from `Zone.ChunksNear` + within `ChunkDetectionRange`. `TargetNearest` stays enemies-only (`:380-388`, its own comment: it drives weapon + lock). The HUD at `:1266-1275` shows the chunk's size, and its wear as hitpoints, for a chunk target. The + visibility fills are ship-only. `UpdateFireControlDebug(Entity)` (`:1284`) takes `TargetRef`. + - `Weapon.cs:101` `StanceAllowsFire`: a chunk is allowed. `:110-117` `ArcAllowsFire`: reads the target position + through the ref. + - `LockWeapon.cs:80-98`, `TurretController.cs:60-100`, `Combat.cs:28-126`, `Minion.cs:14,18,21`, `Ship.cs:84`, + `EntityInstance.cs:207,236,405`: `.Entity` and null-checks. Turrets and combat never hold a chunk (they write + only enemies). +- **Authority map:** + - Owner: `Entity.Target`, the one slot. + - Inputs: the player's targeting handlers and the AI states. + - Outputs: the current `TargetRef`. + - Derived state: `TargetItem` (ship-only, nulled on any change as today, `Entity.cs:286`); `TargetRange`. + - Forbidden writers: any second target field; any chunk selection that bypasses `Zone.ChunksNear`. + - Shared paths: player reticle, cycling, AI (Cut 6), and dock/undock reactivation (`Entity.cs:298-299`). + - Deletion line: no `ReactiveProperty Target` remains. +- **Verification:** + - tests: + - `ReticleAndCycleOfferChunksWithinDetectionRange`, headless through the same query the handler calls. + - `AScannerExtendsChunkDetection` (and `UnaidedFallbackWithoutOne`). + - `ABrokenChunkIsNotOffered`. + - `TargetingAChunkClearsTheAimedItem`. + - `TargetRefEquality`: the same chunk held twice is equal. + - Every existing fire-control test still passes unchanged. That is the negative check that ship targeting did + not move. + - negative: `rg -n "ReactiveProperty Target\b"` is empty. + - operator: target a rock with the reticle, cycle through rocks and ships, and dock and undock with a rock + targeted. + +--- + +### Cut 4. A shot at a chunk goes through FireControl + +- **Repo/branch:** Aetheria, `codex/mining`. Depends on Cut 3 and fire-control 12.4. +- **Deletes first:** none. Reshape `PendingShot.Target` and `ShotOutcome.Target` (`FireControl.cs:860,997`) to `TargetRef`. +- **Adds:** `Silhouette.Disc(float radiusCells, float precision)`, which returns one interval `[-R, R)`, `A = 0`, + and `Span = 2R`. The hull path and the disc path share one integrator, extracted from `:710-718`. `ShotOutcome` + gains `float3 ImpactPoint` for presentation: the chunk position at commit. +- **Per-file changes** (re-anchor after 12.4): + - `PFire` `:161-181`: for a chunk, the visibility gate is range ≤ `ChunkDetectionRange`, `info = 1`, and a + `LockWeapon` is gated out. + - `Forecast`, `HitProbability`, `Inspect` (`:194-265`): for a chunk, the silhouette is the disc. + - `PredictedIntercept` `:133-139` and `TravelDirection`: position and velocity through the ref. + - `Fire` `:329-393`: freezes the ref. + - `Step` `:398-443`: `targetGone` includes `!ChunkExists`. + - `CommitProbability` and `Commit` (`:476-559`): for a chunk, the disc and no shield. + - `Apply` `:564-582`: a chunk hit calls `Zone.Wear(chunk, Damage)`. + - Unity presentation: `EntityInstance.cs:207,236` and the effect managers aim at the chunk's rendered position when + the target is a chunk. That is presentation only. +- **Authority map:** + - Owner: `FireControl`, unchanged. + - New inputs: the chunk pose, velocity and radius through `Zone`. + - Forbidden: any hit test against chunk geometry outside `Commit`, and any read of a chunk pose in `Apply` (R4). + - Shared: player trigger, AI `Activate`, turrets (which cannot hold chunks). + - Deletion line: none. +- **Verification:** + - tests: + - `AChunkShotUsesTheSameFactors`: Accuracy, PSpread and Sigma equal a ship shot's at equal range and precision. + - `DiscMassIsTheClosedForm`: `POnHull = Φ(R/σ) − Φ(−R/σ)`. + - `ABiggerRockIsEasierToHit`. + - `AChunkThatBreaksMidFlightIsAMiss`. + - `ChunkHitsWearTheChunk`. + - `LaunchersCannotFireOnAChunk`. + - `ChunkDiceAreTheShotsOwn`: the same galaxy and shot give the same outcome. + - Stryker over the new branches. + - negative: no `Target.Value.Position`-shaped chunk read outside `Zone.Chunk*` (grep). + - operator: shoot a rock with each damage type. It shrinks and breaks, and misses are plausible at range. + +--- + +### Cut 5. Composition, the loot roll, and the mined lot + +- **Repo/branch:** Aetheria, `codex/mining`. Depends on Cut 4 and Q4-Q7. Written for A in each. +- **Deletes first:** `BodyData.Resources` (`ZoneData.cs:53-54`), becoming a retired-key comment (key 4). Its reader + `MineAsteroid` is already gone. Under Q7=B it stays instead, gets a writer, and the jitter is derived. +- **Adds:** + - `SimpleCommodityData` key 12 `Dictionary Affinity` (nullable; absent means 1). Key 13 + `float? Abundance` (null means not a belt ore). + - `PlanetSettings.AsteroidYield` (`ExponentialLerp`) and `ChunkCompositionVariance` (float). + `GameplaySettings.MiningDepthPerPenetration` (float). + - `Zone.ChunkComposition(ChunkId, Span<...>)`: derived (Q7), from belt-ore abundances times the `StableHash` jitter. + - The loot roll inside `Commit`, after the hit draw, from the same per-shot generator: first the units draw, then + one draw per unit for the ore. The result is frozen into `ShotOutcome` as `Loot` (commodity and count pairs, + presentation-readable). + - `ItemManager.ExtractedLot(int zone, CultRecordKey belt, CultRecordRef)`: find-or-mint, + using an index derived from the ledger at construction. + - `Extracted` key 2 `CultRecordRef Body`. + - `ItemInstance` gains `Lot` at key 11. It moves from `CraftedItemInstance` (`ItemInstance.cs:38`), so the key is + byte-identical per provenance probe Q4. +- **Per-file changes:** + - `FireControl.Apply`: after `Zone.Wear`, mint or find the lot and `Source.CargoBays.Any(c => c.TryStore(unit))`. + Overflow is lost. + - `Entity.cs:1838`: merge only when `Data` and `Lot` both match. `:1916`: the split copies `Lot`. + - `SavedGame.cs:110-114`: the roots are every `ItemInstance` with `Lot != 0`. + - `ItemManager.cs:72` `GetLot` takes `ItemInstance`. +- **Authority map:** + - Owners: `FireControl.Commit` decides the loot. `ItemManager` mints lots. `EquippedCargoBay` places units. + - Inputs: frozen damage, type and penetration; the chunk composition at commit; remaining HP at commit. + - Outputs: `ShotOutcome.Loot`, units in cargo, ledger lots. + - Derived: composition, and the source→lot index. + - Forbidden writers: any loot draw outside `Commit`; any mined `SimpleCommodity` without a lot; any shared random + stream. + - Shared paths: player and AI shots; save GC; continue (the index is rebuilt). + - Deletion line: `BodyData.Resources` retired before the composition function is added. +- **Verification:** + - tests: + - `RightDamageTypeRaisesTheOresShare` (the Q4 table as a fixture). + - `PenetrationFlattensTowardRareOre`. + - `ZeroPenetrationReadsTheRawComposition`. + - `AChunkYieldsItsAuthoredTotalWhateverBreaksIt` (Invariant 7: the sum over many shots from different weapons). + - `LootIsDecidedAtCommitAndPerformedAtArrival`. + - `TheSameShotYieldsTheSameOre`. + - `MinedUnitsCarryAnExtractedLotNamingZoneAndBelt`. + - `UnitsFromOneSourceStack`, and `UnitsFromTwoSourcesDoNot`. + - `ASplitStackKeepsItsLot`. + - `MinedLotsSurviveSave` (GC roots). + - `AFullHoldLosesTheOverflow`. + - `CompositionIsStableAcrossContinue`. + - Stryker over the formula and the mint. + - negative: `rg -n "\.Resources\b" Assets/Scripts/ServerShared` returns no `BodyData` reads. + - catalog: `AetherDb census` shows keys 12 and 13 as `defaulted_missing_slot` until Cut 7 writes them. That is the + compatible drift fire-control Cut 12 already measured. + - operator: mine one rock with a Thermal gun and one with a Kinetic gun, and read the cargo. + +--- + +### Cut 6. The AI mines on the same path + +- **Repo/branch:** Aetheria, `codex/mining`, rebased onto locomotion Cut 4. Depends on Cut 5 and Q9. +- **Deletes first:** `Agents/Tasks/Mining.cs` is reshaped. It is not persisted, so its MessagePack attributes go. + It keeps `Belt` (`CultRecordKey`) and a wanted-ore reference. +- **Adds:** `MiningState` in `Minion` (`Minion.cs:7-22`), entered when `Task is Mining` and no enemy is targeted. + It picks a chunk via `Zone.ChunksNear` and writes `Ship.Target` (the same slot the player writes). It approaches + through the live `MoveToState` toward `ChunkPosition`. It selects the weapon group whose damage type has the + highest `Affinity` for the wanted ore, gates on `FireControl.HitProbability ≥ AgentMinHitProbability`, and calls + `weapon.Activate()`, which is exactly `Combat.cs:117-124`. It retargets when `!ChunkExists`, and stops when no + cargo bay can take a unit. The combat transition keeps priority: a visible enemy preempts mining + (`Minion.cs:14,18`). `Zone.CreateAgent` (`Zone.cs:122-129`) assigns `Mining` by Q9's rule. +- **Authority map:** + - Owner: `MiningState` decides what to shoot. + - Forbidden: any AI-only extraction call. The only way ore enters an AI hold is `FireControl.Apply`. + - Shared paths: the same target slot, `HitProbability`, `Activate`, `Commit` and `Apply` as the player. +- **Verification:** + - tests: + - `AnAgentMinesThroughFireControl`: a headless zone, an agent with a Mining task, N seconds of ticks, then ore in + its hold whose lots are `Extracted` from that belt, and every unit traceable to a `ShotOutcome`. + - `AnEnemyPreemptsMining`. + - `TheAgentPicksTheAffineGroup`. + - operator: watch an NPC mine a belt. + +--- + +### Cut 7. Content (operator review) + +- A content cut through an `AetherDb` command (`mining-content [apply]`, dry run by default, one catalog commit; + precedent: `targeting-catalog`). The operator reviews a sheet before `apply`: + - The 14 legacy ores missing from live (Crystal, Silicon, Carbon, Nitrogen, Iron, Titanium, Copper, Lead, Ethanol, + Hydrocarbons, Acid, Oxygen, Carbon Dioxide, Plant Matter), restored with their legacy mass, specific heat, + conductivity and category **(probe values in `legacy-0414`)**. The 3 missing ammo types are out of scope. + - `Abundance` and `Affinity` for each belt ore, over the four damage types that exist (Q8). + - The Drill Bit as a mining weapon (Q1), and the Resource Scanner as the chunk sensor (Q2), restored from legacy + with their authored ranges. + - `AsteroidYield`, `ChunkCompositionVariance`, `MiningDepthPerPenetration` and `UnaidedChunkRange` values. + - The Corrosive and Ionizing gap, recorded (Q8). +- **Verification:** `census` and `dangling` before and after. The loot-table fixture from Cut 5 runs against the live + catalog, asserting that every belt ore is reachable by at least one live weapon's damage type. + +--- + +## Subtraction ledger (estimate) + +| Cut | Removed | Added | Formats and targets | +|---|---|---|---| +| 1 | ~65 (`Zone.cs`), ~12 (`ZoneRenderer.cs`) | ~35 | threading removed; no new target | +| 2 | ~160 (`MiningTool.cs`, `MineAsteroid`, survey `Execute`, dictionaries, `_random`, `MiningDifficulty`) | ~70 | union 26 retired; `ZonePack` key 6 added | +| 3 | ~0 | ~80, plus ~60 mechanical site edits | `Target` retyped | +| 4 | ~0 | ~70 | `PendingShot`/`ShotOutcome` target retyped | +| 5 | 2 (`BodyData.Resources`) | ~150 | `SimpleCommodityData` keys 12 and 13; `Extracted` key 2; `ItemInstance.Lot` moves to the base | +| 6 | ~15 (`Mining.cs` attributes) | ~90 | none | +| 7 | 0 | content only | catalog records | + +Net code is roughly flat against about 240 lines deleted. Every addition buys a named capability: targetable chunks, +the loot roll, provenance, AI mining. + +## Build budget + +Headless: `Aetheria.Shared` (netstandard2.1), `tests/Aetheria.Shared.Tests` (net10.0), and `tools/AetherDb` +(net10.0), on the Windows workstation. Nothing ships from them. Both CultLib roots are passed explicitly. Unity +compiles are Self's or the operator's, on the main tree, never batchmode while the editor holds the project. No new +package, assembly or executable target. From 22a54ccbaf4bebc2308bdc84c9978e0a3a615d8b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 01:27:16 +0200 Subject: [PATCH 02/45] Cut 1: belts are evaluated when asked, not simulated every tick Zone.Update no longer threads a per-belt Task.Run over a double-buffered Transforms/OrbitPosition pair. AsteroidBelt.Pose/Size are the one place a chunk pose is computed, a pure function of zone time; Zone.ChunkPose and Zone.EvaluateBelt read them at the zone's own time. MiningTool, ResourceScanner and ZoneRenderer (belt matrices and AsteroidBeltUI) all read through Zone instead of the deleted per-tick buffers; the renderer owns its own float4[] buffer, filled fresh every frame. Deleted: System.Threading/.Tasks usings, BeltUpdates, the wait loop, the per-belt copy+Task.Run, NearestAsteroid (dead/broken reader), the private UpdateAsteroidTransforms, and AsteroidBelt's four pose fields plus their allocations. ~65 lines, matching the cut map's estimate. miningprobe, densest zone of the operator's save (EAC-2265, 809 asteroids, 8 belts), rebuilt against this cut and rerun 10x each side: before (branch point, 8bd25f6f/e729e6d6): Zone.Update wall 541-3654 us/tick, CPU 120-391 us/tick (7 clean runs) 3/10 runs crashed: InvalidOperationException from Zone.GetOrbitPosition, the documented _updatedOrbits/Orbits data race between the main thread's Clear() and a belt Task.Run after (this commit): Zone.Update wall 95-185 us/tick, CPU 99-152 us/tick, in line with the 31-39 us "no belt work" baseline the map recorded -- belts are free per tick 0/10 races ChunkPose has no buffer to go stale: ~0.12 u of parent+asteroid motion is visible across one 17 ms tick instead of being one tick behind tests/Aetheria.Shared.Tests/MiningCut1Tests.cs (new, 260/260 suite green): ChunkPoseIsTheOrbitAtZoneTime, ChunkPoseDoesNotDependOnUpdateCadence, DamagedChunkShrinksAndABrokenOneHasNoSize -- each expected value is the closed-form orbit/size rule reimplemented in the test, never a call to AsteroidBelt.Pose/Size. Negative grep (Zone.cs, "Zone Display", ServerShared/Behaviors) for Task\.Run|System\.Threading|NewTransforms|\.Transforms\b|BeltUpdates| NearestAsteroid|\.OrbitPosition\b|NewOrbitPosition: one hit, the commented line in MineAsteroid that Cut 2 deletes. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/MiningTool.cs | 9 +- .../ServerShared/Behaviors/ResourceScanner.cs | 2 +- Assets/Scripts/ServerShared/Zone.cs | 107 ++++------ Assets/Scripts/Zone Display/ZoneRenderer.cs | 24 ++- .../Aetheria.Shared.Tests/MiningCut1Tests.cs | 201 ++++++++++++++++++ 5 files changed, 266 insertions(+), 77 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/MiningCut1Tests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs b/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs index f03c12fac..1b388100f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs +++ b/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs @@ -56,10 +56,11 @@ public class MiningTool : Behavior public override bool Execute(float dt) { Range = Evaluate(_data.Range); - var belt = Entity.Zone.AsteroidBelts[AsteroidBelt]; - if (AsteroidBelt.IsSet() && - Entity.Zone.AsteroidExists(AsteroidBelt, Asteroid) && - length(Entity.Position.xz - belt.Transforms[Asteroid].xy) - belt.Transforms[Asteroid].w < Range) + if (!AsteroidBelt.IsSet() || !Entity.Zone.AsteroidExists(AsteroidBelt, Asteroid)) + return false; + + var pose = Entity.Zone.ChunkPose(AsteroidBelt, Asteroid); + if (length(Entity.Position.xz - pose.xy) - pose.w < Range) { Entity.Zone.MineAsteroid( Entity, diff --git a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs index 0b708cd96..a8379204f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs @@ -77,7 +77,7 @@ public class ResourceScanner : Behavior, IAlwaysUpdatedBehavior { if(Asteroid > -1 && Asteroid < beltData.Asteroids.Length && - length(Entity.Position.xz - Entity.Zone.AsteroidBelts[ScanTarget].Transforms[Asteroid].xy) < Range) + length(Entity.Position.xz - Entity.Zone.ChunkPose(ScanTarget, Asteroid).xy) < Range) { _scanTime += dt; if (_scanTime > ScanDuration) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 14ee6b6c0..af7d22436 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -6,8 +6,6 @@ using System; using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using GameCult.Caching; using UniRx; using CultMath; @@ -43,8 +41,6 @@ public class Zone private int _nextShotId; public int NextShotId() => ++_nextShotId; - private List BeltUpdates = new List(); - public float Time { get => (float) _time; @@ -150,9 +146,6 @@ public class Zone { _time += deltaTime; _updatedOrbits.Clear(); - foreach (var t in BeltUpdates) - t.Wait(); - BeltUpdates.Clear(); foreach (var orbit in Orbits) { orbit.Value.PreviousPosition = orbit.Value.Position; @@ -160,13 +153,6 @@ public class Zone orbit.Value.Velocity = (orbit.Value.Position - orbit.Value.PreviousPosition) / deltaTime; } - foreach (var belt in AsteroidBelts) - { - Array.Copy(belt.Value.NewTransforms, belt.Value.Transforms, belt.Value.Transforms.Length); - belt.Value.OrbitPosition = belt.Value.NewOrbitPosition; - BeltUpdates.Add(Task.Run(() => UpdateAsteroidTransforms(belt.Key))); - } - foreach(var agent in Agents) agent.Update(deltaTime); @@ -223,55 +209,22 @@ public class Zone return float2.zero; } - public int NearestAsteroid(CultRecordKey planetDataID, float2 position) - { - var beltData = Planets[planetDataID] as AsteroidBeltData; - - var asteroidPositions = AsteroidBelts[planetDataID].Transforms; - - int nearest = 0; - float nearestDistance = Single.MaxValue; - for (int i = 0; i < beltData.Asteroids.Length; i++) - { - var dist = lengthsq(asteroidPositions[i].xz - position); - if (AsteroidExists(planetDataID, i) && dist < nearestDistance) - { - nearest = i; - nearestDistance = dist; - } - } - - return nearest; - } - public bool AsteroidExists(CultRecordKey planetDataID, int asteroid) => ((AsteroidBeltData) Planets[planetDataID]).Asteroids.Length > asteroid && asteroid >= 0; - private void UpdateAsteroidTransforms(CultRecordKey planetDataID) + // Cut 1 (docs/mining-cut.md): a chunk's pose is a pure function of zone time, computed when asked. No stored + // pose survives a tick; the sim and the renderer both read it fresh through here. + public float4 ChunkPose(CultRecordKey belt, int index) { - var beltData = Planets[planetDataID] as AsteroidBeltData; + var beltData = Planets[belt] as AsteroidBeltData; + var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); + return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings); + } - var belt = AsteroidBelts[planetDataID]; - - var orbitData = Orbits[beltData.Orbit.Key].Data; - belt.NewOrbitPosition = GetOrbitPosition(orbitData.Parent.Key); - for (var i = 0; i < beltData.Asteroids.Length; i++) - { - float size; - if(belt.RespawnTimers.ContainsKey(i)) size = 0; - else if (belt.Damage.ContainsKey(i)) - { - var asteroidHitpoints = Settings.AsteroidHitpoints.Evaluate(beltData.Asteroids[i].Size); - var damage = (asteroidHitpoints - belt.Damage[i]) / asteroidHitpoints; - size = Settings.AsteroidSize.Evaluate(damage * beltData.Asteroids[i].Size); - } - else size = Settings.AsteroidSize.Evaluate(beltData.Asteroids[i].Size); - - var rot = (float) (_time * beltData.Asteroids[i].RotationSpeed % (PI * 2)); - var pos = OrbitData.Evaluate((float) frac(_time / Settings.OrbitPeriod.Evaluate(beltData.Asteroids[i].Distance) + - beltData.Asteroids[i].Phase)) * beltData.Asteroids[i].Distance + belt.NewOrbitPosition; - //belt.NewPositions[i] = float3(pos.x, GetHeight(pos) + Settings.AsteroidVerticalOffset, pos.y); - belt.NewTransforms[i] = float4(pos.x, pos.y, rot, size); - } + public void EvaluateBelt(CultRecordKey belt, Span into) + { + var beltData = Planets[belt] as AsteroidBeltData; + var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); + AsteroidBelts[belt].Evaluate(into, _time, parentPosition, Settings); } public OrbitData CreateOrbit(CultRecordKey parent, float2 position) @@ -502,11 +455,7 @@ public class Sun : GasGiant public class AsteroidBelt { public AsteroidBeltData Data; - public float4[] Transforms; // x, y, rotation, scale - public float4[] NewTransforms; // x, y, rotation, scale public float Radius { get; } - public float2 OrbitPosition; - public float2 NewOrbitPosition; public Dictionary RespawnTimers = new Dictionary(); public Dictionary Damage = new Dictionary(); public Dictionary<(Entity, int), float> MiningAccumulator = new Dictionary<(Entity, int), float>(); @@ -514,10 +463,38 @@ public class AsteroidBelt public AsteroidBelt(AsteroidBeltData data) { Data = data; - Transforms = new float4[data.Asteroids.Length]; - NewTransforms = new float4[data.Asteroids.Length]; Radius = data.Asteroids.Max(a => a.Distance); } + + // Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used. + // The only place a chunk pose is computed; no per-tick copy survives it. + public float4 Pose(int index, double time, float2 parentPosition, PlanetSettings settings) + { + var asteroid = Data.Asteroids[index]; + var rot = (float) (time * asteroid.RotationSpeed % (PI * 2)); + var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) + + asteroid.Phase)) * asteroid.Distance + parentPosition; + return float4(pos.x, pos.y, rot, Size(index, settings)); + } + + // Cut 1 (docs/mining-cut.md): the size rule of Zone.cs:259-267, moved unchanged. + public float Size(int index, PlanetSettings settings) + { + if (RespawnTimers.ContainsKey(index)) return 0; + if (Damage.ContainsKey(index)) + { + var asteroidHitpoints = settings.AsteroidHitpoints.Evaluate(Data.Asteroids[index].Size); + var damage = (asteroidHitpoints - Damage[index]) / asteroidHitpoints; + return settings.AsteroidSize.Evaluate(damage * Data.Asteroids[index].Size); + } + return settings.AsteroidSize.Evaluate(Data.Asteroids[index].Size); + } + + public void Evaluate(Span into, double time, float2 parentPosition, PlanetSettings settings) + { + for (var i = 0; i < Data.Asteroids.Length; i++) + into[i] = Pose(i, time, parentPosition, settings); + } } public class Orbit diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 0b437dcfe..13164eac3 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -72,6 +72,9 @@ public class ZoneRenderer : MonoBehaviour private Dictionary _beltObjects = new Dictionary(); private Dictionary _beltMeshes = new Dictionary(); private Dictionary _beltMatrices = new Dictionary(); + // Cut 1 (docs/mining-cut.md): the renderer's own buffer, filled from Zone.EvaluateBelt each frame. No pose + // is stored on AsteroidBelt any more. + private Dictionary _beltTransforms = new Dictionary(); private float _viewDistance; //private float _maxDepth; private float _minimapDistance; @@ -282,6 +285,7 @@ public class ZoneRenderer : MonoBehaviour _beltObjects.Clear(); _beltMeshes.Clear(); _beltMatrices.Clear(); + _beltTransforms.Clear(); _tourPlanets.Clear(); } } @@ -343,6 +347,7 @@ public class ZoneRenderer : MonoBehaviour meshes.RemoveAt(Random.Range(0, meshes.Count)); _beltMeshes[key] = meshes.ToArray(); _beltMatrices[key] = new Matrix4x4[meshes.Count][]; + _beltTransforms[key] = new float4[beltData.Asteroids.Length]; var count = beltData.Asteroids.Length / meshes.Count; var remainder = beltData.Asteroids.Length - count * meshes.Count; for (int i = 0; i < meshes.Count; i++) @@ -446,25 +451,30 @@ public class ZoneRenderer : MonoBehaviour foreach (var (key, belt) in Zone.AsteroidBelts) { - var height = Zone.GetHeight(belt.OrbitPosition); + // Cut 1 (docs/mining-cut.md): belts have no stored pose any more. The renderer fills its own buffer + // from Zone.EvaluateBelt, at the current zone time, every frame. + var orbitPosition = Zone.GetOrbitPosition(Zone.Orbits[belt.Data.Orbit.Key].Data.Parent.Key); + var height = Zone.GetHeight(orbitPosition); + var transforms = _beltTransforms[key]; + Zone.EvaluateBelt(key, transforms); if(isVisible(new Bounds( - new Vector3(belt.OrbitPosition.x,height,belt.OrbitPosition.y), + new Vector3(orbitPosition.x,height,orbitPosition.y), new Vector3(belt.Radius * 2,100,belt.Radius * 2)))) { var meshes = _beltMeshes[key]; var matrices = _beltMatrices[key]; - var count = belt.Transforms.Length / meshes.Length; + var count = transforms.Length / meshes.Length; for (int i = 0; i < meshes.Length; i++) { for (int t = 0; t < matrices[i].Length; t++) { var tx = t + i * count; - var transform = belt.Transforms[tx]; + var transform = transforms[tx]; matrices[i][t] = Matrix4x4.TRS(new Vector3(transform.x,0,transform.y), Quaternion.Euler( cos(transform.z + (float)i / meshes.Length) * 100, sin(transform.z + (float)i / meshes.Length) * 100, - (float)tx / belt.Transforms.Length * 360), + (float)tx / transforms.Length * 360), Vector3.one * transform.w); } @@ -473,7 +483,7 @@ public class ZoneRenderer : MonoBehaviour } if(_showAsteroidUI) - _beltObjects[key].Update(belt.Transforms, height); + _beltObjects[key].Update(transforms, height); } foreach (var planet in Planets) @@ -659,7 +669,7 @@ public class AsteroidBeltUI public void Update(float4[] transforms, float height) { - var parentPosition = _belt.OrbitPosition; + var parentPosition = _zone.GetOrbitPosition(_orbitParent); for (var i = 0; i < transforms.Length; i++) { var transform = transforms[i]; diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs new file mode 100644 index 000000000..b47a0bcc1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -0,0 +1,201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.IO; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; + +// Cut 1 (docs/mining-cut.md): belts stop being simulated every tick on a threaded double buffer and become a +// pure function of zone time, evaluated on request. These tests pin that: a chunk's pose is exactly the +// closed-form orbit at the zone's current time (no staleness), it does not depend on how many ticks it took to +// get there (no stored state), and the size rule moved off the deleted per-tick pass unchanged. Every expected +// value below is computed independently, by the closed form written in this file -- never by calling +// AsteroidBelt.Pose/Size, the code under test. +public sealed class MiningCut1Tests : IDisposable +{ + private int _fixtureCount; + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut1-" + Guid.NewGuid().ToString("N")); + + public MiningCut1Tests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings GameSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + // Authored-shape curves, not defaults: a default ExponentialLerp/ExponentialCurve evaluates to a flat + // constant everywhere, which would hide a broken formula (every asteroid or every damage state would read + // alike). These give period, size and hitpoints real, distinct curvature over the fixture's distance and + // damage range. + private static PlanetSettings Settings() => new PlanetSettings + { + OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, + AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, + AsteroidHitpoints = new ExponentialLerp { Minimum = 10f, Maximum = 300f, Exponent = 2f }, + AsteroidRespawnTime = new ExponentialLerp { Minimum = 5f, Maximum = 60f, Exponent = 1f } + }; + + private const int AsteroidCount = 24; + private const float RootDistance = 4000f; + private const float RootPhase = 0.17f; + + // A belt of AsteroidCount asteroids at distinct distances, phases, size parameters and rotation speeds, + // orbiting a parent (RootDistance/RootPhase) that is itself moving -- so a test that reads the parent's + // motion through ChunkPose is exercising real recursion, not a fixed point. Each call opens its own + // catalog, so two zones built from this fixture never share cache state. + private (Zone zone, CultRecordKey beltKey, Asteroid[] asteroids, PlanetSettings settings) BuildFixture() + { + var dir = Path.Combine(_root, $"cat{_fixtureCount++}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + var run = Path.Combine(dir, "run.cc"); + // OrbitData and BodyData (AsteroidBeltData) are run types (AetheriaStores.RunTypes), not catalog types: + // they need a run store to write into. + var cache = AetheriaStores.Open(catalog, run, catalogWritable: true); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var settings = Settings(); + + var rootOrbit = new OrbitData { Distance = RootDistance, Phase = RootPhase }; + var rootKey = cache.Upsert(rootOrbit).Key; + + var beltOrbit = new OrbitData { Parent = new CultRecordRef(rootKey), Distance = 900f, Phase = 0.42f }; + var beltOrbitKey = cache.Upsert(beltOrbit).Key; + + var asteroids = new Asteroid[AsteroidCount]; + for (var i = 0; i < AsteroidCount; i++) + { + asteroids[i] = new Asteroid + { + Distance = 120f + i * 11.3f, + Phase = frac(i * 0.6180339887f), + Size = frac(i * 0.37f + 0.1f), + RotationSpeed = 0.15f + i * 0.037f + }; + } + + var beltData = new AsteroidBeltData { Orbit = new CultRecordRef(beltOrbitKey), Asteroids = asteroids }; + var beltKey = cache.Upsert(beltData).Key; + + var pack = new ZonePack + { + Orbits = { new CultRecordRef(rootKey), new CultRecordRef(beltOrbitKey) }, + Planets = { new CultRecordRef(beltKey) }, + Radius = 5000f, + Mass = 10000f, + Time = 0 + }; + + var zone = new Zone(items, settings, pack, new GalaxyZone { Name = "MiningCut1", Owner = null }, null); + return (zone, beltKey, asteroids, settings); + } + + // The formula of Zone.GetOrbitPosition (unchanged by this cut), reimplemented here so the expected value + // never calls production orbit code. + private static float2 OrbitPosition(double time, float distance, float phase, PlanetSettings settings, float2 fixedPosition = default) + { + var period = settings.OrbitPeriod.Evaluate(distance); + if (period <= .01f) return fixedPosition; + var wrapped = (float) frac(time / period); + return OrbitData.Evaluate(frac(wrapped + phase)) * distance + fixedPosition; + } + + // The formula of AsteroidBelt.Pose (Zone.cs:269-271 before this cut), reimplemented independently. + private static float4 ExpectedPose(double time, Asteroid asteroid, float2 parentPosition, PlanetSettings settings, float size) + { + var rot = (float) (time * asteroid.RotationSpeed % (PI * 2)); + var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) + asteroid.Phase)) + * asteroid.Distance + parentPosition; + return float4(pos.x, pos.y, rot, size); + } + + private static void AssertClose(float expected, float actual, float eps, string what) + { + Assert.True(Math.Abs(expected - actual) <= eps, $"{what}: expected {expected}, got {actual} (eps {eps})"); + } + + [Fact] + public void ChunkPoseIsTheOrbitAtZoneTime() + { + var (zone, beltKey, asteroids, settings) = BuildFixture(); + + // Uneven dt, so a tick-count-based or double-buffered implementation would disagree with a pure + // function of the accumulated time. + var dts = new[] { 0.13f, 0.07f, 0.31f, 0.02f, 0.5f, 0.11f, 0.19f, 0.08f, 0.44f, 0.03f, 0.27f, 0.09f }; + double time = 0; + foreach (var dt in dts) + { + zone.Update(dt); + time += dt; + } + + var parentPosition = OrbitPosition(time, RootDistance, RootPhase, settings); + + for (var i = 0; i < asteroids.Length; i++) + { + var expected = ExpectedPose(time, asteroids[i], parentPosition, settings, settings.AsteroidSize.Evaluate(asteroids[i].Size)); + var actual = zone.ChunkPose(beltKey, i); + AssertClose(expected.x, actual.x, 0.01f, $"asteroid {i} x"); + AssertClose(expected.y, actual.y, 0.01f, $"asteroid {i} y"); + AssertClose(expected.z, actual.z, 0.001f, $"asteroid {i} rotation"); + AssertClose(expected.w, actual.w, 0.001f, $"asteroid {i} size"); + } + } + + [Fact] + public void ChunkPoseDoesNotDependOnUpdateCadence() + { + var (zoneOneStep, beltKeyA, asteroids, settings) = BuildFixture(); + zoneOneStep.Update(1.0f); + + var (zoneManySteps, beltKeyB, _, _) = BuildFixture(); + for (var i = 0; i < 60; i++) + zoneManySteps.Update(1f / 60f); + + for (var i = 0; i < asteroids.Length; i++) + { + var a = zoneOneStep.ChunkPose(beltKeyA, i); + var b = zoneManySteps.ChunkPose(beltKeyB, i); + // Float summation of 60 steps of 1/60 drifts from 1.0 by a few ULPs; the pose is a pure function + // of that (nearly identical) time, not of how many ticks produced it, so a loose position epsilon + // catches a cadence-dependent implementation without chasing float noise. + AssertClose(a.x, b.x, 0.05f, $"asteroid {i} x"); + AssertClose(a.y, b.y, 0.05f, $"asteroid {i} y"); + AssertClose(a.z, b.z, 0.01f, $"asteroid {i} rotation"); + AssertClose(a.w, b.w, 0.0001f, $"asteroid {i} size"); + } + } + + [Fact] + public void DamagedChunkShrinksAndABrokenOneHasNoSize() + { + var (zone, beltKey, asteroids, settings) = BuildFixture(); + var belt = zone.AsteroidBelts[beltKey]; + + const int undamagedIndex = 5; + const int damagedIndex = 9; + const int brokenIndex = 14; + + var undamagedExpected = settings.AsteroidSize.Evaluate(asteroids[undamagedIndex].Size); + Assert.Equal(undamagedExpected, belt.Size(undamagedIndex, settings), 4); + + var hp = settings.AsteroidHitpoints.Evaluate(asteroids[damagedIndex].Size); + var damage = hp * 0.6f; + belt.Damage[damagedIndex] = damage; + var damagedExpected = settings.AsteroidSize.Evaluate((hp - damage) / hp * asteroids[damagedIndex].Size); + var damagedActual = belt.Size(damagedIndex, settings); + Assert.Equal(damagedExpected, damagedActual, 4); + Assert.True(damagedActual < settings.AsteroidSize.Evaluate(asteroids[damagedIndex].Size), + "a damaged asteroid must be smaller than its undamaged size"); + + belt.RespawnTimers[brokenIndex] = settings.AsteroidRespawnTime.Evaluate(asteroids[brokenIndex].Size); + Assert.Equal(0f, belt.Size(brokenIndex, settings)); + } +} From d9c887c129664a578a94f34c3dab19cc0c0a0f39 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 01:49:51 +0200 Subject: [PATCH 03/45] Cut 1 Stryker fix batch: cover EvaluateBelt and belt Radius Stryker over Zone.cs (231 mutants, scoped by --mutate to this cut's file) found two real gaps, both NoCoverage/Survived, not equivalents: - Zone.EvaluateBelt and AsteroidBelt.Evaluate -- the renderer's own path (ZoneRenderer.cs Update, AsteroidBeltUI.Update) -- were never called by any test, so the whole batch body and its loop bound were unreached. EvaluateBeltFillsEveryAsteroidExactlyAsChunkPoseWould pins that the batch path fills every index exactly as the per-index ChunkPose would, closing the gap without duplicating the orbit formula a second time. - AsteroidBelt.Radius (data.Asteroids.Max(a => a.Distance)) had no assertion; a Max-to-Min mutant survived. BeltRadiusIsTheFarthestAsteroidsDistance pins it, since the renderer's visibility bounds and minimap mesh both size off this field. Full suite: 262/262 green (260 + these 2). Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut1Tests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs index b47a0bcc1..d35dcfca4 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -198,4 +198,44 @@ public sealed class MiningCut1Tests : IDisposable belt.RespawnTimers[brokenIndex] = settings.AsteroidRespawnTime.Evaluate(asteroids[brokenIndex].Size); Assert.Equal(0f, belt.Size(brokenIndex, settings)); } + + // Stryker gap (2026-09-25 Stryker pass on this cut): nothing called Zone.EvaluateBelt or + // AsteroidBelt.Evaluate, the renderer's own path (ZoneRenderer.cs:452-478), leaving both at NoCoverage. + // This does not re-derive the orbit formula (that is ChunkPoseIsTheOrbitAtZoneTime's job); it pins that the + // batch path is the same function as the per-index one, per index, over every asteroid -- so the renderer's + // buffer can never quietly diverge into a second copy of the pose formula. + [Fact] + public void EvaluateBeltFillsEveryAsteroidExactlyAsChunkPoseWould() + { + var (zone, beltKey, asteroids, _) = BuildFixture(); + zone.Update(0.37f); + + var buffer = new float4[asteroids.Length]; + zone.EvaluateBelt(beltKey, buffer); + + Assert.Equal(asteroids.Length, buffer.Length); + for (var i = 0; i < asteroids.Length; i++) + { + var expected = zone.ChunkPose(beltKey, i); + Assert.Equal(expected.x, buffer[i].x, 4); + Assert.Equal(expected.y, buffer[i].y, 4); + Assert.Equal(expected.z, buffer[i].z, 4); + Assert.Equal(expected.w, buffer[i].w, 4); + } + } + + // Stryker gap: AsteroidBelt.Radius (Zone.cs, AsteroidBelt constructor) had no assertion, so Max survived a + // Min mutation. The renderer's visibility bounds (ZoneRenderer.cs:452) and minimap mesh (:663) both use it + // as the belt's farthest asteroid; a Min there would cull or mis-bound the belt. + [Fact] + public void BeltRadiusIsTheFarthestAsteroidsDistance() + { + var (zone, beltKey, asteroids, _) = BuildFixture(); + + var expectedRadius = float.MinValue; + foreach (var a in asteroids) + if (a.Distance > expectedRadius) expectedRadius = a.Distance; + + Assert.Equal(expectedRadius, zone.AsteroidBelts[beltKey].Radius, 4); + } } From 670831c8af16a494e72557e539a00b187b1c3d2a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 02:39:42 +0200 Subject: [PATCH 04/45] Mining Cut 1 status, and a corrected cost baseline Co-Authored-By: Claude Opus 5.5 --- docs/mining-cut.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/mining-cut.md b/docs/mining-cut.md index 064d55e63..64408e95c 100644 --- a/docs/mining-cut.md +++ b/docs/mining-cut.md @@ -464,10 +464,29 @@ Rulings (operator): none yet. Q1-Q9 open. Open: Q1-Q9. Cut 1 needs none of them. +**Cut 1: landed 2026-09-25** at `22a54ccb` and `d9c887c1` (Stryker fix batch: `EvaluateBelt` had no +coverage and `Radius`'s `Max`→`Min` survived). 262 tests. Probe on the operator's densest zone, 10 runs each: +before, 541-3654 µs wall and 120-391 µs CPU per tick with **3/10 runs crashing** on the belt-task race; after, +95-185 µs wall and 99-152 µs CPU, **0/10 races**. Soul closed it: no thread or task left in ServerShared, poses a +pure function of zone time (formula byte-identical to `e729e6d6`), one owner, no per-frame renderer allocation +(the buffer is allocated once in `LoadPlanet`). + +Correction to this map's own baseline: the "31-39 µs with no belt work" figure above does not reproduce for a +zone of this shape. Soul's independent probe of the same 56-body, 8-belt layout: 100 µs per tick with belts, +114 µs with the belts replaced by plain bodies at equal orbit count — belts cost nothing per tick. The ~100 µs +that remains is the pre-existing per-body orbit step (about 2 µs per orbit over ~57 orbits), untouched by this +cut. The 31-39 µs was measured on a smaller body count. + +Still owed for Cut 1: a Unity compile of the renderer change (`ZoneRenderer.cs`, `AsteroidBeltUI`), which runs +when this branch meets a tree Unity can open; an isolated Stryker rerun by a later Soul (the closing pass's run +collided with its own concurrent build); and the operator's fly-through. + Follow-ups outside this campaign: - `AsteroidBeltUI.Update` feeds `Quaternion.Euler(90, transform.z, 0)` a rotation in radians where the API takes degrees (`ZoneRenderer.cs:666`). It is a presentation defect and is not fixed here. -- The planet orbit step stays per tick (`Zone.cs:156-161`). It is cheap. +- The planet orbit step stays per tick (`Zone.cs:156-161`). It is now the whole of the zone's per-tick cost + (~2 µs per orbit). 2 µs for one orbit evaluation is more than its arithmetic warrants — curve evaluation or + the `_updatedOrbits` memo is the likely cost. Measure before touching it; a candidate follow-up, not scope. ## Cut order From 007771fe878e2d124f7055da17be5425f89d1e91 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 10:06:39 +0200 Subject: [PATCH 05/45] Mining Q1 ruled: MiningTool goes, the Drill Bit returns as a weapon Co-Authored-By: Claude Opus 5.5 --- docs/mining-cut.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/mining-cut.md b/docs/mining-cut.md index 64408e95c..b6d31c3e1 100644 --- a/docs/mining-cut.md +++ b/docs/mining-cut.md @@ -149,7 +149,8 @@ other way. Self delivers these one at a time (memory: pace operator questions). Cut 1 needs no ruling. The CPU-versus-GPU belt renderer is a stated default: CPU, one orbit function, so no parity test is needed. Reopen it on request. -**Q1 (blocks Cut 2). What becomes of `MiningTool`, `ResourceScanner` and the Drill Bit?** +**Q1 (blocks Cut 2). What becomes of `MiningTool`, `ResourceScanner` and the Drill Bit?** **Ruled 2026-09-25: A.** +Operator: "Bring back the drill, indeed." The operator's model is "hit it with a weapon". `MiningTool` (`Behaviors/MiningTool.cs`, 76 lines) is a separate damage channel with its own DPS, efficiency, penetration and range. Nothing assigns its target (`AsteroidBelt` and `Asteroid` have no writer), and it throws `KeyNotFoundException` if it ever executes with an unset belt @@ -460,9 +461,9 @@ lot. That includes every existing loadout ammo stack. Reading such a lot through Status: cut map. Ends are owned by Part I of this document; Part III owns the means. -Rulings (operator): none yet. Q1-Q9 open. +Rulings (operator): Q1 = A (2026-09-25). Q2-Q9 open. -Open: Q1-Q9. Cut 1 needs none of them. +Open: Q2-Q9. Cut 1 needs none of them; Cut 2 now waits only on Q3. **Cut 1: landed 2026-09-25** at `22a54ccb` and `d9c887c1` (Stryker fix batch: `EvaluateBelt` had no coverage and `Radius`'s `Max`→`Min` survived). 262 tests. Probe on the operator's densest zone, 10 runs each: From e4d38f16faeaee904bb47319b321c43bde2a573f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 11:09:35 +0200 Subject: [PATCH 06/45] Mining Q3 ruled: chunks respawn on the authored curve, wear persists Co-Authored-By: Claude Opus 5.5 --- docs/mining-cut.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/mining-cut.md b/docs/mining-cut.md index b6d31c3e1..a3434bc3b 100644 --- a/docs/mining-cut.md +++ b/docs/mining-cut.md @@ -203,7 +203,7 @@ later: area(blast disc)`, which is 12.4's own area rule with the chunk disc as the only cell. Untargeted chunks near a blast are unaffected, so there is no per-blast scan of belts. -**Q3 (blocks Cut 2). Do chunks respawn, and does wear survive save/continue?** +**Q3 (blocks Cut 2). Do chunks respawn, and does wear survive save/continue?** **Ruled 2026-09-25: A.** Today `MineAsteroid` sets `RespawnTimers` (`Zone.cs:316`), but nothing ever decrements them, and `AsteroidExists` (`:247`) ignores them. A broken asteroid renders at size 0 forever and can still be mined **(read)**. None of this is persisted. @@ -461,9 +461,9 @@ lot. That includes every existing loadout ammo stack. Reading such a lot through Status: cut map. Ends are owned by Part I of this document; Part III owns the means. -Rulings (operator): Q1 = A (2026-09-25). Q2-Q9 open. +Rulings (operator): Q1 = A, Q3 = A (2026-09-25). Q2, Q4-Q9 open. -Open: Q2-Q9. Cut 1 needs none of them; Cut 2 now waits only on Q3. +Open: Q2, Q4-Q9. Cut 2 is unblocked. **Cut 1: landed 2026-09-25** at `22a54ccb` and `d9c887c1` (Stryker fix batch: `EvaluateBelt` had no coverage and `Radius`'s `Max`→`Min` survived). 262 tests. Probe on the operator's densest zone, 10 runs each: From 3b9f0f1d09efa9ea6a831a3033d26a3f1f71ee56 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 11:30:44 +0200 Subject: [PATCH 07/45] Cut 2: the dead mining path goes, and chunks get one owner MiningTool (its Execute range-checked against nothing, since no writer ever set AsteroidBelt/Asteroid) and Zone.MineAsteroid (mints nothing; the SimpleCommodity drop was commented out) are deleted, per Q1=A: "Bring back the drill, indeed." Union tag 26 is retired, not reused. ResourceScanner's dead survey Execute and its scan-target fields go too; its authored Range/ MinimumDensity/ScanDuration stay pending Q2. PlanetSettings.MiningDifficulty had one reader, now gone. Chunk wear (damage, broken-until) moves off AsteroidBelt's three per-belt dictionaries and onto Zone as the one owner (Q3=A): Zone.Wear is the only writer, ChunkExists/ChunkRadius are the readers, and respawn is a lazy comparison against zone time, never a per-tick loop. ChunkId is (CultRecordKey Field, int Index) -- "Field", not "Belt", because chunks are meant to generalize past asteroid belts to other debris fields later. Persisted in ZonePack's new key 6 (List, nullable so an older record with no key 6 loads with no wear instead of throwing), pruned of healed, non-broken entries at pack. The break threshold stays strictly greater-than, matching today's behaviour: damage exactly equal to hitpoints does not yet break the chunk. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/Behaviors.cs | 2 +- .../ServerShared/Behaviors/MiningTool.cs | 77 -------- .../ServerShared/Behaviors/MiningTool.cs.meta | 11 -- .../ServerShared/Behaviors/ResourceScanner.cs | 64 +----- Assets/Scripts/ServerShared/Settings.cs | 1 - Assets/Scripts/ServerShared/Zone.cs | 183 +++++++++++------- Assets/Scripts/ServerShared/ZoneData.cs | 25 +++ .../Aetheria.Shared.Tests/MiningCut1Tests.cs | 15 +- 8 files changed, 152 insertions(+), 226 deletions(-) delete mode 100644 Assets/Scripts/ServerShared/Behaviors/MiningTool.cs delete mode 100644 Assets/Scripts/ServerShared/Behaviors/MiningTool.cs.meta diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index b9c4c152e..e1d0d8cea 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -170,7 +170,7 @@ public interface IPowerConsumer Union(23, typeof(ThermotoggleData)), Union(24, typeof(EnergyDrawData)), // Union(25, typeof(MiningControllerData)), - Union(26, typeof(MiningToolData)), + // Union(26): retired, do not reuse -- was the "hit it with a tool" behavior deleted in Cut 2 (docs/mining-cut.md, Q1=A). // Union(27, typeof(SurveyControllerData)), Union(28, typeof(ResourceScannerData)), // Union(29, typeof(WanderControllerData)), diff --git a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs b/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs deleted file mode 100644 index 1b388100f..000000000 --- a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs +++ /dev/null @@ -1,77 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ - -using GameCult.Caching; -using System; -using System.Linq; -using MessagePack; -using Newtonsoft.Json; -using CultMath; -using static CultMath.math; - -[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] -public class MiningToolData : BehaviorData -{ - [Inspectable, JsonProperty("dps"), Key(1)] - public PerformanceStat DamagePerSecond = new PerformanceStat(); - - [Inspectable, JsonProperty("efficiency"), Key(2)] - public PerformanceStat Efficiency = new PerformanceStat(); - - [Inspectable, JsonProperty("penetration"), Key(3)] - public PerformanceStat Penetration = new PerformanceStat(); - - [Inspectable, JsonProperty("range"), Key(4)] - public PerformanceStat Range = new PerformanceStat(); - - public override Behavior CreateInstance(EquippedItem item) - { - return new MiningTool(this, item); - } - public override Behavior CreateInstance(ConsumableItemEffect item) - { - return new MiningTool(this, item); - } -} - -public class MiningTool : Behavior -{ - public CultRecordKey AsteroidBelt; - public int Asteroid; - - private MiningToolData _data; - public float Range { get; private set; } - - public MiningTool(MiningToolData data, EquippedItem item) : base(data, item) - { - _data = data; - } - - public MiningTool(MiningToolData data, ConsumableItemEffect item) : base(data, item) - { - _data = data; - } - - public override bool Execute(float dt) - { - Range = Evaluate(_data.Range); - if (!AsteroidBelt.IsSet() || !Entity.Zone.AsteroidExists(AsteroidBelt, Asteroid)) - return false; - - var pose = Entity.Zone.ChunkPose(AsteroidBelt, Asteroid); - if (length(Entity.Position.xz - pose.xy) - pose.w < Range) - { - Entity.Zone.MineAsteroid( - Entity, - AsteroidBelt, - Asteroid, - Evaluate(_data.DamagePerSecond) * dt, - Evaluate(_data.Efficiency), - Evaluate(_data.Penetration)); - return true; - } - - return false; - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs.meta b/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs.meta deleted file mode 100644 index 9985f36a2..000000000 --- a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a7eebfaae57b2304580ef7e4a2740993 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs index a8379204f..051dd5832 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs @@ -2,13 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -using GameCult.Caching; -using System; -using System.Linq; using MessagePack; using Newtonsoft.Json; -using CultMath; -using static CultMath.math; [Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] public class ResourceScannerData : BehaviorData @@ -33,31 +28,18 @@ public class ResourceScannerData : BehaviorData } } +// Cut 2 (docs/mining-cut.md, Q1=A): the dead survey Execute and its scan-target fields are gone +// (ScanTarget/Asteroid/_scanTime/_scanTarget, and the always-false-until-scanned PlanetSurveyFloor write it +// worked toward). Range, MinimumDensity and ScanDuration stay authored data on this behavior until Q2 rules +// whether the scanner becomes a chunk's detection-range gate (docs/mining-cut.md Q2 consequence "Visibility"). public class ResourceScanner : Behavior, IAlwaysUpdatedBehavior { - public int Asteroid = -1; - private ResourceScannerData _data; - private float _scanTime; - private CultRecordKey _scanTarget; public float Range { get; private set; } public float MinimumDensity { get; private set; } public float ScanDuration { get; private set; } - public CultRecordKey ScanTarget - { - get => _scanTarget; - set - { - if (!value.Equals(_scanTarget)) - { - _scanTarget = value; - _scanTime = 0; - } - } - } - public ResourceScanner(ResourceScannerData data, EquippedItem item) : base(data, item) { _data = data; @@ -68,44 +50,6 @@ public class ResourceScanner : Behavior, IAlwaysUpdatedBehavior _data = data; } - public override bool Execute(float dt) - { - var planetData = Entity.Zone.Planets[ScanTarget]; - if (planetData != null) - { - if (planetData is AsteroidBeltData beltData) - { - if(Asteroid > -1 && - Asteroid < beltData.Asteroids.Length && - length(Entity.Position.xz - Entity.Zone.ChunkPose(ScanTarget, Asteroid).xy) < Range) - { - _scanTime += dt; - if (_scanTime > ScanDuration) - { - // TODO: Implement Scanning! - //Context.ItemData.Get(Entity.Corporation).PlanetSurveyFloor[ScanTarget] = MinimumDensity; - _scanTime = 0; - } - return true; - } - } - else - { - if(length(Entity.Position.xz - Entity.Zone.GetOrbitPosition(planetData.Orbit.Key)) < Range) - { - _scanTime += dt; - if (_scanTime > ScanDuration) - { - //Context.ItemData.Get(Entity.Corporation).PlanetSurveyFloor[ScanTarget] = MinimumDensity; - _scanTime = 0; - } - return true; - } - } - } - return false; - } - public void Update(float delta) { Range = Evaluate(_data.Range); diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index ffd369b01..67de2f0aa 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -29,7 +29,6 @@ public class PlanetSettings public ExponentialLerp AsteroidHitpoints; public ExponentialLerp AsteroidRespawnTime; public float GravityStrength; - public float MiningDifficulty = 500f; public float SecureAreaRadiusMultiplier = .45f; public ExponentialCurve OrbitPeriod; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index af7d22436..c04fa4b8e 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -11,7 +11,6 @@ using UniRx; using CultMath; using static CultMath.math; using float2 = CultMath.float2; -using Random = CultMath.Random; public class Zone { @@ -28,9 +27,12 @@ public class Zone private ItemManager _itemManager; private double _time; - private Random _random; public List Agents = new List(); + // Cut 2 (docs/mining-cut.md): chunk wear, keyed by chunk. Zone.Wear is the only writer; ChunkExists and + // ChunkRadius are the only readers besides the pack/unpack round trip below. + private Dictionary _wear = new Dictionary(); + // Cut 3 (docs/fire-control-cut.md, 0b table): Zone owns the pending-shot collection and the ShotId // namespace; FireControl owns every transition a shot goes through. ShotCommitted publishes once a shot's // outcome is decided (R4's commit horizon); ShotResolved republishes the same, unchanged outcome at @@ -64,7 +66,6 @@ public class Zone _itemManager = itemManager; Settings = settings; CombatSeed = galaxyZone?.Name.StableHash() ?? 1337u; - _random = new Random(CombatSeed); var cache = itemManager.ItemData; // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship, @@ -99,6 +100,13 @@ public class Zone } } + // Cut 2 (docs/mining-cut.md): read after belts are built so a later ChunkExists/ChunkRadius call has + // Planets/AsteroidBelts populated. A pack from before key 6 existed has ChunkWear == null (nullable + // persistence rule); that reads as no wear rather than throwing. + if (pack.ChunkWear != null) + foreach (var wear in pack.ChunkWear) + _wear[new ChunkId(wear.Field, wear.Index)] = new ChunkWear { Damage = wear.Damage, BrokenUntil = wear.BrokenUntil }; + foreach (var entityPack in pack.Entities) { var entity = EntitySerializer.Unpack(_itemManager, this, entityPack); @@ -133,10 +141,19 @@ public class Zone Entities = Entities.Select(EntitySerializer.Pack).ToList(), Orbits = Orbits.Keys.Select(key => new CultRecordRef(key)).ToList(), Planets = Planets.Keys.Select(key => new CultRecordRef(key)).ToList(), - Time = _time + Time = _time, + // Cut 2 (docs/mining-cut.md): drop entries that carry no live information -- healed (no damage) and + // not currently broken -- so a save never accumulates wear rows for chunks nobody has touched since + // they last respawned. + ChunkWear = _wear + .Where(kv => !IsExpired(kv.Value)) + .Select(kv => new ChunkWearPack { Field = kv.Key.Field, Index = kv.Key.Index, Damage = kv.Value.Damage, BrokenUntil = kv.Value.BrokenUntil }) + .ToList() }; } + private bool IsExpired(ChunkWear wear) => wear.Damage <= 0f && (!wear.BrokenUntil.HasValue || wear.BrokenUntil.Value <= _time); + public void AddOrbit(OrbitData orbit) { Orbits.Add(_itemManager.ItemData.RefOf(orbit).Key, new Orbit(Settings, orbit)); @@ -209,7 +226,54 @@ public class Zone return float2.zero; } - public bool AsteroidExists(CultRecordKey planetDataID, int asteroid) => ((AsteroidBeltData) Planets[planetDataID]).Asteroids.Length > asteroid && asteroid >= 0; + // Cut 2 (docs/mining-cut.md): the index is in range for its field and the chunk is not broken at zone time. + // Respawn is a comparison against zone time, never a per-tick loop or a write on read. + public bool ChunkExists(ChunkId chunk) + { + if (!(Planets.TryGetValue(chunk.Field, out var body) && body is AsteroidBeltData beltData)) + return false; + if (chunk.Index < 0 || chunk.Index >= beltData.Asteroids.Length) + return false; + return !(_wear.TryGetValue(chunk, out var wear) && wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time); + } + + // Cut 2: the size rule of AsteroidBelt.Size (Cut 1), now reading wear from its one owner (Zone) instead of + // per-belt dictionaries. A broken chunk (BrokenUntil in the future) has no size; an unbroken, undamaged + // chunk reads its authored size; a damaged one shrinks toward it. + public float ChunkRadius(ChunkId chunk) + { + var belt = AsteroidBelts[chunk.Field]; + if (!_wear.TryGetValue(chunk, out var wear)) + return belt.UndamagedSize(chunk.Index, Settings); + if (wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time) + return 0f; + if (wear.Damage <= 0f) + return belt.UndamagedSize(chunk.Index, Settings); + return belt.DamagedSize(chunk.Index, wear.Damage, Settings); + } + + // Cut 2: the only writer of chunk wear. Returns whether this hit broke the chunk. The break threshold is + // strictly greater-than, matching the deleted per-tick miner's own comparison against accumulated damage: + // damage exactly equal to hitpoints does not yet break the chunk, only damage that exceeds them does. + public bool Wear(ChunkId chunk, float damage) + { + var beltData = (AsteroidBeltData) Planets[chunk.Field]; + var size = beltData.Asteroids[chunk.Index].Size; + var hitpoints = Settings.AsteroidHitpoints.Evaluate(size); + + _wear.TryGetValue(chunk, out var wear); + var newDamage = wear.Damage + damage; + + if (newDamage > hitpoints) + { + var respawnTime = Settings.AsteroidRespawnTime.Evaluate(size); + _wear[chunk] = new ChunkWear { Damage = 0f, BrokenUntil = _time + respawnTime }; + return true; + } + + _wear[chunk] = new ChunkWear { Damage = newDamage, BrokenUntil = null }; + return false; + } // Cut 1 (docs/mining-cut.md): a chunk's pose is a pure function of zone time, computed when asked. No stored // pose survives a tick; the sim and the renderer both read it fresh through here. @@ -217,14 +281,17 @@ public class Zone { var beltData = Planets[belt] as AsteroidBeltData; var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); - return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings); + var size = ChunkRadius(new ChunkId(belt, index)); + return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings, size); } public void EvaluateBelt(CultRecordKey belt, Span into) { var beltData = Planets[belt] as AsteroidBeltData; var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); - AsteroidBelts[belt].Evaluate(into, _time, parentPosition, Settings); + var beltInstance = AsteroidBelts[belt]; + for (var i = 0; i < beltData.Asteroids.Length; i++) + into[i] = beltInstance.Pose(i, _time, parentPosition, Settings, ChunkRadius(new ChunkId(belt, i))); } public OrbitData CreateOrbit(CultRecordKey parent, float2 position) @@ -247,46 +314,6 @@ public class Zone return orbit; } - public void MineAsteroid(Entity miner, CultRecordKey asteroidBelt, int asteroid, float damage, float efficiency, float penetration) - { - var beltData = Planets[asteroidBelt] as AsteroidBeltData; - var belt = AsteroidBelts[asteroidBelt]; - //var asteroidTransform = belt.Transforms[asteroid]; - - var size = beltData.Asteroids[asteroid].Size; - var asteroidHitpoints = Settings.AsteroidHitpoints.Evaluate(size); - - if (!belt.Damage.ContainsKey(asteroid)) - belt.Damage[asteroid] = 0; - belt.Damage[asteroid] = belt.Damage[asteroid] + damage; - - if (!belt.MiningAccumulator.ContainsKey((miner, asteroid))) - belt.MiningAccumulator[(miner, asteroid)] = 0; - belt.MiningAccumulator[(miner, asteroid)] = belt.MiningAccumulator[(miner, asteroid)] + damage; - - if (belt.Damage[asteroid] > asteroidHitpoints) - { - belt.RespawnTimers[asteroid] = Settings.AsteroidRespawnTime.Evaluate(size); - belt.Damage.Remove(asteroid); - belt.MiningAccumulator.Remove((miner, asteroid)); - return; - } - - var resourceCount = beltData.Resources.Sum(x => x.Value); - var resource = beltData.Resources.MaxBy(x => pow(x.Value, 1f / penetration) * _random.NextFloat()); - if (efficiency * _random.NextFloat() * belt.MiningAccumulator[(miner, asteroid)] * resourceCount / Settings.MiningDifficulty > 1) - { - belt.MiningAccumulator.Remove((miner, asteroid)); - // var newSimpleCommodity = new SimpleCommodity - // { - // Data = resource.Key, - // Quantity = 1 - // }; - // TODO: Drop item onto the Grid - //miner.AddCargo(newSimpleCommodity); - } - } - public SecurityLevel GetSecurityLevel(float2 pos) { if (GalaxyZone.Owner==null) return SecurityLevel.Open; @@ -456,9 +483,6 @@ public class AsteroidBelt { public AsteroidBeltData Data; public float Radius { get; } - public Dictionary RespawnTimers = new Dictionary(); - public Dictionary Damage = new Dictionary(); - public Dictionary<(Entity, int), float> MiningAccumulator = new Dictionary<(Entity, int), float>(); public AsteroidBelt(AsteroidBeltData data) { @@ -467,34 +491,57 @@ public class AsteroidBelt } // Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used. - // The only place a chunk pose is computed; no per-tick copy survives it. - public float4 Pose(int index, double time, float2 parentPosition, PlanetSettings settings) + // The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from + // per-belt storage -- Zone owns wear now, so the caller (Zone.ChunkPose/EvaluateBelt) computes it through + // Zone.ChunkRadius and passes it in. + public float4 Pose(int index, double time, float2 parentPosition, PlanetSettings settings, float size) { var asteroid = Data.Asteroids[index]; var rot = (float) (time * asteroid.RotationSpeed % (PI * 2)); var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) + asteroid.Phase)) * asteroid.Distance + parentPosition; - return float4(pos.x, pos.y, rot, Size(index, settings)); + return float4(pos.x, pos.y, rot, size); } - // Cut 1 (docs/mining-cut.md): the size rule of Zone.cs:259-267, moved unchanged. - public float Size(int index, PlanetSettings settings) + // Cut 1's size rule (Zone.cs:259-267), split in Cut 2 into its undamaged and damaged halves now that wear + // lives on Zone instead of here. Neither reads wear directly; the caller (Zone.ChunkRadius) decides which + // applies and supplies the damage. + public float UndamagedSize(int index, PlanetSettings settings) => settings.AsteroidSize.Evaluate(Data.Asteroids[index].Size); + + public float DamagedSize(int index, float damage, PlanetSettings settings) { - if (RespawnTimers.ContainsKey(index)) return 0; - if (Damage.ContainsKey(index)) - { - var asteroidHitpoints = settings.AsteroidHitpoints.Evaluate(Data.Asteroids[index].Size); - var damage = (asteroidHitpoints - Damage[index]) / asteroidHitpoints; - return settings.AsteroidSize.Evaluate(damage * Data.Asteroids[index].Size); - } - return settings.AsteroidSize.Evaluate(Data.Asteroids[index].Size); + var asteroidHitpoints = settings.AsteroidHitpoints.Evaluate(Data.Asteroids[index].Size); + var remaining = (asteroidHitpoints - damage) / asteroidHitpoints; + return settings.AsteroidSize.Evaluate(remaining * Data.Asteroids[index].Size); + } +} + +// Cut 2 (docs/mining-cut.md): a chunk is (field, index). "Field" is deliberate, not "belt" -- the operator's +// ruling generalizes chunks to other kinds of debris field later; only AsteroidBelt/AsteroidBeltData, the one +// live field kind, keep their existing names. +public readonly struct ChunkId : IEquatable +{ + public readonly CultRecordKey Field; + public readonly int Index; + + public ChunkId(CultRecordKey field, int index) + { + Field = field; + Index = index; } - public void Evaluate(Span into, double time, float2 parentPosition, PlanetSettings settings) - { - for (var i = 0; i < Data.Asteroids.Length; i++) - into[i] = Pose(i, time, parentPosition, settings); - } + public bool Equals(ChunkId other) => Field.Equals(other.Field) && Index == other.Index; + public override bool Equals(object obj) => obj is ChunkId other && Equals(other); + public override int GetHashCode() => (Field.GetHashCode() * 397) ^ Index; +} + +// Cut 2: wear on one chunk. BrokenUntil is the absolute zone time the chunk respawns at; null means the chunk +// carries damage (or none) but is not currently broken. Respawn is read lazily by comparing to zone time -- +// nothing decrements or clears this on a timer. +public struct ChunkWear +{ + public float Damage; + public double? BrokenUntil; } public class Orbit diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 90158fdb4..7b3515841 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -35,7 +35,32 @@ public class ZonePack [JsonProperty("time"), Key(5)] public double Time; + // Cut 2 (docs/mining-cut.md): chunk wear (damage taken, broken-until time), keyed by chunk. Nullable, not + // defaulted to an empty list: an older record has no key 6 at all, and MessagePack fills a missing trailing + // key with nil, which a reference-typed field reads back as null. ARunWithoutKeySixLoadsWhole pins that a + // record from before this key existed loads with no wear, rather than throwing or silently reading a + // default instance. + [JsonProperty("chunkWear"), Key(6)] + public List ChunkWear; +} +// Cut 2 (docs/mining-cut.md): one packed wear entry for one chunk. "Field" names the body that owns the chunk +// (an asteroid belt today; the operator's ruling generalizes chunks to other kinds of debris field later), not +// "belt" -- the chunk surface itself must not bake in the one live field kind. +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class ChunkWearPack +{ + [JsonProperty("field"), Key(0)] + public CultRecordKey Field; + + [JsonProperty("index"), Key(1)] + public int Index; + + [JsonProperty("damage"), Key(2)] + public float Damage; + + [JsonProperty("brokenUntil"), Key(3)] + public double? BrokenUntil; } [JsonObject(MemberSerialization.OptIn)] diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs index d35dcfca4..3091fad4d 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -173,30 +173,29 @@ public sealed class MiningCut1Tests : IDisposable } } + // Cut 2 (docs/mining-cut.md) moved wear off AsteroidBelt and onto Zone, so this now pins only the two pure + // size formulas AsteroidBelt still owns (undamaged, and damaged-given-a-damage-value). Whether a chunk is + // broken, and what its size reads as when broken, is Zone's decision -- see MiningCut2Tests + // (BrokenChunkHasNoRadius, DamagedChunkShrinksTowardBroken). [Fact] - public void DamagedChunkShrinksAndABrokenOneHasNoSize() + public void UndamagedAndDamagedSizeAreThePureCurves() { var (zone, beltKey, asteroids, settings) = BuildFixture(); var belt = zone.AsteroidBelts[beltKey]; const int undamagedIndex = 5; const int damagedIndex = 9; - const int brokenIndex = 14; var undamagedExpected = settings.AsteroidSize.Evaluate(asteroids[undamagedIndex].Size); - Assert.Equal(undamagedExpected, belt.Size(undamagedIndex, settings), 4); + Assert.Equal(undamagedExpected, belt.UndamagedSize(undamagedIndex, settings), 4); var hp = settings.AsteroidHitpoints.Evaluate(asteroids[damagedIndex].Size); var damage = hp * 0.6f; - belt.Damage[damagedIndex] = damage; var damagedExpected = settings.AsteroidSize.Evaluate((hp - damage) / hp * asteroids[damagedIndex].Size); - var damagedActual = belt.Size(damagedIndex, settings); + var damagedActual = belt.DamagedSize(damagedIndex, damage, settings); Assert.Equal(damagedExpected, damagedActual, 4); Assert.True(damagedActual < settings.AsteroidSize.Evaluate(asteroids[damagedIndex].Size), "a damaged asteroid must be smaller than its undamaged size"); - - belt.RespawnTimers[brokenIndex] = settings.AsteroidRespawnTime.Evaluate(asteroids[brokenIndex].Size); - Assert.Equal(0f, belt.Size(brokenIndex, settings)); } // Stryker gap (2026-09-25 Stryker pass on this cut): nothing called Zone.EvaluateBelt or From c924bae0dc1a1a0e11a2bb11c27d75e1543e118d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 11:31:22 +0200 Subject: [PATCH 08/45] Cut 2: tests for chunk wear's accumulation, break, respawn and persistence Fixtures use two belts of distinct sizes and authored-shape hitpoints/ respawn curves, exercised over uneven time steps, never the flat default curves that would hide a broken formula. Pins: damage accumulates and shrinks the chunk before it breaks; the break threshold is strictly greater-than (equal-to-hitpoints does not break); respawn is a comparison against zone time, reached through uneven dt steps; a chunk broken, respawned, and damaged again starts from zero rather than leaking stale wear; wear does not cross-talk between chunks that share a field or an index; wear round-trips through PackZone/Zone(pack); a healed, non-broken entry is pruned from the pack; and a record built as raw MessagePack bytes with only the six pre-Cut-2 keys (not a default- constructed ZonePack) deserializes with ChunkWear == null and loads with no wear, per the nullable-key-6 persistence rule. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut2Tests.cs | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/MiningCut2Tests.cs diff --git a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs new file mode 100644 index 000000000..bf79a2d3d --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs @@ -0,0 +1,307 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; +using static CultMath.math; + +// Cut 2 (docs/mining-cut.md): the dead per-tick "hit it with a tool" behavior and its per-tick accumulator are +// gone, along with the scanner's dead survey half, and chunk wear gets one owner: Zone. These tests pin that +// owner's behaviour -- accumulation, breaking, lazy respawn, and the persistence round trip through ZonePack's +// new nullable key 6 -- never by reaching into AsteroidBelt (it no longer holds wear at all). +public sealed class MiningCut2Tests : IDisposable +{ + private int _fixtureCount; + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut2-" + Guid.NewGuid().ToString("N")); + + public MiningCut2Tests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static readonly MessagePackSerializerOptions PackOptions = + CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly); + + private static GameplaySettings GameSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + // Authored-shape curves, not flat defaults: hitpoints and respawn time both vary with size over the + // fixture's range, so a test that pins "breaks at the authored hitpoints" or "respawns at the authored + // curve" is exercising real curvature, not a constant that any wrong formula would also satisfy. + private static PlanetSettings Settings() => new PlanetSettings + { + OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, + AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, + AsteroidHitpoints = new ExponentialLerp { Minimum = 20f, Maximum = 220f, Exponent = 1.6f }, + AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f } + }; + + private sealed class Fixture + { + public Zone Zone; + public ItemManager Items; + public PlanetSettings Settings; + public CultRecordKey BeltA; + public CultRecordKey BeltB; + public Asteroid[] AsteroidsA; + public Asteroid[] AsteroidsB; + } + + // Two belts of different sizes, asteroids at distinct sizes within each -- so a bug that keys wear by field + // alone (ignoring index), or by index alone (ignoring field), shows up as cross-talk between chunks that + // share an index or a belt. + private Fixture BuildFixture() + { + var dir = Path.Combine(_root, $"cat{_fixtureCount++}"); + Directory.CreateDirectory(dir); + var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var settings = Settings(); + + var rootOrbit = new OrbitData { Distance = 3000f, Phase = 0.05f }; + var rootKey = cache.Upsert(rootOrbit).Key; + + CultRecordKey MakeBelt(int count, float baseDistance, float sizeStart) + { + var beltOrbit = new OrbitData { Parent = new CultRecordRef(rootKey), Distance = baseDistance, Phase = 0.11f }; + var beltOrbitKey = cache.Upsert(beltOrbit).Key; + var asteroids = new Asteroid[count]; + for (var i = 0; i < count; i++) + { + asteroids[i] = new Asteroid + { + Distance = 100f + i * 9.7f, + Phase = frac(i * 0.618f), + Size = frac(sizeStart + i * 0.29f), + RotationSpeed = 0.1f + i * 0.02f + }; + } + var beltData = new AsteroidBeltData { Orbit = new CultRecordRef(beltOrbitKey), Asteroids = asteroids }; + return cache.Upsert(beltData).Key; + } + + var beltAKey = MakeBelt(9, 700f, 0.05f); + var beltBKey = MakeBelt(5, 1600f, 0.61f); + + var beltAData = (AsteroidBeltData) cache.Get(beltAKey); + var beltBData = (AsteroidBeltData) cache.Get(beltBKey); + + var pack = new ZonePack + { + Orbits = { new CultRecordRef(rootKey) }, + Planets = { new CultRecordRef(beltAKey), new CultRecordRef(beltBKey) }, + Radius = 5000f, + Mass = 10000f, + Time = 0 + }; + // Both belt orbits share the root as parent and are already registered against the cache; the Zone + // constructor discovers each belt's own orbit key through AsteroidBeltData.Orbit, but it also needs + // every orbit key it will walk listed in pack.Orbits. + pack.Orbits.Add(new CultRecordRef(beltAData.Orbit.Key)); + pack.Orbits.Add(new CultRecordRef(beltBData.Orbit.Key)); + + var zone = new Zone(items, settings, pack, new GalaxyZone { Name = "MiningCut2", Owner = null }, null); + return new Fixture + { + Zone = zone, Items = items, Settings = settings, + BeltA = beltAKey, BeltB = beltBKey, + AsteroidsA = beltAData.Asteroids, AsteroidsB = beltBData.Asteroids + }; + } + + [Fact] + public void WearAccumulatesAndDoesNotBreakUntilStrictlyOverHitpoints() + { + var f = BuildFixture(); + const int index = 3; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + + // Several partial hits under the threshold: still there, still shrinking. + Assert.False(f.Zone.Wear(chunk, hp * 0.3f)); + Assert.True(f.Zone.ChunkExists(chunk)); + Assert.False(f.Zone.Wear(chunk, hp * 0.3f)); + Assert.True(f.Zone.ChunkExists(chunk)); + + var before = f.Zone.ChunkRadius(chunk); + Assert.True(before < f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), + "accumulated but unbroken damage must have already shrunk the chunk"); + + // The remaining exact amount lands accumulated damage exactly on the hitpoint threshold: pins the + // break condition as strictly greater-than, not greater-or-equal (today's behaviour, kept deliberately + // -- see Zone.Wear's own comment). Equal-to-hitpoints must not break the chunk. + var remaining = hp - hp * 0.6f; + Assert.False(f.Zone.Wear(chunk, remaining)); + Assert.True(f.Zone.ChunkExists(chunk), "damage exactly equal to hitpoints must not break the chunk"); + + // One more point of damage tips it over. + Assert.True(f.Zone.Wear(chunk, 0.01f)); + Assert.False(f.Zone.ChunkExists(chunk)); + Assert.Equal(0f, f.Zone.ChunkRadius(chunk)); + } + + [Fact] + public void ABrokenChunkReturnsAtItsRespawnTimeFullyHealed() + { + var f = BuildFixture(); + const int index = 1; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.False(f.Zone.ChunkExists(chunk)); + + // Uneven steps that sum to just under the authored respawn time: still broken. + var dts = new float[] { respawn * 0.2f, respawn * 0.31f, respawn * 0.1f, respawn * 0.15f }; + foreach (var dt in dts) f.Zone.Update(dt); + Assert.False(f.Zone.ChunkExists(chunk), "must still be broken just under the authored respawn time"); + + // The remaining sliver plus a margin crosses the respawn time. + f.Zone.Update(respawn * 0.3f); + Assert.True(f.Zone.ChunkExists(chunk), "must have respawned once zone time reaches the authored respawn time"); + Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), f.Zone.ChunkRadius(chunk), 4); + } + + [Fact] + public void BrokenThenRespawnedThenWornAgainStartsFromZeroDamage() + { + var f = BuildFixture(); + const int index = 2; + var chunk = new ChunkId(f.BeltB, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsB[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp * 1.5f)); + f.Zone.Update(respawn + 1f); + Assert.True(f.Zone.ChunkExists(chunk)); + + // A hit well under hitpoints, on the freshly respawned chunk, must not break it -- if the old damage + // (or the old broken state) leaked through, this hit alone would already be over any stale threshold. + Assert.False(f.Zone.Wear(chunk, hp * 0.4f)); + Assert.True(f.Zone.ChunkExists(chunk)); + var radius = f.Zone.ChunkRadius(chunk); + Assert.True(radius > 0f && radius < f.Zone.AsteroidBelts[f.BeltB].UndamagedSize(index, f.Settings)); + } + + [Fact] + public void WearIsIndependentPerChunk() + { + var f = BuildFixture(); + // Same index in both belts, and a different index in the same belt, so a key collision on either half + // of ChunkId would show up as cross-talk. + var chunkA0 = new ChunkId(f.BeltA, 0); + var chunkB0 = new ChunkId(f.BeltB, 0); + var chunkA1 = new ChunkId(f.BeltA, 1); + + var hpA0 = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[0].Size); + Assert.True(f.Zone.Wear(chunkA0, hpA0 + 1f)); + + Assert.False(f.Zone.ChunkExists(chunkA0)); + Assert.True(f.Zone.ChunkExists(chunkB0), "a different field at the same index must be unaffected"); + Assert.True(f.Zone.ChunkExists(chunkA1), "a different index in the same field must be unaffected"); + Assert.Equal(f.Zone.AsteroidBelts[f.BeltB].UndamagedSize(0, f.Settings), f.Zone.ChunkRadius(chunkB0), 4); + } + + [Fact] + public void WearSurvivesPackAndUnpack() + { + var f = BuildFixture(); + var damaged = new ChunkId(f.BeltA, 4); + var broken = new ChunkId(f.BeltB, 3); + var untouched = new ChunkId(f.BeltA, 5); + + var hpDamaged = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[4].Size); + var hpBroken = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[3].Size); + + Assert.False(f.Zone.Wear(damaged, hpDamaged * 0.45f)); + Assert.True(f.Zone.Wear(broken, hpBroken + 5f)); + f.Zone.Update(1.3f); + + var damagedRadiusBefore = f.Zone.ChunkRadius(damaged); + var pack = f.Zone.PackZone(); + Assert.NotNull(pack.ChunkWear); + Assert.True(pack.ChunkWear.Count >= 2); + + var reloaded = new Zone(f.Items, f.Settings, pack, new GalaxyZone { Name = "MiningCut2Reload", Owner = null }, null); + + Assert.True(reloaded.ChunkExists(untouched)); + Assert.False(reloaded.ChunkExists(broken), "a still-broken chunk must stay broken across save/continue"); + Assert.True(reloaded.ChunkExists(damaged)); + Assert.Equal(damagedRadiusBefore, reloaded.ChunkRadius(damaged), 4); + } + + [Fact] + public void APackOmitsExpiredWear() + { + var f = BuildFixture(); + var chunk = new ChunkId(f.BeltA, 6); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[6].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[6].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + f.Zone.Update(respawn + 2f); + Assert.True(f.Zone.ChunkExists(chunk), "sanity: this chunk must have respawned before packing"); + + var pack = f.Zone.PackZone(); + var stillPacked = pack.ChunkWear?.Exists(w => w.Field.Equals(chunk.Field) && w.Index == chunk.Index) ?? false; + Assert.False(stillPacked, "a fully healed, no-longer-broken entry carries no live information and must be pruned"); + } + + // Persistence rule (docs/mining-cut.md): ZonePack key 6 is nullable so a record from before it existed -- + // built here as raw MessagePack bytes with only the six original keys, not a default-constructed ZonePack + // -- deserializes with ChunkWear == null and loads as a zone with no wear, rather than throwing or reading + // some default non-null instance. + [Fact] + public void ARunWithoutKeySixLoadsWhole() + { + var f = BuildFixture(); + var oldShapePack = new ZonePack + { + Planets = { new CultRecordRef(f.BeltA), new CultRecordRef(f.BeltB) }, + Orbits = f.Zone.Orbits.Keys.ConvertToRefList(), + Entities = new List(), + Radius = 5000f, + Mass = 10000f, + Time = 12.5 + }; + + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(6); // the pre-Cut-2 shape: keys 0-5 only, no slot for key 6 at all. + MessagePackSerializer.Serialize(ref writer, oldShapePack.Planets, PackOptions); + MessagePackSerializer.Serialize(ref writer, oldShapePack.Orbits, PackOptions); + MessagePackSerializer.Serialize(ref writer, oldShapePack.Entities, PackOptions); + MessagePackSerializer.Serialize(ref writer, oldShapePack.Radius, PackOptions); + MessagePackSerializer.Serialize(ref writer, oldShapePack.Mass, PackOptions); + MessagePackSerializer.Serialize(ref writer, oldShapePack.Time, PackOptions); + writer.Flush(); + + var loadedPack = MessagePackSerializer.Deserialize(buffer.WrittenMemory, PackOptions); + Assert.Null(loadedPack.ChunkWear); + + var zone = new Zone(f.Items, f.Settings, loadedPack, new GalaxyZone { Name = "MiningCut2Old", Owner = null }, null); + Assert.True(zone.ChunkExists(new ChunkId(f.BeltA, 0))); + Assert.Equal(zone.AsteroidBelts[f.BeltA].UndamagedSize(0, f.Settings), zone.ChunkRadius(new ChunkId(f.BeltA, 0)), 4); + } +} + +internal static class MiningCut2TestExtensions +{ + public static List> ConvertToRefList(this IEnumerable keys) + { + var list = new List>(); + foreach (var key in keys) list.Add(new CultRecordRef(key)); + return list; + } +} From daa645601abcf1f41f6b93691de4084d3c14b2c5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 12:05:47 +0200 Subject: [PATCH 09/45] Cut 2 Stryker fix batch: chunk-index bounds, respawn boundary, ChunkId equality Stryker over Zone.cs (concurrency 1, 111 mutants tested) surfaced four real gaps on this cut's own lines, all now killed: - ChunkExists's range check (`index < 0 || index >= length`) survived both an AND weakening and a `>` boundary weakening, because nothing tried an out-of-range index on either side alone. - The broken-until comparisons in ChunkExists/ChunkRadius and the pack-time pruning in IsExpired all survived a `>`/`<` vs `>=`/`<=` boundary flip, because every prior test advanced zone time well past BrokenUntil instead of landing on it exactly. - ChunkId.Equals survived an AND-to-OR weakening: the wear dictionary's own GetHashCode already separates same-field/different-index and same-index/different-field pairs into different buckets, so a bad Equals was never reached through a lookup. Testing equality directly closes that. Two survivors on this cut's lines are recorded as equivalent, not chased: ChunkRadius's `wear.Damage <= 0f` boundary (at damage == 0, DamagedSize and UndamagedSize evaluate the same curve at the same point, so no input distinguishes the two branches), and ChunkId.GetHashCode's exact formula (a hashcode's bit pattern isn't a contract; any deterministic function of the same two fields trivially keeps equal keys hashing equal, and this dictionary is too small for distribution to matter). The remaining survivors (lines 44, 75, 170, 200, 212, 216, 319, 336-412) sit in Update/GetOrbitPosition/GetSecurityLevel/GetHeight/GetNormal/PowerPulse, none of which this cut touches; they predate it and are out of scope here. Mutation score 38.62% -> 42.33% (78/111 killed, 2 timeout, 31 survived, all 31 either pre-existing or one of the two equivalents above). Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut2Tests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs index bf79a2d3d..6a37ae781 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs @@ -294,6 +294,63 @@ public sealed class MiningCut2Tests : IDisposable Assert.True(zone.ChunkExists(new ChunkId(f.BeltA, 0))); Assert.Equal(zone.AsteroidBelts[f.BeltA].UndamagedSize(0, f.Settings), zone.ChunkRadius(new ChunkId(f.BeltA, 0)), 4); } + + // Stryker gap (2026-09-25 pass on this cut): ChunkExists's range check reads `chunk.Index < 0 || + // chunk.Index >= length`; nothing exercised either side of that OR alone, or the >= boundary at exactly + // `length`, so a mutant weakening it to AND (which can never be true, since an index cannot be both + // negative and past the end at once) and a mutant weakening >= to > both survived. + [Fact] + public void OutOfRangeChunkIndicesDoNotExist() + { + var f = BuildFixture(); + Assert.False(f.Zone.ChunkExists(new ChunkId(f.BeltA, -1)), "a negative index alone must already fail"); + Assert.False(f.Zone.ChunkExists(new ChunkId(f.BeltA, f.AsteroidsA.Length)), + "the index exactly at the asteroid count is already past the end (0-based), not the first one out"); + } + + // Stryker gap: ChunkId.Equals was reachable only through the wear dictionary, whose GetHashCode already + // separates a same-field-different-index or same-index-different-field pair into different buckets, so a + // mutant weakening Equals from `&&` to `||` never got exercised by a lookup. This tests the value equality + // directly, the way the operator's ruling actually describes chunk identity: same field AND same index. + [Fact] + public void ChunkIdEqualityRequiresBothFieldAndIndex() + { + var f = BuildFixture(); + var a0 = new ChunkId(f.BeltA, 0); + var a0Again = new ChunkId(f.BeltA, 0); + var a1 = new ChunkId(f.BeltA, 1); + var b0 = new ChunkId(f.BeltB, 0); + + Assert.Equal(a0, a0Again); + Assert.NotEqual(a0, a1); + Assert.NotEqual(a0, b0); + } + + // Stryker gap: the broken-until comparisons (ChunkExists, ChunkRadius) and the pack-time pruning rule + // (IsExpired) all compare BrokenUntil against zone time at their own boundary, and no existing test landed + // exactly on it (ABrokenChunkReturnsAtItsRespawnTimeFullyHealed and APackOmitsExpiredWear both advance well + // past it). Breaking at time 0 and stepping by exactly the authored respawn time lands zone time on + // BrokenUntil bit-for-bit (0.0 + respawnTime, then 0.0 += respawnTime): the chunk must already read as + // respawned at that exact instant, not one tick later, and packing at that instant must already prune it. + [Fact] + public void RespawnAndPruneBoundaryIsInclusiveOfExactlyNow() + { + var f = BuildFixture(); + const int index = 7; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + f.Zone.Update(respawn); // zone time now equals BrokenUntil exactly, not "respawn plus a margin". + + Assert.True(f.Zone.ChunkExists(chunk), "must read as respawned the instant zone time reaches BrokenUntil, not strictly after it"); + Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), f.Zone.ChunkRadius(chunk), 4); + + var pack = f.Zone.PackZone(); + var stillPacked = pack.ChunkWear?.Exists(w => w.Field.Equals(chunk.Field) && w.Index == chunk.Index) ?? false; + Assert.False(stillPacked, "at exactly the respawn instant the entry already carries no live information"); + } } internal static class MiningCut2TestExtensions From a9538b205eb784f545a2d6fc106967ffd2602536 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 12:07:18 +0200 Subject: [PATCH 10/45] Mining map: Q2 ruled (one slot, a chunk of a field), detection deferred to EW, Cut 2 status Co-Authored-By: Claude Opus 5.5 --- docs/mining-cut.md | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/mining-cut.md b/docs/mining-cut.md index a3434bc3b..25f5ae1eb 100644 --- a/docs/mining-cut.md +++ b/docs/mining-cut.md @@ -165,7 +165,26 @@ damage channel with its own DPS, efficiency, penetration and range. Nothing assi - **Recommended: A.** B is a second hit path with its own dice and its own yield rule, which is exactly the split Cut 3 of fire control removed. `ResourceScanner` is Q2's business. -**Q2 (blocks Cut 3). How does a chunk become a target?** This is the architectural fork. The options below were +**Q2 (blocks Cut 3). How does a chunk become a target?** **Ruled 2026-09-25: A, one slot, two kinds.** +Operator: "Note that I have been saying chunks specifically because this should generalize to more than just +asteroid fields ("rocks"), there's various kinds of debris fields we'd want to represent, too. Maybe that's +pedantic, it doesn't change the schema. One target slot, two kinds." So a target is an `Entity` or a **chunk of a +field**; asteroid belts are one kind of field and debris fields another. `ChunkId` is `(Field, Index)` (landed in +Cut 2); new chunk-surface code speaks of chunks and fields, never belts or rocks. Composition and loot tables +(Q4, Q7) are per field kind: a debris field rolls salvage through the same weighted roll. + +**Detection is deferred to the electronic-warfare campaign.** Operator, correcting Self's "rocks never hide": "But +uh, chunks have the same kind of sensor envelope as a hull, that's what makes masking and hiding possible. They +have black body emission and an albedo and a cross section." Then: "Put the fields in, defer the signature and +detection work for the later EW campaign." So, as **explicit placeholders the EW campaign owns and replaces**: +a chunk passes the visibility gate unconditionally with PSensor 1 (weapon range and arc still gate); targeting a +chunk is an on-demand query against the field, with no per-tick chunk detection; `ResourceScanner` parks (its data +stays; its purpose is decided with detection). Handed to EW: chunks present a sensor envelope like a hull +(black-body emission, albedo, cross-section), which makes masking and hiding in fields possible; per-chunk +evaluation must stay on demand or be aggregated per field for cost (per-pair detection is ~184 ns per observed body +per tick, the reason chunks-as-entities was rejected). + +The options as mapped, kept for the record: The options below were costed against the live code: - **A. One slot, two kinds.** `Entity.Target` becomes `ReactiveProperty`, where `TargetRef` is a small @@ -190,7 +209,7 @@ costed against the live code: rock" is the one-owner sentence, and it matches the parked electronic-warfare direction: "every body presents a signature through the shared detection interface". Consequences that come with A, named here and not decided later: -- **Visibility.** A chunk passes the visibility gate within the shooter's chunk detection range, and its PSensor is +- **Visibility (superseded 2026-09-25 by the EW deferral above).** A chunk passes the visibility gate within the shooter's chunk detection range, and its PSensor is 1, because rocks do not hide. Recommend that range be the equipped `ResourceScanner`'s `Range`, with a `GameplaySettings.UnaidedChunkRange` fallback. That is the `TargetingSystem`/`UnaidedTracking` precedent (`FireControl.cs:121-125`), and it gives `ResourceScanner` a job: it is to chunks what the targeting system is to @@ -461,9 +480,11 @@ lot. That includes every existing loadout ammo stack. Reading such a lot through Status: cut map. Ends are owned by Part I of this document; Part III owns the means. -Rulings (operator): Q1 = A, Q3 = A (2026-09-25). Q2, Q4-Q9 open. +Rulings (operator): Q1 = A, Q2 = A (with the field generalization and the EW deferral), Q3 = A (2026-09-25). Q4-Q9 open. -Open: Q2, Q4-Q9. Cut 2 is unblocked. +Open: Q4-Q9. Cut 3 waits only on fire-control Cut 12 closing (it edits `FireControl.cs`). + +**Cut 2: landed 2026-09-25** at `3b9f0f1d`, `c924bae0`, `daa64560`. 272 tests; production net −73 lines; `AetherDb census` byte-identical; break threshold kept strictly `>`; `ZonePack` key 6 nullable, proven by a raw-MessagePack older-record test. Spec discrepancy fixed: the retirement comment for union tag 26 no longer names `MiningToolData`, which the cut's own negative grep forbids. Soul pending. **Cut 1: landed 2026-09-25** at `22a54ccb` and `d9c887c1` (Stryker fix batch: `EvaluateBelt` had no coverage and `Radius`'s `Max`→`Min` survived). 262 tests. Probe on the operator's densest zone, 10 runs each: From 34994732d246cd06e115fc5ddd363bcc1afb9327 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 12:49:57 +0200 Subject: [PATCH 11/45] Cut 2 fix batch: a broken chunk absorbs no damage Zone.Wear did not check whether the chunk was already broken. A later hit read wear.Damage starting from 0 (the value ChunkWear carries while BrokenUntil is in the future), stayed under hitpoints, and wrote BrokenUntil = null -- un-breaking a chunk mid-respawn. Damage delivered to a chunk that is currently broken (zone time < BrokenUntil) now changes nothing: Wear returns false (this hit did not break it -- it was already broken) and BrokenUntil, and Damage, are left untouched. The chunk stays absent until its original respawn time, then resumes wear from zero as before. Adds two behavioural tests: a re-hit inside the respawn window, both below and above hitpoints, must not un-break the chunk or move its respawn time; and the same rule survives a save/reload round trip. Confirmed by reverting the fix and watching both new tests fail with the chunk reappearing early, then restoring the fix byte-exactly. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 7 +- .../Aetheria.Shared.Tests/MiningCut2Tests.cs | 77 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index c04fa4b8e..d37d6b650 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -255,13 +255,18 @@ public class Zone // Cut 2: the only writer of chunk wear. Returns whether this hit broke the chunk. The break threshold is // strictly greater-than, matching the deleted per-tick miner's own comparison against accumulated damage: // damage exactly equal to hitpoints does not yet break the chunk, only damage that exceeds them does. + // A chunk that is currently broken (zone time < BrokenUntil) absorbs nothing: this hit did not break it + // (it was already broken), and its wear stays zeroed until it respawns and starts fresh. public bool Wear(ChunkId chunk, float damage) { + _wear.TryGetValue(chunk, out var wear); + if (wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time) + return false; + var beltData = (AsteroidBeltData) Planets[chunk.Field]; var size = beltData.Asteroids[chunk.Index].Size; var hitpoints = Settings.AsteroidHitpoints.Evaluate(size); - _wear.TryGetValue(chunk, out var wear); var newDamage = wear.Damage + damage; if (newDamage > hitpoints) diff --git a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs index 6a37ae781..345d6cf40 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs @@ -312,6 +312,83 @@ public sealed class MiningCut2Tests : IDisposable // separates a same-field-different-index or same-index-different-field pair into different buckets, so a // mutant weakening Equals from `&&` to `||` never got exercised by a lookup. This tests the value equality // directly, the way the operator's ruling actually describes chunk identity: same field AND same index. + // Bug (found dormant, confirmed by Soul, on Cut 4's path): Wear did not check whether the chunk was already + // broken. A later hit computed newDamage from a fresh 0f (ChunkWear's default Damage after breaking), stayed + // under hitpoints, and wrote BrokenUntil = null -- un-breaking a chunk mid-respawn. The rule: damage to a + // currently-broken chunk (zone time < BrokenUntil) changes nothing. It stays broken until its original + // respawn time regardless of whether the re-hit is above or below hitpoints, and its wear starts from zero + // only once it actually respawns. + [Fact] + public void HittingABrokenChunkDoesNotUnbreakItEitherBelowOrAboveHitpoints() + { + var f = BuildFixture(); + const int index = 4; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.False(f.Zone.ChunkExists(chunk)); + + // A hit well under hitpoints, inside the respawn window: must not break it (it is already broken) and + // must not touch BrokenUntil. + f.Zone.Update(respawn * 0.2f); + Assert.False(f.Zone.Wear(chunk, hp * 0.1f), "a hit on an already-broken chunk did not break it"); + Assert.False(f.Zone.ChunkExists(chunk), "must still be absent -- the old bug un-broke it here"); + Assert.Equal(0f, f.Zone.ChunkRadius(chunk)); + + // A second hit, this one alone well over hitpoints, inside the same window: still must not break it + // (it is already broken) and must not push BrokenUntil further out. + f.Zone.Update(respawn * 0.3f); + Assert.False(f.Zone.Wear(chunk, hp + 50f), "a hit on an already-broken chunk did not break it, even when large"); + Assert.False(f.Zone.ChunkExists(chunk)); + + // Just under the original respawn time (measured from the original break, not from either re-hit): + // still broken. + f.Zone.Update(respawn * 0.45f); + Assert.False(f.Zone.ChunkExists(chunk), "must still be broken just under the ORIGINAL respawn time"); + + // Crossing the original respawn time: present again, and unworn -- the re-hits left no residue. + f.Zone.Update(respawn * 0.1f); + Assert.True(f.Zone.ChunkExists(chunk), "must respawn at its original time despite the re-hits"); + Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), f.Zone.ChunkRadius(chunk), 4); + + // Worn again from fresh: breaks on the normal threshold, not one warped by the earlier no-op hits. + Assert.False(f.Zone.Wear(chunk, hp * 0.9f)); + Assert.True(f.Zone.ChunkExists(chunk)); + Assert.True(f.Zone.Wear(chunk, hp * 0.2f)); + Assert.False(f.Zone.ChunkExists(chunk)); + } + + // Same rule, checked across a save/reload: a re-hit taken while a chunk is broken must not move BrokenUntil, + // and that must survive the pack/unpack round trip -- not just live in the same Zone instance. + [Fact] + public void ReHitDuringRespawnSurvivesSaveRoundTripAtTheSameAbsoluteTime() + { + var f = BuildFixture(); + const int idx = 3; + var chunk = new ChunkId(f.BeltB, idx); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[idx].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsB[idx].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + f.Zone.Update(respawn * 0.4f); + Assert.False(f.Zone.Wear(chunk, hp * 0.5f), "re-hit inside the window did not break an already-broken chunk"); + + var pack = f.Zone.PackZone(); + var reloaded = new Zone(f.Items, f.Settings, pack, new GalaxyZone { Name = "MiningCut2ReHitReload", Owner = null }, null); + + Assert.False(reloaded.ChunkExists(chunk), "still broken immediately after reload"); + + // Just under the original respawn time (measured from the original break at zone time 0): still broken. + reloaded.Update(respawn * 0.59f); + Assert.False(reloaded.ChunkExists(chunk), "must still be broken just under the ORIGINAL respawn time after reload"); + + reloaded.Update(respawn * 0.02f); + Assert.True(reloaded.ChunkExists(chunk), "must respawn at its original absolute time after reload, unmoved by the re-hit"); + Assert.Equal(f.Zone.AsteroidBelts[f.BeltB].UndamagedSize(idx, f.Settings), reloaded.ChunkRadius(chunk), 4); + } + [Fact] public void ChunkIdEqualityRequiresBothFieldAndIndex() { From 93bfdebd76ec3107cde31aab6b40c84dd6a2a43a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 12:50:32 +0200 Subject: [PATCH 12/45] Cut 2 fix batch: drop dead MiningDifficulty from Settings.asset The field was deleted from PlanetSettings but the authored asset still serialized MiningDifficulty: 500. Byte-exact removal of the one line; nothing else in the file changes. Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/Settings.asset | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index b2a9b0ed2..692fb08fe 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:347752dd66c3d250eddde8f6f78a17d7369d562bd698a0c8099e941f7c0825c8 -size 13163 +oid sha256:b2e346f533ccee5d2c655b3d8e0817e3f93e9234af06557ee9c6ec13cecb9acd +size 13137 From 283ce7dce2946c5fff5494b3ff8e6f68062c5f7a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 12:51:42 +0200 Subject: [PATCH 13/45] Mining map: ChunkId is (Field, Index) throughout, and Cut 2 closed Co-Authored-By: Claude Opus 5.5 --- docs/mining-cut.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/mining-cut.md b/docs/mining-cut.md index 25f5ae1eb..78f41c56c 100644 --- a/docs/mining-cut.md +++ b/docs/mining-cut.md @@ -128,7 +128,7 @@ nothing per tick. | Kind | What names it | What happens to it over time | Who decides | |---|---|---|---| -| **Chunk** | `ChunkId = (CultRecordKey Belt, int Index)`. `Belt` is the `AsteroidBeltData` record key in the run store; `Index` indexes its `Asteroids` array. Stable across save/continue, because `RunSave.Commit` writes the run store's whole view, bodies included (`SavedGame.cs:97-99`), and nothing rewrites `Asteroids` after generation. | Born at zone generation (`ZoneGenerator.cs:141-153`). Lives for the run. Removed with every `BodyData` by `RunSave.Clear` (`SavedGame.cs:124`). | `ZoneGenerator` writes `Asteroids` once. Nothing else may write it. | +| **Chunk** | `ChunkId = (CultRecordKey Field, int Index)`. `Field` is the field's record key in the run store (today always an `AsteroidBeltData`; debris fields are another kind, Q2); `Index` indexes its chunks (`Asteroids` for a belt). Stable across save/continue, because `RunSave.Commit` writes the run store's whole view, bodies included (`SavedGame.cs:97-99`), and nothing rewrites `Asteroids` after generation. | Born at zone generation (`ZoneGenerator.cs:141-153`). Lives for the run. Removed with every `BodyData` by `RunSave.Clear` (`SavedGame.cs:124`). | `ZoneGenerator` writes `Asteroids` once. Nothing else may write it. | | **Chunk pose** (position, rotation, velocity) | Derived: `(ChunkId, zone time)`. | Never stored. Evaluated on demand. | One function on `AsteroidBelt` (Cut 1). Forbidden writers: any stored pose array, any per-tick loop. | | **Chunk wear** (damage taken, broken-until time) | Keyed by `ChunkId` inside the owning `Zone`. | Damage accumulates at Apply. When it reaches the chunk's hitpoints, the chunk breaks with `RespawnAt = zone time + AsteroidRespawnTime(size)`. At `time >= RespawnAt` it is whole again, read lazily with no timer. Persisted in `ZonePack` (new nullable key 6) with the zone's own `Time`, and pruned of expired entries at pack. | `FireControl.Apply` through one `Zone` method is the only writer. `Zone` owns the storage. | | **Chunk composition** | Derived: `(belt identity, ChunkId, catalog ore table)`. | Never stored (recommended, Q7). A retune of the catalog retunes every belt. | The catalog author, through per-ore data on `SimpleCommodityData`. The derivation function is the only reader of that data for belts. | @@ -484,7 +484,13 @@ Rulings (operator): Q1 = A, Q2 = A (with the field generalization and the EW def Open: Q4-Q9. Cut 3 waits only on fire-control Cut 12 closing (it edits `FireControl.cs`). -**Cut 2: landed 2026-09-25** at `3b9f0f1d`, `c924bae0`, `daa64560`. 272 tests; production net −73 lines; `AetherDb census` byte-identical; break threshold kept strictly `>`; `ZonePack` key 6 nullable, proven by a raw-MessagePack older-record test. Spec discrepancy fixed: the retirement comment for union tag 26 no longer names `MiningToolData`, which the cut's own negative grep forbids. Soul pending. +**Cut 2: landed 2026-09-25** at `3b9f0f1d`, `c924bae0`, `daa64560`. 272 tests; production net −73 lines; `AetherDb census` byte-identical; break threshold kept strictly `>`; `ZonePack` key 6 nullable, proven by a raw-MessagePack older-record test. Spec discrepancy fixed: the retirement comment for union tag 26 no longer names `MiningToolData`, which the cut's own negative grep forbids. Soul found one real defect, dormant until Cut 4 wires `FireControl.Apply` into `Wear`: a hit on a chunk that was +already broken reset `BrokenUntil` and resurrected it on the spot (any shot resolving after the chunk broke — +travel time, two shots close together). Fixed at `34994732`: a hit on a broken chunk changes nothing and `Wear` +returns false; pinned below and above hitpoints and across a save taken inside the respawn window (both tests fail +on the old code). `93bfdebd` removes the dead `MiningDifficulty: 500` still serialized in `Settings.asset`. Soul also +confirmed the strict `>` threshold cannot stall (wear accumulates across hits; only zero damage never breaks). +274 tests. **Closed**; the two-commit fix's own check folds into Cut 3's Soul pass. **Cut 1: landed 2026-09-25** at `22a54ccb` and `d9c887c1` (Stryker fix batch: `EvaluateBelt` had no coverage and `Radius`'s `Max`→`Min` survived). 262 tests. Probe on the operator's densest zone, 10 runs each: @@ -613,7 +619,7 @@ and wear has one owner" (Cut 2). - `PlanetSettings.MiningDifficulty` (`Settings.cs:32`). Its only reader is `MineAsteroid`. - About 160 lines in total. - **Adds:** - - `ChunkId` (readonly struct, `(CultRecordKey Belt, int Index)`, with value equality). + - `ChunkId` (readonly struct, `(CultRecordKey Field, int Index)`, with value equality). - The wear store on `Zone`: `ChunkWear { float Damage; double? BrokenUntil }` keyed by `ChunkId`. - `Zone.ChunkExists(ChunkId)`: the index is in range and the chunk is not broken at zone time. - `Zone.ChunkRadius(ChunkId)`: `Size` from Cut 1, reading wear. From d50a816a87733e0619e37541673430752c09beb1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 12:25:41 +0200 Subject: [PATCH 14/45] Refresh the mining map for assimilation into Eureka (Imagination, 2026-09-29) Re-anchored to the post-fire-control-12 Body (synthetic merge tested on Yggdrasil: 379/364/381 green). Codex's map is raw material; this is the proposal. Operator questions open. Co-Authored-By: Claude Opus 5.5 --- docs/mining-cut-refresh.md | 640 +++++++++++++++++++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 docs/mining-cut-refresh.md diff --git a/docs/mining-cut-refresh.md b/docs/mining-cut-refresh.md new file mode 100644 index 000000000..2e99d88f6 --- /dev/null +++ b/docs/mining-cut-refresh.md @@ -0,0 +1,640 @@ +# Aetheria Codex assimilation: refreshed cut maps (mining, moddable ships) + +Date 2026-09-29. Imagination output for Self. It re-anchors two Codex lanes to the Body as it will stand after +`codex/fire-control-12` merges to `master`. The Codex maps are raw material, not authority. Where this file and +a Codex map disagree, this file is the proposal and the Codex map is the input. + +## How this was established + +- **Branch tips read, 2026-09-29, after `git fetch`.** + - `origin/master` `9b85211f` + - `origin/codex/fire-control-12` `b66ba524` + - `origin/codex/mining` `283ce7dc` + - `origin/codex/moddable-ships` `375d6bd4` + - Nothing in `F:\Projects\Aetheria`'s working tree was read or switched. Every read is `git show` or `git grep` + against a SHA. +- **The post-fire-control Body is a synthetic merge.** `git merge-tree --write-tree origin/master + origin/codex/fire-control-12` is clean, tree `91576243`. I wrapped it in an unreferenced local commit, + `8a2db490` (no ref, no push to origin), so it can be built. The fire-control checklist (`docs/merge-to-master-checklist.md:7,107` + on `b66ba524`) predicts the same clean merge. +- **Anchors.** Each `file:line` names a real SHA, never the synthetic one: + - `b66ba524` for files only fire control touched; + - `283ce7dc` for files the mining branch touched; + - `375d6bd4` for files the ships branch touched. + + The three file sets are disjoint. I checked with `git diff --name-only` over each branch's own range, and none + of the three pairs shares a file. So each anchored line is byte-identical in the merged tree. +- **Tests ran on Yggdrasil, against synthetic merges** (section 0). CultLib `45c2f40` and CultMath `6d5e209` are + the pins in `Directory.Build.props`. The two LFS files the tests read (`GameData/Aetheria.cc`, + `Assets/Resources/Settings.asset`) were staged beside the job, because the verify mirror carries no LFS. +- **Catalog facts** come from a probe that opens the merged tree's `GameData/Aetheria.cc` through + `AetheriaStores.Open` (section 0). + +## 0. Probe results + +**Test suite on the synthetic merges**, Yggdrasil, `dotnet test tests/Aetheria.Shared.Tests`, CultLib `45c2f40`, +CultMath `6d5e209`, the `dotnet` image: + +| Tree | Commit (local, unreferenced) | Result | +|---|---|---| +| master + fire-control-12 + mining | `396d19e5` | **379 passed, 0 failed** | +| master + fire-control-12 + ships | `0036204f` | **364 passed, 0 failed** | +| master + fire-control-12 + mining + ships | `474c35c3` | **381 passed, 0 failed** | + +Fire control closed 12.4 at 362 tests. 379 = 362 + 17 mining tests, 364 = 362 + 2 ship tests, and 381 is both +together. Nothing on either branch breaks a fire-control test, and nothing in fire control breaks a branch test. +This proves the headless simulation only. No Unity compile ran. + +**Catalog probe** (`scratchpad/probe/Program.cs`: it opens `GameData/Aetheria.cc` through `AetheriaStores.Open` +in the merged tree; the catalog oid `2aa79c0a` is the same in all three merges and at `b66ba524`): + +- 124 items: 32 `GearData`, 18 `WeaponItemData`, 13 `SimpleCommodityData`, 5 `HullData`, 51 compound + commodities, 4 cargo bays, 1 docking bay. +- **Weapon damage types:** Kinetic 9, Electric 3, Optical 3, Thermal 3. There is none of Corrosive or Ionizing, + though the `DamageType` enum has all six. The mining map said Kinetic 10. +- **Penetration > 0:** only Autocannon, LRMM72 and SRMM72, each at 0.25. +- **DamageSpread > 0:** GT 3K 3.25, plight 3.3, pswarm 1.5, scorched void policy 1.5, and SRMM72 0.5. The map + listed four and missed SRMM72. +- **No weapon has `BlastRadius` or `Fuse`.** Blasts are dormant in shipped content until F12-2 authors them. +- **Simple commodities:** the 13 the map named. Every one is priced 0 **except Ammo, at 1000**. The map's + "every live simple commodity has Price 0" is off by one. +- **No live item carries a scanner or mining behaviour**, on fire control or on mining merged. +- **Pre-existing, on `b66ba524` as well as on the merges:** four `GearData` records (Refinery, Deep Ore + Extractor, Assembly Line, Shipyard) hold a **null element** in `Behaviors` (index 0 of 1). It does not come from + mining's union-26 retirement: the same four are null on fire control alone. This matters to variants C4 (A.6). +- **Shipped hull mount ids are not unique:** + + | Hull | Hardpoints | Distinct `Transform`s | Empty `Transform`s | + |---|---|---|---| + | Djinni | 22 | 20 | 3 | + | Longinus | 13 | 12 | 2 | + | LonginusX | 10 | 9 | 2 | + | Turret | 7 | 3 | 5 | + | Zenith | 5 | 1 | 5 | + + All five hulls use `Prefab`. This matters to ship migration (S5). + +## Shared precondition for both campaigns + +Both campaigns wait on one event: `codex/fire-control-12` merging into `master` with `--no-ff`, which Self does +after the operator signs off (`docs/merge-to-master-checklist.md:105-113` on `b66ba524`). Until then, neither +campaign has a base to merge onto except the fire-control branch itself. After it: + +``` +git switch codex/mining && git merge --no-ff master # in a worktree, never the operator's tree +git switch codex/moddable-ships && git merge --no-ff master +``` + +**Merge, do not rebase.** Both maps and their Soul receipts cite landed SHAs: mining cites 11 of its 13 commits, +and the ships doc cites its commits' results. A rebase rewrites every one of those SHAs, which strands the maps' +evidence. The merge has no conflicts (below), so it costs one merge commit per branch. + +--- + +# Part A. Mining + +## A.1 Status header (verified against git) + +Branch `codex/mining`, tip `283ce7dc`: 13 commits on `8bd25f6f`. `8bd25f6f` is an ancestor of fire control, +which has since moved **76 commits** to `b66ba524`. + +**What truly landed:** + +| Cut | Commits | What | +|---|---|---| +| map | `e729e6d6`, `670831c8`, `007771fe`, `e4d38f16`, `a9538b20`, `283ce7dc` | target, cut map, Q1-Q3 rulings, status | +| 1 | `22a54ccb`; fix `d9c887c1` | belts evaluated on demand; `Task.Run` belt threading deleted | +| 2 | `3b9f0f1d`, `c924bae0`; fixes `daa64560`, `34994732`, `93bfdebd` | `MiningTool`, `MineAsteroid` and the survey `Execute` deleted; `ChunkId`, `Zone` wear store, `ZonePack` key 6 | + +Production delta under `Assets/Scripts` (`git diff --numstat 8bd25f6f 283ce7dc`): **+181 / −250**. Tests add +**+681** (`MiningCut1Tests.cs` 240, `MiningCut2Tests.cs` 441). + +**Test counts.** The map says 274 tests after Cut 2 (`:493`), counted on its own `8bd25f6f` base. On the merged +tree the suite is 379, of which 17 are mining's. Every one passes (section 0). + +**Corrections to `docs/mining-cut.md` at `283ce7dc`:** + +1. **`:5` "Nothing here has landed" is false.** Cuts 1 and 2 landed at the SHAs above, and the map's own status + block (`:487-500`) says so. Replace it with a status line naming both cuts and the base. +2. **`:9` anchors at `8bd25f6f` are stale for every fire-control file.** At `b66ba524`, `FireControl.cs` is 1,614 + lines. `Splash` is gone. `Apply` is a switch over three functions (`ApplyDirectHit` `:680`, `ApplyBlastHit` + `:740`, `Detonate` `:1074`). `Silhouette` is at `:1278` and `PendingShot` at `:1464`. Every Cut 3 and 4 anchor + in the map (`:396-412`, `:669-721`) is re-anchored in A.3 below. +3. **`:24-27` "Fire control is mid-rewrite" is stale.** Cut 12.4 closed 2026-09-29 (`c50029d5`), and 362 tests + passed at that close (`docs/fire-control-cut.md` 12.4 status on `b66ba524`). +4. **Cut 3's spec contradicts the Q2 ruling recorded in the same file.** + - Q2's EW deferral (`:176-185`) says a chunk passes the visibility gate unconditionally with PSensor 1, that + targeting is an on-demand field query, and that `ResourceScanner` parks. + - Cut 3 (`:667-668`, `:693`) still specifies `FireControl.ChunkDetectionRange` from the scanner's `Range`, an + `UnaidedChunkRange` fallback, and a test `AScannerExtendsChunkDetection`. + - Cut 7 (`:834-836`) still restores "the Resource Scanner as the chunk sensor" and authors `UnaidedChunkRange`. + - All of that is superseded and is rewritten in A.3. +5. **Q2's blast consequence (`:221-223`) was written against `Splash`.** Cut 12.4 replaced it with + `Detonate(Zone, float2 worldPlanar, float radius, float damage, DamageType)` (`FireControl.cs:1074` @`b66ba524`). + - `Detonate` takes no shooter. + - It walks `zone.Entities` only (`:1085`). + - So a proximity burst beside a rock does nothing to the rock today, and nothing could route its loot. + - This is new question Q10. +6. **`:135`, and the `SimpleCommodityData` comment it copies, are wrong about the retired keys.** Both claim keys + 6, 7 and 8 are retired. They are live: `ItemData` declares them for every item, `SpecificHeat` `Key(6)`, + `Conductivity` `Key(7)` and `Price` `Key(8)` (`ItemData.cs:291-298` @`b66ba524`), and `SimpleCommodityData` + inherits them. Only key 11 is retired on `SimpleCommodityData`. Keys 12 and 13 are still free. The + comment at `ItemData.cs:304` should say "key 11". +7. **Q7's recommendation (`:298-310`) predates the Q2 ruling.** Q2 (`:171-174`) says composition and loot tables + are **per field kind**, with debris rolling salvage through the same roll. Q7 recommends per-ore `Abundance` on + `SimpleCommodityData`, which has one table for every field kind. The two are unreconciled. Q7 is restated + below, behind a new question about what a field kind is. +8. **The `Target` blast-radius count (`:193-196`, "about 60 reads in 12 files") has grown.** + - At `b66ba524`, `git grep` finds 28 target reads in `ActionGameManager.cs` and 22 in `FireControl.cs`, where + the map counted 20 and 11. + - It finds 11 in `Entity.cs`, plus sites in the Unity weapon managers (`GuidedProjectileManager`, + `LightningGunManager`, `ConstantParticleWeaponManager`, `GuidedProjectile`) that the map does not list. +9. **The catalog changed after the mining probe.** + - `4f218e85` and `dd437580` (locomotion Cut 1) landed Longinus, Djinni and five thrusters in `Aetheria.cc` + after `8bd25f6f`. + - Catalog oid at `8bd25f6f`: `1862ddc4`. At `b66ba524`, and in all three merged trees: `2aa79c0a`. + - The census in section 0 replaces the map's weapon and commodity counts. + - Kinetic is 9 weapons, not 10 (`:313`). + - Ammo is priced 1000 (`:117`, `:421`). + - SRMM72 also authors `DamageSpread` (`:270`). + - The Q8 conclusion stands: no Corrosive or Ionizing weapon exists. +10. **Still owed from Cut 1 (`:508-510`):** a Unity compile of the `ZoneRenderer`/`AsteroidBeltUI` change, an + isolated Stryker rerun, and the operator's fly-through. Nothing after `d9c887c1` records any of them. +11. **Still owed from Cut 2 (`:493`):** Soul has not verified the two-commit fix (`34994732`, `93bfdebd`). The + map folds that check into Cut 3's Soul pass. Keep it named, so that it is not lost when Cut 3 is rewritten. + +## A.2 Merge path onto the fire-control-12 lineage + +| Probe | Result | +|---|---| +| `git merge-tree --write-tree origin/codex/fire-control-12 origin/codex/mining` | clean, tree `b7feb776` | +| `git merge-tree --write-tree 8a2db490 origin/codex/mining` (master+fc12) | clean, tree `7280568c` | +| mining merged, then ships merged on top | clean, tree `a7410174` | +| files touched by both mining (`8bd25f6f..283ce7dc`) and fire control (`8bd25f6f..b66ba524`) | none | + +The merged tree passes the full headless suite: 379/379, and 381/381 with ships (section 0). + +**No textual conflicts, and no shared file.** The semantic seam is not textual: mining Cut 3 onward edits the +code fire control rewrote after the mining map was written. That is handled by re-anchoring, not by merging. + +## A.3 Ordered cuts + +The Codex numbering is kept so the map's history still reads, and Soul fix slots are added. Cut order: + +| Cut | Nature | Blocked by | +|---|---|---| +| M0 | Placement: merge master, correct the map | fire-control-12 on master | +| M-Soul | Fix batch from the parallel Soul pass over Cuts 1-2 (content unknown here) | Soul's report | +| 3 | A chunk is a target (rewritten for the EW deferral) | M0; Q12; Q13 | +| 4 | A shot at a chunk goes through FireControl (re-anchored to 12.4) | 3; Q10; the fire-control no-target fuse ruling | +| 5 | Composition, loot, the mined lot | 4; Q4, Q5, Q6, Q7, Q11 | +| 6 | The AI mines on the same path | 5; Q9; locomotion Cut 4 | +| 7 | Content | 5; Q1, Q8; variants C4 for any family authored as variants | + +### M0. Placement and map correction + +- **Repo/branch:** Aetheria, `codex/mining`, in a worktree. Do not use the operator's tree. +- **Deletes first:** none in code. +- **Keeps/moves/adds:** + - `git merge --no-ff master` once fire-control-12 is on master. + - In `docs/mining-cut.md`: apply corrections 1-11 of A.1. + - Replace Cut 3 and Cut 4 with the versions below. + - Strike `UnaidedChunkRange` from Cut 5's and Cut 7's tunables, and "Resource Scanner as the chunk sensor" from + Cut 7. + - Mark the `:396-412` table as history, anchored at `8bd25f6f`. +- **Verification:** + - Yggdrasil, the same job that section 0 ran: + `ygg-verify.sh dotnet ''`. + Rule pinned: fire control and mining compose without a behaviour change on either side. Every pre-existing + test passes unchanged. + - The verify mirror has no LFS (the push from `F:\Projects\Aetheria` fails in the LFS pre-push hook on an + `ssh:` remote). Push from a `--shared --no-checkout` clone, and mount the two LFS blobs. This is recorded + so the next Hands does not rediscover it. + - Unity batchmode compile of the merged tree (Self, in a worktree, never with the operator's editor open on + the main tree). This is the Cut 1 renderer compile the map still owes. + - Operator: fly through a belt (the Cut 1 check the map still owes). Rocks orbit smoothly, and the minimap + and the map screen track them. +- **Subtraction:** docs only. + +### M-Soul. Fix slot for the parallel Soul pass + +- **Repo/branch:** `codex/mining`, after M0. +- **Content:** whatever the parallel Soul pass finds in Cuts 1-2, including the unverified `34994732` and + `93bfdebd`. This map does not predict it. Cut 3 does not start until this slot is closed or empty, because + Cut 3 builds on `Zone.Wear`, `ChunkExists` and `ChunkRadius`. + +### Cut 3. A chunk is a target (rewritten for the Q2 ruling) + +- **Repo/branch:** Aetheria, `codex/mining`, after M-Soul. +- **Deletes first:** + - The scanner-as-sensor plan: no `ChunkDetectionRange`, no `UnaidedChunkRange`, no scanner read in the target + path. + - Optional, and Self's call on what the ruling's "parks" means: the `ResourceScanner` behaviour still runs a + per-tick `Update` that evaluates three stats no code reads (`ResourceScanner.cs:35-58` @`283ce7dc`, + `IAlwaysUpdatedBehavior`). Either keep it as is, or delete the behaviour's `Update` and properties and keep + `ResourceScannerData` authored. No live item carries it (see the census). +- **Adds:** + - `TargetRef`: a readonly struct holding `Entity Entity` and `ChunkId? Chunk`, with value equality and + `IsNone`. It lives beside `ChunkId` (`Zone.cs:527` @`283ce7dc`). + - `Zone.ChunksNear(float2 position, float range, List into)`: the one chunk query. Belts whose + annulus cannot reach are skipped. The rest are evaluated through `ChunkPose` (`Zone.cs:285`), and broken + chunks are skipped through `ChunkExists` (`:231`). + - `Zone.ChunkVelocity(ChunkId)`: analytic velocity plus the parent `Orbit.Velocity`. + - **Visibility, per the Q2 ruling:** a chunk is always visible with info 1. How far the player's cycling and + the AI's query reach is Q13. +- **Per-file changes** (`b66ba524` unless marked): + - `Entity.cs:46`: `Target` becomes `ReactiveProperty`. + - `Entity.cs:201`, `:227`: entity removal clears the slot only when it holds that entity. + - `Entity.cs:229`: `TargetedBy` fires for entities only. + - `Entity.cs:233`: `TargetedByCount` counts entity targets only. + - **`Entity.cs:298-299`:** it clears the target when the target is not in `EntityInfoGathered`. Unchanged, it + clears every chunk target on the next pass, because a chunk is never in that dictionary. The chunk branch + clears only when `!Zone.ChunkExists`, or leaves a broken chunk held as the map's `:139` says. Pick one and + pin it. + - `Entity.cs:317`: `TrySelectTargetItem` is entity-only. + - `Entity.cs:1078`: `TargetRange` reads the chunk position for a chunk. + - `ActionGameManager.cs:365-377` (reticle pick): include the chunk under the reticle, from `ChunksNear` at the + reticle. + - `ActionGameManager.cs:381-388` (`TargetNearest`): stays enemies-only. It drives lock. + - `ActionGameManager.cs:390-404` (next and previous): include chunks within the Q13 reach. + - `ActionGameManager.cs:412-414` (Cycle Target Item): entity-only. + - `ActionGameManager.cs:582`, `:745`, `:769`, `:1046`, `:1224`, `:1239`, `:1266-1267`, `:1355-1373`: `.Entity` + reads, or the chunk position for the indicator. + - `ActionGameManager.cs:1285`: `UpdateFireControlDebug` takes `TargetRef`. + - `Weapon.cs:101` `StanceAllowsFire`: true for a chunk. `Weapon.cs:110` `ArcAllowsFire`: the position through the ref. + - `LockWeapon.cs:86`: a chunk never locks (Q12). + - `TurretController`, `Combat.cs`, `Minion.cs`, `Ship.cs`, `EntityInstance.cs`, `PropertiesPanel.cs`, + `FieldDriver.cs`, `ActionBarSlot.cs`: `.Entity` and null checks. +- **Authority map:** + - Owner: `Entity.Target`, the one slot. + - Inputs: the player's handlers and AI states (Cut 6). + - Outputs: the current `TargetRef`. + - Derived state: `TargetItem`, which is entity-only and nulled on any change as today; `TargetRange`. + - Forbidden writers: any second target field; any chunk pick that bypasses `ChunksNear`; any chunk visibility + or detection stored per tick. + - Shared paths: reticle, cycling, AI, and dock or undock re-activation (`Entity.cs:298-299`). + - Deletion line: no `ReactiveProperty Target` remains. +- **Verification** (Yggdrasil, the M0 command with `--filter FullyQualifiedName~MiningCut3`, then the full suite): + - `ChunksNearSkipsUnreachableBeltsAndBrokenChunks`: pins the one query and the break rule. + - `AChunkTargetSurvivesTheInfoPass`: the negative for `Entity.cs:298-299`. It fails on the naive retype. + - `TargetingAChunkClearsTheAimedItem`: pins the derived `TargetItem` rule. + - `TargetRefEquality`: the same chunk held twice is equal, and a chunk never equals an entity. + - `ChunkTargetIsNeverLocked`: pins Q12's default. + - Every existing fire-control test passes unchanged. That is the negative that ship targeting did not move. + - Negative grep: `git grep -n "ReactiveProperty Target\b"` is empty. + - Stryker over `TargetRef` and `Zone.ChunksNear` (`dotnet tool install -g dotnet-stryker` inside the same job). + - Unity compile: Self. + - Operator: target a rock with the reticle; cycle through rocks and ships; dock and undock with a rock held. +- **Subtraction estimate:** about 0 removed and about 70 added, plus about 90 mechanical site edits (up from the + map's 60, correction 8). + +### Cut 4. A shot at a chunk goes through FireControl (re-anchored to 12.4) + +- **Repo/branch:** `codex/mining`, after Cut 3. +- **Lands after:** the fire-control no-target fuse ruling (`docs/fire-control-cut.md`, 12.4 status, "a fused + weapon fired with no target bursts at the shooter's own position", `FireControl.cs:389`). That ruling's fix + edits the same `target != null` branches this cut retypes (`:367`, `:372-378`, `:389`). If the ruling is still + open, this cut preserves today's behaviour for the no-target case and says so in its commit. +- **Deletes first:** none. `PendingShot.Target` (`:1468`) and `ShotOutcome.Target` (`:1598`) become `TargetRef`. +- **Adds:** + - `Silhouette.Disc(float radiusCells, float precision)`. + - The shared integrator is extracted from `Silhouette` `:1302-1308` (the `Phi` sum over merged intervals, and + `span`). The hull path and the disc path then call one function. No second `Phi` summation exists. +- **Per-file changes** (`FireControl.cs` @`b66ba524`): + - `PFire` `:161-181`: for a chunk, no `VisibleEntities` gate (`:173`) and info 1 (`:179`). A `LockWeapon` is + gated out (`:175`, Q12). The arc stays (`:176`). + - `Forecast` `:206`, `HitProbability` `:228`, `Inspect` `:243-283`: the disc silhouette. `Inspect`'s + `Visible` is true for a chunk. + - `PredictedIntercept` `:133`, `TravelDirection` `:307`: position and velocity through the ref. `Bearing` + `:316` is not called for a chunk, because a disc has no facing. + - `DeviationProbability` `:336-341`: the live chunk position is `Zone.ChunkPose` at `now`. + - `Fire` `:355-418`: freezes the ref. + - `Step` `:439`: `targetGone` includes `!ChunkExists`. + - `CommitProbability` `:506-520`: for a chunk, the disc (no `GetData(Hull)` at `:516`). + - `Commit` `:550-600`: for a chunk hit, no `Lane` (`:563-578`) and no shield block (`:585-592`). `Cell`, + `Bearing` and `Lateral` stay zero in `MakeOutcome` (`:1028`). + - `Apply` `:651-678`: + - Null fuse on a chunk: `Zone.Wear(chunk, Damage)` (`Zone.cs:260` @`283ce7dc`). + - Contact or delayed fuse on a chunk: no `ApplyBlastHit` (it needs a hull, `:747-760`). The fuse point is + the chunk centre at arrival. The chunk's pose is a pure function of time, so the point is exact. + - Proximity fuse: `Detonate` as today. What it does to chunks is Q10. + - `Detonate` `:1074-1150`: unchanged unless Q10 says otherwise. + - Unity presentation: `EntityInstance.cs` (4 target reads) and the four weapon managers aim at the chunk's + rendered position. Presentation only. +- **Authority map:** + - Owner: `FireControl`, unchanged. + - New inputs: chunk pose, velocity and radius through `Zone`. + - Forbidden writers: any chunk hit test outside `Commit`; any chunk pose read in `Apply` except the fuse point + of a blast; any second `Phi` integrator. + - Shared paths: player trigger, AI `Activate` (`Combat.cs:121`), turrets (which never hold a chunk). + - Deletion line: none. +- **Verification** (Yggdrasil, `--filter FullyQualifiedName~MiningCut4`, then the full suite): + - `AChunkShotUsesTheSameFactors`: `Accuracy`, `PSpread` and `Sigma` equal a ship shot's at equal range and + precision. Pins one fire path. + - `DiscMassIsTheClosedForm`: `POnHull = Φ(R/σ) − Φ(−R/σ)`. Pins the shared integrator. + - `HullSilhouetteUnchangedByTheExtraction`: every existing `Silhouette` test and the `ProbeC` equivalence + still pass. This is the negative for the extraction. + - `AChunkThatBreaksMidFlightIsAMiss`: pins `Step`'s gone rule. + - `ChunkHitsWearTheChunk`, and `AHitOnABrokenChunkChangesNothing`, which re-pins `34994732` through the real + path. + - `LaunchersCannotFireOnAChunk`: pins Q12. + - `ChunkDiceAreTheShotsOwn`: the same galaxy and shot id give the same outcome. + - Stryker over the new branches. + - Operator: shoot a rock with each damage type. It shrinks and breaks, and misses at range look plausible. +- **Subtraction estimate:** about 0 removed and about 80 added. The integrator extraction nets about −5. + +### Cut 5. Composition, the loot roll, and the mined lot + +- **Repo/branch:** `codex/mining`, after Cut 4. Written for the recommendations in Q4-Q7 and Q11. Rewrite the + cut if a ruling differs. +- **Deletes first:** `BodyData.Resources` (`ZoneData.cs:78-79` @`283ce7dc`, key 4) becomes a retired-key comment. + Its only reader, `MineAsteroid`, died in Cut 2. +- **Adds, under Q7 = A′ (see Q7):** + - A catalog document type per field kind, holding a list of yield entries: commodity reference, abundance, + and affinity per damage type. + - A nullable kind reference on the field, written by `ZoneGenerator` for new fields. Existing runs resolve a + null kind as Q11 rules. + - `Zone.ChunkComposition(ChunkId, …)`, derived from the kind's entries and a `StableHash` jitter. + - The loot roll inside `Commit`, after the hit draw, from the shot's own generator. Frozen into + `ShotOutcome.Loot`. + - `ItemManager.ExtractedLot(zone, field, commodity)`: find-or-mint, with a source-to-lot index derived at + construction. + - `Extracted` (`Provenance.cs:116` @`b66ba524`) gains `Body` at key 2. + - `Lot` moves from `CraftedItemInstance` (`ItemInstance.cs:38`) to `ItemInstance`, keeping key 11. Coordinate + with variants C4 (Dependencies). +- **Per-file changes** (`b66ba524`): + - `FireControl.Apply`: after `Zone.Wear`, mint or find the lot and `TryStore` it (`Entity.cs:1805`). + - `Entity.cs:1856` (the stack merge matches `Data.Key` only): it must also match `Lot`. + - `Entity.cs:1934` (the split constructs a `SimpleCommodity` without `Lot`): the split copies `Lot`. + - `SavedGame.cs:113` (`.OfType()` as the ledger roots): every `ItemInstance` with + `Lot != 0`. + - `ItemManager.cs:72` (`GetLot(CraftedItemInstance)`): takes `ItemInstance`. +- **Authority map:** + - Owners: + - `FireControl.Commit` decides the loot. + - `ItemManager` mints lots. + - `EquippedCargoBay` places units. + - The field-kind record owns what a field yields. + - Inputs: frozen damage, damage type and penetration; the composition at commit; remaining hit points at commit. + - Outputs: `ShotOutcome.Loot`, units in cargo, ledger lots. + - Derived state: composition (never stored); the source-to-lot index. + - Forbidden writers: + - any loot draw outside `Commit`, except the blast branch Q10 names; + - any mined `SimpleCommodity` with `Lot == 0`; + - any shared random stream; + - per-ore tables on `SimpleCommodityData` (the Q7 alternative that competes with the kind record). + - Deletion line: `BodyData.Resources` is retired before the composition function is added. +- **Verification:** the map's list (`:780-792` @`283ce7dc`) stands, with the fixture rebuilt on the kind record, + plus: + - `ADebrisKindAndAnAsteroidKindRollDifferentTables`: pins "per field kind" (Q2). + - `AMinedStackSurvivesSaveGc`: the negative for `SavedGame.cs:113`. It fails on the old roots. + - `StacksMergeOnlyWithinALot`: the negative for `Entity.cs:1856`. + - Census before and after: the new slots read as `defaulted_missing_slot` until Cut 7. + - Operator: mine one rock with a Thermal gun and one with a Kinetic gun, and read the cargo and its brand + rows. +- **Subtraction estimate:** 2 removed, about 170 added (the kind record costs about 20 more than per-ore fields). + +### Cut 6. The AI mines on the same path + +The map's Cut 6 (`:800-822` @`283ce7dc`) stands, re-anchored: +- the agent factory is `Zone.CreateAgent` at `Zone.cs:126-133` (`283ce7dc`), which assigns `PatrolOrbitsTask` + at `:129`; +- the `Minion` transitions are at `Minion.cs:10-21` (`b66ba524`); +- the `Activate` call to copy is `Combat.cs:121`. + +It waits on locomotion Cut 4 (not landed; locomotion Cut 1 is the only locomotion cut on fire-control-12, per +`docs/merge-to-master-checklist.md:93`). It inherits fire control's F12-5 ("`Combat.cs:117` gates a proximity +weapon on a direct-hit forecast"): an AI miner with a proximity weapon is gated by the wrong forecast until F12-5 +lands. Name it in the cut and do not fix it there. + +### Cut 7. Content + +The map's Cut 7 (`:826-839`) stands, with three changes: +- It drops the Resource Scanner as the chunk sensor (correction 4). +- It authors the field-kind records (Q7 = A′) instead of per-ore fields. +- It runs on the adopted variants release, if any family is authored as variants (Dependencies). + +Its `AetherDb mining-content` command must honour variants Q1a: no plain `Upsert` at a variant key. + +## A.4 Subtraction ledger (estimate, whole campaign from here) + +| Cut | Removed | Added | Formats and targets | +|---|---|---|---| +| 1-2 (landed) | 250 | 181 src, 681 test | union 26 retired; `ZonePack` key 6 | +| 3 | ~0 (optionally ~15, the scanner `Update`) | ~70, plus ~90 mechanical site edits | `Target` retyped | +| 4 | ~5 (integrator extraction) | ~80 | `PendingShot` and `ShotOutcome` target retyped | +| 5 | 2 | ~170 | a field-kind document type; `Extracted` key 2; `Lot` moves to `ItemInstance` key 11; `BodyData` key 4 retired | +| 6 | ~15 | ~90 | none | +| 7 | 0 | content | catalog records | + +No new package, assembly or executable target. The net is additive from Cut 3 on. Each addition buys a named +capability: targetable chunks, the loot roll, provenance, and AI mining. + +## A.5 Operator questions (mining), one fork each + +Q1-Q3 are ruled. Q4-Q9 are restated with context. Q10-Q13 are new, raised by 12.4 and by the Q2 ruling. + +**Q4. The loot weight formula.** (Blocks Cut 5.) + +*Context.* You asked for loot weighted by "some arcane combination of the damage type, the chunk's composition, +and the stats of the weapon you hit it with". The map's proposal for one hit by weapon *w* (damage type *d*, +damage *D*, penetration *P*) on chunk *c*: +- **Units** = `min(D, remaining HP) / HP(size) × Yield(size)`: the floor, plus one Bernoulli draw on the remainder. + A chunk yields the same total however it is broken; damage only gets you there faster. +- **Ore weight** = `Affinity_ore(d) × Comp_c(ore)^(1/Depth)`, with `Depth = 1 + P × MiningDepthPerPenetration`. + - Affinity is "the right tool for the ore". + - Penetration flattens the composition toward rare ores, but never removes the common one. This is the 2020 + `pow(x, 1/penetration)` rule, rewritten so penetration 0 means the raw composition. +- Worked table (`:257-264` @`283ce7dc`): the right damage type roughly triples an ore's share, and penetration + multiplies a rare ore's share further. +- Penetration is being retuned (fire-control F12-2). `MiningDepthPerPenetration` is the one knob that absorbs it. + +*Options.* +- **A:** the formula as written. +- **B:** A, plus `DamageSpread` as a yield multiplier ("breadth"). It changes no weights. +- **C:** A without the penetration term. + +*Recommend A.* Every term is one sentence, and every weapon stat in it reads as a benefit (your reciprocal-formula +rule). B can be added later without changing a weight. + +**Q5. Is the loot rolled per hit, or once when the chunk breaks?** (Blocks Cut 5.) + +*Context.* Per-hit rolling keeps only hit points as state. Rolling at break needs either the breaking weapon +alone to decide everything, or per-ore accumulation per chunk. + +*Options.* +- **A:** per hit, units proportional to damage. +- **B:** once at break, weighted by the weapon that broke it. +- **C:** once at break, weights accumulated per hit. + +*Recommend A.* Your sentence ties the weight to "the weapon you hit it with". Under B, a chip shot from the wrong +gun at the end decides the whole rock. + +**Q6. Where does the ore go, and how big is a lot?** (Blocks Cut 5.) Two sub-forks, one decision each. + +*Q6a. Destination.* +- **A:** straight into the shooter's cargo at arrival. A full hold loses the overflow. +- **B:** a floating pickup. +- *Recommend A.* Pickups are Unity-only (`ItemPickup`, and collection in `ShieldManager`). An AI can never collect + them, which breaks the same-inputs ruling. + +*Q6b. Lot granularity.* +- **A:** one lot per (zone, field, commodity) source, reused until garbage collection. +- **B:** one lot per hit. +- *Recommend A.* Stacks merge only within one lot, so B makes every hit its own cargo stack and grows the ledger + per shot. A is also the shape backward generation will synthesize. + +Lot quality is 1. Nothing reads a `SimpleCommodity`'s quality today (`ItemManager.cs:117,226` @`b66ba524` read +crafted lots only). + +**Q11 (new; answer it before Q7). What is a field kind, and what does each kind yield?** + +*Context.* Your Q2 ruling generalized mining to "chunks of a field", with "various kinds of debris fields" beside +asteroid belts, and said a debris field "rolls salvage through the same weighted roll". Today the only field in +the code is `AsteroidBeltData`, and there is no kind concept. The loot entries the map designed are ores +(`SimpleCommodityData`). Salvage could mean commodities, or it could mean gear. + +The question is what the kind is for: +- Is a kind a content record an author makes more of: "rich belt", "ice belt", "wreck field of faction X"? +- Is it a small fixed set of simulation categories: rock, debris? +- Can a kind's table name items other than simple commodities, such as a damaged `GearData` from a wreck? + +I am not recommending here, because the answer defines the concept. What depends on it: +- where composition lives (Q7); +- whether mined units are always `SimpleCommodity`, which Cut 5's lot and stacking rules assume; +- whether debris salvage enters provenance as `Extracted` or as a different origin. + +**Q7 (restated). Where does composition live?** (Blocks Cut 5; follows Q11.) + +*Context.* +- `BodyData.Resources` exists on every body. It has had no writer since 2020, its only reader died in Cut 2, and + 0 of the 8 belts in the probe save have data in it. +- The map recommended per-ore `Abundance` and `Affinity` fields on `SimpleCommodityData`, with composition + derived. That was written before your ruling that tables are per field kind. + +*Options.* +- **A′ (new):** composition is derived from a per-kind catalog record: a list of entries, each a commodity, + abundance and affinity. A field names its kind. Nothing is stored per chunk. +- **A:** the original. Per-ore fields on `SimpleCommodityData` give one global table for every field kind. +- **B:** `ZoneGenerator` writes each belt's `Resources` at generation, and chunk jitter is derived. Runs started + before Cut 5 stay empty until New Game. + +*Recommend A′,* if Q11 says kinds are content. +- It is the only option where a debris field and a belt can differ, which is what Q2 ruled. +- It is also the shape document variants pay off on. "Rich belt" is a variant of "common belt" that overrides one + entry's abundance by element id. That works only if the entries are a list of objects, not a dictionary: the + variants ruling Q7 replaces dictionaries whole. + +**Q8. The catalog has no Corrosive or Ionizing weapon, now or in its history.** (Blocks Cut 7.) + +*Context.* +- Live weapons use four damage types (census, section 0), and the enum has six. +- Both legacy msgpacks use the same four. +- An ore whose affinity peaks on a missing type cannot be reached. + +*Options.* +- **A:** author affinities over the four types that exist, and record the gap. +- **B:** author new Corrosive and Ionizing weapons in Cut 7. + +*Recommend A.* New weapon types are a content decision, not a mining one. + +**Q9. Which AI ships mine?** (Blocks Cut 6.) + +*Context.* Nothing assigns a `Mining` task. Every NPC gets `PatrolOrbitsTask` (`Zone.cs:129` @`283ce7dc`). + +*Options.* +- **A:** in a zone with fields, an NPC whose loadout has a weapon with nonzero affinity for some ore there gets a + `Mining` task, at an authored share. +- **B:** only dedicated miner hulls or factions mine. That needs the faction-territory "extraction" role. +- **C:** defer AI mining, and prove the path with a test agent only. + +*Recommend A* for this campaign. B can later replace A's eligibility rule without touching the path. + +**Q10 (new). What does a blast do to chunks, and who gets the ore?** (Blocks Cut 4's proximity branch and +Cut 5's blast loot.) + +*Context.* +- 12.4 made every blast an area: `Detonate` applies damage in proportion to the overlap of the blast disc with + each covered cell, over every entity, live at detonation (`FireControl.cs:1074-1150` @`b66ba524`). +- It takes no shooter, so it cannot route loot. +- Chunks are not entities, so today a blast ignores them entirely. +- The mining map assumed only the targeted chunk counts. +- One mechanism fact matters: **a chunk's pose is a pure function of time.** `Commit` can compute exactly where + every chunk will be at arrival, so blast loot can still be decided at commit, as the target's invariant 5 + demands. The only thing that can change between commit and arrival is a chunk breaking, and `Wear` already + refuses a hit on a broken chunk. + +*Options.* +- **A:** a blast covers every chunk its disc overlaps. Each chunk takes its area share. `Commit` computes the + covered chunks at the arrival pose and rolls their loot from the shot's generator into the shooter's cargo. + `Detonate` gains a chunk pass through `ChunksNear`. +- **B:** a blast covers only the targeted chunk, with its area share. No other chunk is touched. +- **C:** blasts do not mine. Only direct hits wear chunks. + +*Recommend A.* +- It is 12.4's own rule, "a blast has no direction", applied to one more kind of body, so there is one area rule. +- B is a special case that makes a proximity burst next to a rock you did not target do nothing. +- The cost of A is one `ChunksNear` query per blast. The map measured about 55 µs for the largest probe belt. + +**Q12 (new, from the map's own open line `:217-220`). Can launchers mine?** + +*Context.* +- Lock and guided launchers need a lock. `LockWeapon.cs:86` @`b66ba524` locks only a hostile target, and a rock is + never hostile. +- So GT 3K, LRMM72, SRMM72, pswarm and scorched void policy cannot fire at a chunk. +- The map said "Rule it if that is wrong", and nobody has. + +*Options.* +- **A:** launchers cannot mine. +- **B:** a chunk can be locked, and the lock rule gains a chunk branch. + +*Recommend A.* It keeps the lock path as it is, and most launchers are blast weapons, which reach chunks through +Q10 = A anyway when a proximity round is fired at a nearby ship. It is your call if you want missiles as mining +tools. + +**Q13 (new). Until the EW campaign, how far away can a chunk be targeted?** + +*Context.* +- Your Q2 ruling deferred chunk detection. A chunk is visible unconditionally, and targeting it is an on-demand + query. +- The reticle pick is local. +- "Next target" and the AI's pick need a radius, or they walk every rock in the zone (809 in the probe zone). +- The map had tied that radius to the Resource Scanner, which your ruling parked. + +*Options.* +- **A:** the longest range among the ship's active weapons. +- **B:** a `GameplaySettings` constant. +- **C:** reticle only; cycling skips chunks. + +*Recommend A.* It needs no new tunable. Weapon range already gates fire, so a rock you can cycle to is a rock you +can shoot. The EW campaign replaces it. + +## A.6 Dependencies (mining) + +- **Fire-control-12 on master:** hard, for every cut from M0 on. +- **The no-target fuse ruling:** the same lines as Cut 4 (`FireControl.cs:389`). Land it first or fold it in. +- **F12-2, the penetration retune:** Q4's `MiningDepthPerPenetration` absorbs it. There is no ordering constraint. +- **F12-5, AI and proximity blasts:** Cut 6 inherits it. +- **Locomotion Cut 4:** Cut 6. +- **Settings-globals:** Cut 5's tunables join `GameplaySettings` and `PlanetSettings` wherever those live. None may + be CultMath-typed while settings are Unity-serialized. +- **Document variants:** + - **C2 (element ids, in flight on `hands/variants-c2a`):** + - "Ids everywhere" (variants Q3) requires an element-id member on every object-list element type. For + mining, that is the Q7 = A′ yield-entry type (new) and `ChunkWearPack` (`ZoneData.cs:51` @`283ce7dc`, + a list element in `ZonePack`). + - If C4 lands first, Cut 5 adds ids to its own new types. If Cut 5 lands first, C4's sweep lists them. + - **Slot coordination:** Cut 5 claims key 11 on `ItemInstance`, the `Lot` move. C4 must not put + `ItemInstance`'s element id at 11. Whichever lands second checks. + - **C3 and C4 (Studio, then Aetheria adoption at `caching-unity-v1.5.0`):** Cut 7 content authored as variants + (field-kind families; a Drill Bit as a variant of an existing beam, if you name a base) waits for C4. + Content authored as plain records does not. The `AetherDb` content command honours variants Q1a either way. + - Aetheria pins CultLib `45c2f40` and `caching-unity-v1.4.0` today (`Directory.Build.props`). No mining cut + needs a CultLib bump. + - **For the C4 owner, not mining:** four catalog records hold a null `Behaviors` element: Refinery, Deep Ore + Extractor, Assembly Line and Shipyard (section 0, present on `b66ba524`). C4's `MintElementIds` pass over + `Aetheria.cc` has to decide what a null list element is: refuse it, drop it, or skip it. The variants cut map + does not say. These are the station factory gear the mining map leaves alone (`:430-433` @`283ce7dc`). + +--- + From 1d2db9acd55eff4991b8ddda29d194645f84791b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:09:45 +0200 Subject: [PATCH 15/45] Record operator rulings on the mining map (2026-09-30) Co-Authored-By: Claude Opus 5.5 --- docs/mining-cut-refresh.md | 1067 +++++++++++++++++++++++------------- 1 file changed, 684 insertions(+), 383 deletions(-) diff --git a/docs/mining-cut-refresh.md b/docs/mining-cut-refresh.md index 2e99d88f6..c9901868e 100644 --- a/docs/mining-cut-refresh.md +++ b/docs/mining-cut-refresh.md @@ -1,8 +1,44 @@ -# Aetheria Codex assimilation: refreshed cut maps (mining, moddable ships) +# Mining: refreshed cut map (v2, operator rulings of 2026-09-30) -Date 2026-09-29. Imagination output for Self. It re-anchors two Codex lanes to the Body as it will stand after -`codex/fire-control-12` merges to `master`. The Codex maps are raw material, not authority. Where this file and -a Codex map disagree, this file is the proposal and the Codex map is the input. +v1 dated 2026-09-29; this is v2, 2026-09-30. Imagination output for Self. It re-anchors the Codex mining lane to +the Body as it will stand after `codex/fire-control-12` merges to `master`, and it incorporates the operator's +rulings of 2026-09-30: + +| Question | Ruling | +|---|---| +| Q4 | A | +| Q5 | A | +| Q6a | A | +| Q6b | A | +| Q7 | A′ | +| Q8 | **B** | +| Q9 | **C** | +| Q10 | A | +| Q11 | authored catalog records that yield simple commodities only | +| Q12 | A | +| Q13 | A, plus the detection correction | + +The Codex map (`docs/mining-cut.md`) is raw material, not authority. Where the two disagree, this file is the +proposal. + +**What changed from v1:** +- **Chunk detection** now goes through the existing reflectance rule. It is no longer unconditional, and it is not + deferred to EW (Cut 3). +- **Cut 6 is deleted.** AI mining is deferred, and the same-path proof is a test agent in Cut 5. +- **Cut 7 authors Corrosive and Ionizing weapons.** +- **The field-kind record arrives in Cut 3** (for cross-section), not Cut 5. +- **Two new forks:** Q14 (how chunk sensor info integrates) and Q16 (how a belt gets its kind). +- **The CultMath-root claim** is settled in the shared precondition. + +The moddable-ships half of v1 lives in `docs/moddable-ships-cut.md` on `codex/moddable-ships`. Section 0's ship +rows are kept for the combined test count. + +## Rulings added 2026-09-30 (Self, after v2) + +- **Q14 A.** A ship's knowledge of a rock is the value the per-tick sensor rule would settle at, computed on + demand through the shared `Sensor.Gain`. It is stateless and costs nothing per tick. +- **Q16 B.** A belt's field kind is chosen at generation and saved. A belt from an older run with no kind gets + one assigned once on first load, by the same function, and that assignment is saved. ## How this was established @@ -72,7 +108,7 @@ in the merged tree; the catalog oid `2aa79c0a` is the same in all three merges a | Turret | 7 | 3 | 5 | | Zenith | 5 | 1 | 5 | - All five hulls use `Prefab`. This matters to ship migration (S5). + All five hulls use `Prefab`. This matters to ship migration (`docs/moddable-ships-cut.md` S5). ## Shared precondition for both campaigns @@ -89,6 +125,39 @@ git switch codex/moddable-ships && git merge --no-ff master and the ships doc cites its commits' results. A rebase rewrites every one of those SHAs, which strands the maps' evidence. The merge has no conflicts (below), so it costs one merge commit per branch. +### Building fire-control-12 headless needs two CultLib checkouts + +Soul (ships branch) reported that fire-control-12 does not compile against its pinned CultLib `45c2f40`, with +`erf` and `erfinv` missing. v1's merges ran green on `45c2f40`. **Both claims are true. They describe two +different build invocations.** + +- `Directory.Build.props` @`b66ba524` pins two revisions of the same repository: `CultLibRevision` `45c2f40` + (CultCache) and `CultMathRevision` `6d5e209` (CultMath, tag `cultmath-unity-v0.2.4`). +- `CultMathRoot` defaults to `CultLibRoot` when it is not passed. +- `erf` and `erfinv` exist in CultMath at `6d5e209` (`packages/cultmath/src/CultMath/math.cs:528`). They do not + exist at `45c2f40`: `git grep` finds neither there. +- `FireControl.cs` calls them at `:1323` and `:1348` @`b66ba524` (Cut 12.0 added them to CultMath). + +Probed on Yggdrasil at `b66ba524`, `dotnet build Aetheria.Shared`: + +| Invocation | Result | +|---|---| +| A. `-p:CultLibRoot=/CultLib` only (at `45c2f40`) | **fails**: `FireControl.cs(1323,54) CS0103 'erf'`, `(1348,53) 'erfinv'` | +| B. as A, plus `-p:SkipCultLibRevisionCheck=true` | the same failure | +| C. `-p:CultLibRoot=/CultLib -p:CultMathRoot=/CultMath` (at `6d5e209`) | **builds** (v1's 379/381 runs used this) | + +**The rule for every Hands and Soul job:** pass both roots, each checked out clean at its own pin. Soul's run was +invocation A: a missing CultMath root, not a broken branch. + +**A second defect this surfaced (not a mining cut).** Case A should never have reached the compiler. The +`VerifyCultLibRevision` target in `Directory.Build.targets` exists to refuse a CultMath root at the wrong +revision, and here it stayed silent. Its `Condition` compares `ProjectReference` identities against a path +spelled with backslashes (`$(CultMathRoot)\packages\cultmath\...`). I infer that on Linux the identity no longer +matches that spelling, so the guard never runs. I have not verified this on Windows. + +This is fire control's or the build owner's to fix. A pin guard that turns itself off on the verification host +is a guard in name only. Hand it to Self as a spawn-worthy defect. + --- # Part A. Mining @@ -122,19 +191,27 @@ tree the suite is 379, of which 17 are mining's. Every one passes (section 0). in the map (`:396-412`, `:669-721`) is re-anchored in A.3 below. 3. **`:24-27` "Fire control is mid-rewrite" is stale.** Cut 12.4 closed 2026-09-29 (`c50029d5`), and 362 tests passed at that close (`docs/fire-control-cut.md` 12.4 status on `b66ba524`). -4. **Cut 3's spec contradicts the Q2 ruling recorded in the same file.** - - Q2's EW deferral (`:176-185`) says a chunk passes the visibility gate unconditionally with PSensor 1, that - targeting is an on-demand field query, and that `ResourceScanner` parks. - - Cut 3 (`:667-668`, `:693`) still specifies `FireControl.ChunkDetectionRange` from the scanner's `Range`, an - `UnaidedChunkRange` fallback, and a test `AScannerExtendsChunkDetection`. - - Cut 7 (`:834-836`) still restores "the Resource Scanner as the chunk sensor" and authors `UnaidedChunkRange`. - - All of that is superseded and is rewritten in A.3. +4. **Chunk detection: both the Codex map and v1 of this map got the ruling wrong.** + - The Codex map's Cut 3 (`:667-668`, `:693`) specifies `FireControl.ChunkDetectionRange` from the scanner's + `Range`, an `UnaidedChunkRange` fallback, and a test `AScannerExtendsChunkDetection`. Its Cut 7 + (`:834-836`) restores "the Resource Scanner as the chunk sensor". + - The Codex Q2 text (`:176-185`) makes a chunk visible unconditionally with PSensor 1, and defers detection to + the EW campaign. v1 of this map followed that text. + - The operator's correction, 2026-09-30: "We can just assign a reflectivity and cross section to each chunk + just like we do with entities, then their visibility depends on how much light is shining on them, no new + mechanism needed ... This is what I actually intended with my ruling about deferring detection." + - So chunk detection is **in this campaign**, through the existing reflectance rule (Cut 3). All three earlier + readings are superseded: + - the scanner as chunk sensor; + - unconditional visibility; + - PSensor fixed at 1. + - `ResourceScanner` stays parked. 5. **Q2's blast consequence (`:221-223`) was written against `Splash`.** Cut 12.4 replaced it with `Detonate(Zone, float2 worldPlanar, float radius, float damage, DamageType)` (`FireControl.cs:1074` @`b66ba524`). - `Detonate` takes no shooter. - - It walks `zone.Entities` only (`:1085`). + - It walks `zone.Entities` only (`:1084`). - So a proximity burst beside a rock does nothing to the rock today, and nothing could route its loot. - - This is new question Q10. + - This was new question Q10, ruled A on 2026-09-30: a blast covers every chunk it overlaps (Cut 4). 6. **`:135`, and the `SimpleCommodityData` comment it copies, are wrong about the retired keys.** Both claim keys 6, 7 and 8 are retired. They are live: `ItemData` declares them for every item, `SpecificHeat` `Key(6)`, `Conductivity` `Key(7)` and `Price` `Key(8)` (`ItemData.cs:291-298` @`b66ba524`), and `SimpleCommodityData` @@ -143,7 +220,7 @@ tree the suite is 379, of which 17 are mining's. Every one passes (section 0). 7. **Q7's recommendation (`:298-310`) predates the Q2 ruling.** Q2 (`:171-174`) says composition and loot tables are **per field kind**, with debris rolling salvage through the same roll. Q7 recommends per-ore `Abundance` on `SimpleCommodityData`, which has one table for every field kind. The two are unreconciled. Q7 is restated - below, behind a new question about what a field kind is. + below. Resolved 2026-09-30: Q11 (kinds are authored records yielding simple commodities) and Q7 A′. 8. **The `Target` blast-radius count (`:193-196`, "about 60 reads in 12 files") has grown.** - At `b66ba524`, `git grep` finds 28 target reads in `ActionGameManager.cs` and 22 in `FireControl.cs`, where the map counted 20 and 11. @@ -185,11 +262,14 @@ The Codex numbering is kept so the map's history still reads, and Soul fix slots |---|---|---| | M0 | Placement: merge master, correct the map | fire-control-12 on master | | M-Soul | Fix batch from the parallel Soul pass over Cuts 1-2 (content unknown here) | Soul's report | -| 3 | A chunk is a target (rewritten for the EW deferral) | M0; Q12; Q13 | -| 4 | A shot at a chunk goes through FireControl (re-anchored to 12.4) | 3; Q10; the fire-control no-target fuse ruling | -| 5 | Composition, loot, the mined lot | 4; Q4, Q5, Q6, Q7, Q11 | -| 6 | The AI mines on the same path | 5; Q9; locomotion Cut 4 | -| 7 | Content | 5; Q1, Q8; variants C4 for any family authored as variants | +| 3 | A chunk is a target, detected by reflected light; the field-kind record appears | M0; **Q14**, **Q16** | +| 4 | A shot at a chunk goes through FireControl (re-anchored to 12.4); blasts cover chunks | 3; the fire-control no-target fuse ruling | +| 5 | Composition, loot, the mined lot, and the same-path test agent | 4 | +| ~~6~~ | **Deleted** (Q9 = C). The test agent moved into Cut 5. | — | +| 7 | Content: field kinds, ores, the Drill Bit, and new Corrosive and Ionizing weapons | 5; variants C4 for families authored as variants | + +Every mining question except Q14 and Q16 is ruled. Neither of those blocks Cut 4 or 5 if Cut 3 lands on the +recommendations. ### M0. Placement and map correction @@ -201,10 +281,19 @@ The Codex numbering is kept so the map's history still reads, and Soul fix slots - Replace Cut 3 and Cut 4 with the versions below. - Strike `UnaidedChunkRange` from Cut 5's and Cut 7's tunables, and "Resource Scanner as the chunk sensor" from Cut 7. + - Replace Q2's "detection is deferred to the electronic-warfare campaign" paragraph (`:176-185`) with the + operator's 2026-09-30 correction, verbatim. + - Record the 2026-09-30 rulings in the map's status header. + - Delete Cut 6 from the map; its history note points here. - Mark the `:396-412` table as history, anchored at `8bd25f6f`. - **Verification:** - Yggdrasil, the same job that section 0 ran: `ygg-verify.sh dotnet ''`. + Both roots are mandatory (shared precondition). + - The job mounts the Yggdrasil mirrors read-only with + `DOCKER_ARGS="-v /home/gamecultadmin/eureka-verify/repos:/repos:ro -v /home/gamecultadmin/eureka-verify/lfs-aetheria:/lfs:ro"`. + - It runs `git config --global --add safe.directory "*"` first. The `GIT_CONFIG_*` safe-directory setting that + `ygg-verify.sh` exports does not reach a local fetch from `/repos`. Rule pinned: fire control and mining compose without a behaviour change on either side. Every pre-existing test passes unchanged. - The verify mirror has no LFS (the push from `F:\Projects\Aetheria` fails in the LFS pre-push hook on an @@ -223,418 +312,630 @@ The Codex numbering is kept so the map's history still reads, and Soul fix slots `93bfdebd`. This map does not predict it. Cut 3 does not start until this slot is closed or empty, because Cut 3 builds on `Zone.Wear`, `ChunkExists` and `ChunkRadius`. -### Cut 3. A chunk is a target (rewritten for the Q2 ruling) +### Cut 3. A chunk is a target, and is detected by reflected light - **Repo/branch:** Aetheria, `codex/mining`, after M-Soul. -- **Deletes first:** - - The scanner-as-sensor plan: no `ChunkDetectionRange`, no `UnaidedChunkRange`, no scanner read in the target - path. - - Optional, and Self's call on what the ruling's "parks" means: the `ResourceScanner` behaviour still runs a - per-tick `Update` that evaluates three stats no code reads (`ResourceScanner.cs:35-58` @`283ce7dc`, - `IAlwaysUpdatedBehavior`). Either keep it as is, or delete the behaviour's `Update` and properties and keep - `ResourceScannerData` authored. No live item carries it (see the census). -- **Adds:** - - `TargetRef`: a readonly struct holding `Entity Entity` and `ChunkId? Chunk`, with value equality and - `IsNone`. It lives beside `ChunkId` (`Zone.cs:527` @`283ce7dc`). - - `Zone.ChunksNear(float2 position, float range, List into)`: the one chunk query. Belts whose - annulus cannot reach are skipped. The rest are evaluated through `ChunkPose` (`Zone.cs:285`), and broken - chunks are skipped through `ChunkExists` (`:231`). - - `Zone.ChunkVelocity(ChunkId)`: analytic velocity plus the parent `Orbit.Velocity`. - - **Visibility, per the Q2 ruling:** a chunk is always visible with info 1. How far the player's cycling and - the AI's query reach is Q13. -- **Per-file changes** (`b66ba524` unless marked): - - `Entity.cs:46`: `Target` becomes `ReactiveProperty`. - - `Entity.cs:201`, `:227`: entity removal clears the slot only when it holds that entity. - - `Entity.cs:229`: `TargetedBy` fires for entities only. - - `Entity.cs:233`: `TargetedByCount` counts entity targets only. - - **`Entity.cs:298-299`:** it clears the target when the target is not in `EntityInfoGathered`. Unchanged, it - clears every chunk target on the next pass, because a chunk is never in that dictionary. The chunk branch - clears only when `!Zone.ChunkExists`, or leaves a broken chunk held as the map's `:139` says. Pick one and - pin it. - - `Entity.cs:317`: `TrySelectTargetItem` is entity-only. - - `Entity.cs:1078`: `TargetRange` reads the chunk position for a chunk. - - `ActionGameManager.cs:365-377` (reticle pick): include the chunk under the reticle, from `ChunksNear` at the - reticle. - - `ActionGameManager.cs:381-388` (`TargetNearest`): stays enemies-only. It drives lock. - - `ActionGameManager.cs:390-404` (next and previous): include chunks within the Q13 reach. - - `ActionGameManager.cs:412-414` (Cycle Target Item): entity-only. - - `ActionGameManager.cs:582`, `:745`, `:769`, `:1046`, `:1224`, `:1239`, `:1266-1267`, `:1355-1373`: `.Entity` - reads, or the chunk position for the indicator. - - `ActionGameManager.cs:1285`: `UpdateFireControlDebug` takes `TargetRef`. - - `Weapon.cs:101` `StanceAllowsFire`: true for a chunk. `Weapon.cs:110` `ArcAllowsFire`: the position through the ref. - - `LockWeapon.cs:86`: a chunk never locks (Q12). - - `TurretController`, `Combat.cs`, `Minion.cs`, `Ship.cs`, `EntityInstance.cs`, `PropertiesPanel.cs`, - `FieldDriver.cs`, `ActionBarSlot.cs`: `.Entity` and null checks. -- **Authority map:** - - Owner: `Entity.Target`, the one slot. - - Inputs: the player's handlers and AI states (Cut 6). - - Outputs: the current `TargetRef`. - - Derived state: `TargetItem`, which is entity-only and nulled on any change as today; `TargetRange`. - - Forbidden writers: any second target field; any chunk pick that bypasses `ChunksNear`; any chunk visibility - or detection stored per tick. - - Shared paths: reticle, cycling, AI, and dock or undock re-activation (`Entity.cs:298-299`). - - Deletion line: no `ReactiveProperty Target` remains. -- **Verification** (Yggdrasil, the M0 command with `--filter FullyQualifiedName~MiningCut3`, then the full suite): - - `ChunksNearSkipsUnreachableBeltsAndBrokenChunks`: pins the one query and the break rule. - - `AChunkTargetSurvivesTheInfoPass`: the negative for `Entity.cs:298-299`. It fails on the naive retype. - - `TargetingAChunkClearsTheAimedItem`: pins the derived `TargetItem` rule. - - `TargetRefEquality`: the same chunk held twice is equal, and a chunk never equals an entity. - - `ChunkTargetIsNeverLocked`: pins Q12's default. - - Every existing fire-control test passes unchanged. That is the negative that ship targeting did not move. - - Negative grep: `git grep -n "ReactiveProperty Target\b"` is empty. - - Stryker over `TargetRef` and `Zone.ChunksNear` (`dotnet tool install -g dotnet-stryker` inside the same job). - - Unity compile: Self. - - Operator: target a rock with the reticle; cycle through rocks and ships; dock and undock with a rock held. -- **Subtraction estimate:** about 0 removed and about 70 added, plus about 90 mechanical site edits (up from the - map's 60, correction 8). +- **Written for:** Q13 = A with the detection correction, Q14 = A, Q16 = B (recommended). -### Cut 4. A shot at a chunk goes through FireControl (re-anchored to 12.4) +#### How detection works today (read at `b66ba524`) + +1. **Emitters write into `Entity.VisibilitySources`** (`Entity.cs:63`, a `Dictionary`). Every + entry is one source's contribution: + - `Reflector.cs:46`: `CrossSection × Zone.GetLight(Position.xz)`. This is the rule the operator named. + - Black-body radiation: `Entity.cs:1269`. + - Weapons: `InstantWeapon.cs:257`, `ConstantWeapon.cs:166`. + - Thrusters: `Thruster.cs:110-111`. + - Sensor pings: `Sensor.cs:117`. + - `Visibility.cs:44` and `Radiator.cs:121`. + + Sources decay each tick (`Entity.cs:1083-1087`, `VisibilityDecay` 0.5). +2. **`Entity.Visibility`** (`Entity.cs:136`) is the sum of the sources. +3. **Each `Sensor` behaviour on an observer** loops over every entity in the zone, every tick + (`Sensor.cs:157-189`). + - It adds a gain into `EntityInfoGathered[target]` (`Entity.cs:64`, a `ReactiveDictionary`). + - Passive gain is `target.Visibility × Sensitivity × SensitivityCurve(angle/π) × dt / dist` (`:179-184`). + - Ping gain is `target.Visibility × Sensitivity × PingBoost × dist`, once per ping (`:170-175`). + - It then decays: `next *= 1 − TargetInfoDecay × dt` (`:186`; `TargetInfoDecay` 0.5 in shipped + `Settings.asset`). +4. **Crossing `TargetDetectionInfoThreshold`** (0.1) adds the target to or removes it from `VisibleEntities`, via + the `ObserveReplace` subscriber at `Entity.cs:237-253`. +5. **Fire control reads both.** `PFire` gates on `VisibleEntities.Contains` (`FireControl.cs:173`) and prices + `PSensor` from the info value (`:179`). The same info also gates the armour and gear reveal thresholds + (0.5 and 0.8) through `IsRevealed`. +6. **Light** is `Zone.GetLight(float2)` (`Zone.cs:369` @`283ce7dc`). It sums `PowerPulse(d / LightRadius, 8)` + over every sun within its `LightRadius`. Beyond every sun's radius, light is 0. + +#### How a chunk plugs in, without becoming an entity + +Two things are needed: +- **A chunk's visibility**, `V(chunk, t)`: `cross-section(chunk) × GetLight(pose(chunk, t))`. This is + `Reflector.cs:46`'s rule, applied to a pose that is already a pure function of time (Cut 1). No stored source, + and no decay: a chunk has only the reflected term, because rocks carry no thrusters, weapons or heat + (black-body emission is out of scope, per the operator's correction). +- **An observer's info on a chunk.** The gain rule is the existing one. Q14 decides how it integrates over time. + Recommended: the closed-form equilibrium of the per-tick rule, evaluated on demand, with no per-tick state. + +**No adapter, and why that is the goal.** The adapter would be a pseudo-entity per chunk, or an `IDetectable` +wrapper, so that `Sensor.Execute`'s loop over `Zone.Entities` sees rocks. That is rejected option C of the Codex +Q2 in a costume: +- It puts every chunk into every sensor's per-tick loop. The map measured 184 ns per observed body per tick, + which is 149 µs per sensor-bearing ship for the 809-chunk probe zone. +- It puts chunks into `EntityInfoGathered`, whose `ObserveReplace` subscriber builds `VisibleEntities`, + `VisibleEnemies` and `VisibleFriendlies`. Every consumer of those collections would then have to learn that + some "entities" are rocks. + +The shared owner has to be **the rule, not the container**. The cut extracts the gain terms at `Sensor.cs:170-184` +into one static function, and both paths call it: +- the entity loop, per tick, unchanged in behaviour; +- the chunk query, on demand. + +Nothing about a chunk is stored per observer. + +#### Deletes first + +- The Codex scanner-as-sensor plan: no `ChunkDetectionRange`, no `UnaidedChunkRange`, and no scanner read + anywhere in the target path. +- The v1 placeholders: unconditional chunk visibility, and PSensor fixed at 1. +- Optional, Self's call: the parked `ResourceScanner` behaviour still runs a per-tick `Update` over three stats no + code reads (`ResourceScanner.cs:35-58` @`283ce7dc`). Delete the `Update` and the properties, and keep + `ResourceScannerData` authored. + +#### Adds + +- **`Sensor.Gain(...)`**, a static function extracted verbatim from `Sensor.cs:170-175` (ping) and `:179-184` + (passive). Inputs: target visibility, sensitivity, the curve at the angle, distance, dt, and whether this is a + ping. `Sensor.Execute` calls it. This is a pure extraction. +- **`FieldKindData`**, a new catalog document type (Q11: authored catalog records). Cut 3 introduces it with only + what detection needs: + - `Name` (`[CultName]`); + - `CrossSection` (float): reflectivity per schematic cell of chunk area; + - `GenerationWeight` (float), for Q16. + + Cut 5 adds the yield entries. Only one kind exists at first, "Asteroid". +- **The belt names its kind.** `AsteroidBeltData` gains a nullable `CultRecordRef Kind`, at the next + free key after `Asteroids` `Key(9)` (`ZoneData.cs:101-105` @`283ce7dc`). + - `ZoneGenerator` writes it at generation, through one function `FieldKinds.Assign(belt key, catalog)`: a + `StableHash` pick weighted by `GenerationWeight`. + - Under Q16 = B, a belt loaded without a kind is assigned by the same function on first load and written back. + One derivation serves both the generation path and the load path. +- **`Zone.ChunkVisibility(ChunkId)`** = `kind.CrossSection × π(ChunkRadius / SchematicCellSize)² × GetLight(ChunkPose.xy)`. + - Area in schematic cells keeps the numbers comparable to hull reflectors. The census has Longinus at 500-150 + over 66 cells and Djinni at 2500-500 over 166, so about 2-15 per cell. + - A worn chunk shrinks and dims. A broken chunk has radius 0, so it is invisible. +- **`Entity.ChunkInfo(ChunkId)`**: the info this observer has on the chunk, from its active `Sensor` behaviours + through `Sensor.Gain`, integrated per Q14. Visible means `ChunkInfo > TargetDetectionInfoThreshold`. +- **`TargetRef`**: a readonly struct holding `Entity Entity` and `ChunkId? Chunk`, with value equality and `IsNone`. + It lives beside `ChunkId` (`Zone.cs:527` @`283ce7dc`). +- **`Zone.ChunksNear(float2 position, float range, List into)`**: the one chunk query. + - Belts whose annulus cannot reach are skipped. + - The rest are evaluated through `ChunkPose` (`Zone.cs:285`). + - Broken chunks are skipped through `ChunkExists` (`:231`). + - It knows nothing about detection. Callers filter by `ChunkInfo`. +- **`Zone.ChunkVelocity(ChunkId)`**: analytic velocity plus the parent `Orbit.Velocity`. +- **`Entity.SetTarget(TargetRef)`**: the one writer. The player's handlers and the Cut 5 test agent both call it + (target invariant 3, "set by one writer path that both the player and the AI call"). Today every handler writes + `Target.Value` directly (`ActionGameManager.cs:377,385,395,403`), so there is no one writer to share. +- **Reach (Q13 = A):** cycling and any programmatic chunk pick use `ChunksNear(position, longest active weapon + Range.Max)`, then filter to chunks the observer can see. + +#### Per-file changes (`b66ba524` unless marked) + +- `Sensor.cs:170-184`: call `Sensor.Gain`. Behaviour must stay identical; the whole existing suite is the check. +- `Entity.cs:46`: `Target` becomes `ReactiveProperty`, written only through `SetTarget`. +- `Entity.cs:201`, `:227`: entity removal clears the slot only when it holds that entity. +- `Entity.cs:229`: `TargetedBy` fires for entities only. +- `Entity.cs:233`: `TargetedByCount` counts entity targets only. +- **`Entity.cs:298-299`:** today it clears a target missing from `EntityInfoGathered`, which would clear every + chunk target on the next pass. For a chunk, it clears when `ChunkInfo ≤ TargetDetectionInfoThreshold`, the same + "lost track" rule expressed through the chunk path. A broken chunk has 0 visibility, so it is lost the same way. +- `Entity.cs:317`: `TrySelectTargetItem` is entity-only. +- `Entity.cs:1078`: `TargetRange` reads the chunk position for a chunk. +- `ActionGameManager.cs:365-377` (reticle pick): pick the chunk under the reticle from `ChunksNear` at the + reticle, if visible. +- `ActionGameManager.cs:381-388` (`TargetNearest`): stays enemies-only. It drives lock. +- `ActionGameManager.cs:390-404` (next and previous): include visible chunks within Q13's reach. +- `ActionGameManager.cs:412-414` (Cycle Target Item): entity-only. +- `ActionGameManager.cs:582`, `:745`, `:769`, `:1046`, `:1224`, `:1239`, `:1266-1267`, `:1355-1373`: `.Entity` + reads, or the chunk position for the indicator. +- `ActionGameManager.cs:1285`: `UpdateFireControlDebug` takes `TargetRef`. +- `Weapon.cs:101` `StanceAllowsFire`: true for a chunk. `Weapon.cs:110` `ArcAllowsFire`: position through the ref. +- `LockWeapon.cs:86`: a chunk never locks (Q12 = A, ruled). +- `TurretController`, `Combat.cs`, `Minion.cs`, `Ship.cs`, `EntityInstance.cs`, `PropertiesPanel.cs`, + `FieldDriver.cs`, `ActionBarSlot.cs`: `.Entity` and null checks. + +#### Authority map + +- **Owners:** + - `Entity.Target` is the one slot, and `SetTarget` is its one writer. + - `Sensor.Gain` is the one detection gain rule. + - `FieldKindData` owns a chunk's reflectivity. + - `FieldKinds.Assign` owns which kind a belt is. +- **Inputs:** player handlers and the test agent; sensor stats; chunk pose and radius; sun light. +- **Outputs:** the current `TargetRef`; `ChunkVisibility`; `ChunkInfo`. +- **Derived state:** + - `ChunkVisibility` and `ChunkInfo` are pure functions of time and are never stored. + - `TargetItem` is entity-only. + - `TargetRange`. +- **Forbidden writers:** + - any second target field; + - any direct `Target.Value =` outside `SetTarget`; + - any chunk in `Zone.Entities` or `EntityInfoGathered`; + - any per-tick chunk detection loop; + - any chunk-specific copy of the gain arithmetic; + - any unconditional visibility for chunks. +- **Shared paths:** reticle, cycling, the test agent, dock and undock re-activation, and belt generation and load + (through `FieldKinds.Assign`). +- **Deletion line:** no `ReactiveProperty Target` remains; no `Target.Value =` outside `SetTarget`; no + gain arithmetic outside `Sensor.Gain`. + +#### Verification + +Yggdrasil, the M0 command with `--filter FullyQualifiedName~MiningCut3`, then the full suite. + +| Test | Rule it pins | +|---|---| +| `SensorGainExtractionChangesNothing` | The existing detection tests, and a recorded `EntityInfoGathered` trace over N ticks for a fixed scene, are byte-equal before and after the extraction. | +| `ChunkInfoMatchesAnEntityHeldStill` | Q14's parity. An entity with a `Reflector` of the same cross-section, at the chunk's position and held still, converges under the per-tick loop to the value `ChunkInfo` returns (tolerance from the discrete fixed point). **One rule, two integrators.** | +| `AChunkInDarknessIsNotVisible` | A chunk outside every sun's `LightRadius` has `ChunkVisibility` 0 and cannot be targeted or fired on. | +| `ABrighterOrBiggerChunkIsSeenFurther` | Monotone in cross-section, radius and light. | +| `AWornChunkDims`, `ABrokenChunkIsInvisible` | Visibility follows `ChunkRadius`. | +| `CyclingReachIsTheLongestActiveWeaponRange` | Q13. A visible chunk beyond it is not offered. | +| `AChunkTargetSurvivesTheInfoPassWhileSeen` | The negative for `Entity.cs:298-299`. It fails on the naive retype. | +| `AChunkTargetIsDroppedWhenItGoesDark` | The lost-track rule, through the chunk path. | +| `TargetRefEquality` | Value equality; a chunk never equals an entity. | +| `ChunkTargetIsNeverLocked` | Q12 = A. | +| `FieldKindAssignmentIsOneFunction` | Generation and first-load assignment give the same kind for the same belt key; the kind is written back once. | +| every existing fire-control and sensor test | Pass unchanged. This is the negative that ship detection and targeting did not move. | + +Also: +- Negative greps: `git grep -n "ReactiveProperty Target\b"` and `git grep -n "Target.Value ="` outside + `Entity.SetTarget` are empty. +- Stryker over `Sensor.Gain`, `Zone.ChunkVisibility`, `Entity.ChunkInfo`, `ChunksNear` and `TargetRef`. +- Census before and after: `AsteroidBeltData` is a run-store type and is absent from the catalog, and + `FieldKindData` shows one record once Cut 7's content lands. Until then, Cut 3 ships a test fixture kind and a + one-record catalog write through `AetherDb`, flagged for operator review. +- Unity compile: Self. +- Operator: + - target a lit rock with the reticle, and cycle through rocks and ships; + - fly into a belt far from the sun and confirm its rocks cannot be picked; + - dock and undock with a rock held. + +#### Subtraction estimate + +About 0 removed (optionally about 15 for the scanner `Update`). About 140 added: +- `Sensor.Gain` extraction nets about 0; +- `FieldKindData` plus assignment, about 40; +- `ChunkVisibility` and `ChunkInfo`, about 30; +- `TargetRef`, `ChunksNear`, `SetTarget`, about 70. + +Plus about 95 mechanical site edits. One new catalog document type (`FieldKindData`), and one nullable +run-store slot (`AsteroidBeltData.Kind`). + +### Cut 4. A shot at a chunk goes through FireControl, and blasts cover chunks (re-anchored to 12.4) - **Repo/branch:** `codex/mining`, after Cut 3. +- **Written for:** Q10 = A, Q12 = A (both ruled). - **Lands after:** the fire-control no-target fuse ruling (`docs/fire-control-cut.md`, 12.4 status, "a fused weapon fired with no target bursts at the shooter's own position", `FireControl.cs:389`). That ruling's fix edits the same `target != null` branches this cut retypes (`:367`, `:372-378`, `:389`). If the ruling is still open, this cut preserves today's behaviour for the no-target case and says so in its commit. -- **Deletes first:** none. `PendingShot.Target` (`:1468`) and `ShotOutcome.Target` (`:1598`) become `TargetRef`. -- **Adds:** - - `Silhouette.Disc(float radiusCells, float precision)`. - - The shared integrator is extracted from `Silhouette` `:1302-1308` (the `Phi` sum over merged intervals, and - `span`). The hull path and the disc path then call one function. No second `Phi` summation exists. -- **Per-file changes** (`FireControl.cs` @`b66ba524`): - - `PFire` `:161-181`: for a chunk, no `VisibleEntities` gate (`:173`) and info 1 (`:179`). A `LockWeapon` is - gated out (`:175`, Q12). The arc stays (`:176`). - - `Forecast` `:206`, `HitProbability` `:228`, `Inspect` `:243-283`: the disc silhouette. `Inspect`'s - `Visible` is true for a chunk. - - `PredictedIntercept` `:133`, `TravelDirection` `:307`: position and velocity through the ref. `Bearing` - `:316` is not called for a chunk, because a disc has no facing. - - `DeviationProbability` `:336-341`: the live chunk position is `Zone.ChunkPose` at `now`. - - `Fire` `:355-418`: freezes the ref. - - `Step` `:439`: `targetGone` includes `!ChunkExists`. - - `CommitProbability` `:506-520`: for a chunk, the disc (no `GetData(Hull)` at `:516`). - - `Commit` `:550-600`: for a chunk hit, no `Lane` (`:563-578`) and no shield block (`:585-592`). `Cell`, - `Bearing` and `Lateral` stay zero in `MakeOutcome` (`:1028`). - - `Apply` `:651-678`: - - Null fuse on a chunk: `Zone.Wear(chunk, Damage)` (`Zone.cs:260` @`283ce7dc`). - - Contact or delayed fuse on a chunk: no `ApplyBlastHit` (it needs a hull, `:747-760`). The fuse point is - the chunk centre at arrival. The chunk's pose is a pure function of time, so the point is exact. - - Proximity fuse: `Detonate` as today. What it does to chunks is Q10. - - `Detonate` `:1074-1150`: unchanged unless Q10 says otherwise. - - Unity presentation: `EntityInstance.cs` (4 target reads) and the four weapon managers aim at the chunk's - rendered position. Presentation only. -- **Authority map:** - - Owner: `FireControl`, unchanged. - - New inputs: chunk pose, velocity and radius through `Zone`. - - Forbidden writers: any chunk hit test outside `Commit`; any chunk pose read in `Apply` except the fuse point - of a blast; any second `Phi` integrator. - - Shared paths: player trigger, AI `Activate` (`Combat.cs:121`), turrets (which never hold a chunk). - - Deletion line: none. -- **Verification** (Yggdrasil, `--filter FullyQualifiedName~MiningCut4`, then the full suite): - - `AChunkShotUsesTheSameFactors`: `Accuracy`, `PSpread` and `Sigma` equal a ship shot's at equal range and - precision. Pins one fire path. - - `DiscMassIsTheClosedForm`: `POnHull = Φ(R/σ) − Φ(−R/σ)`. Pins the shared integrator. - - `HullSilhouetteUnchangedByTheExtraction`: every existing `Silhouette` test and the `ProbeC` equivalence - still pass. This is the negative for the extraction. - - `AChunkThatBreaksMidFlightIsAMiss`: pins `Step`'s gone rule. - - `ChunkHitsWearTheChunk`, and `AHitOnABrokenChunkChangesNothing`, which re-pins `34994732` through the real - path. - - `LaunchersCannotFireOnAChunk`: pins Q12. - - `ChunkDiceAreTheShotsOwn`: the same galaxy and shot id give the same outcome. - - Stryker over the new branches. - - Operator: shoot a rock with each damage type. It shrinks and breaks, and misses at range look plausible. -- **Subtraction estimate:** about 0 removed and about 80 added. The integrator extraction nets about −5. -### Cut 5. Composition, the loot roll, and the mined lot +**Deletes first:** none. `PendingShot.Target` (`:1468`) and `ShotOutcome.Target` (`:1598`) become `TargetRef`. -- **Repo/branch:** `codex/mining`, after Cut 4. Written for the recommendations in Q4-Q7 and Q11. Rewrite the - cut if a ruling differs. -- **Deletes first:** `BodyData.Resources` (`ZoneData.cs:78-79` @`283ce7dc`, key 4) becomes a retired-key comment. - Its only reader, `MineAsteroid`, died in Cut 2. -- **Adds, under Q7 = A′ (see Q7):** - - A catalog document type per field kind, holding a list of yield entries: commodity reference, abundance, - and affinity per damage type. - - A nullable kind reference on the field, written by `ZoneGenerator` for new fields. Existing runs resolve a - null kind as Q11 rules. - - `Zone.ChunkComposition(ChunkId, …)`, derived from the kind's entries and a `StableHash` jitter. - - The loot roll inside `Commit`, after the hit draw, from the shot's own generator. Frozen into - `ShotOutcome.Loot`. - - `ItemManager.ExtractedLot(zone, field, commodity)`: find-or-mint, with a source-to-lot index derived at - construction. - - `Extracted` (`Provenance.cs:116` @`b66ba524`) gains `Body` at key 2. - - `Lot` moves from `CraftedItemInstance` (`ItemInstance.cs:38`) to `ItemInstance`, keeping key 11. Coordinate - with variants C4 (Dependencies). -- **Per-file changes** (`b66ba524`): - - `FireControl.Apply`: after `Zone.Wear`, mint or find the lot and `TryStore` it (`Entity.cs:1805`). - - `Entity.cs:1856` (the stack merge matches `Data.Key` only): it must also match `Lot`. - - `Entity.cs:1934` (the split constructs a `SimpleCommodity` without `Lot`): the split copies `Lot`. - - `SavedGame.cs:113` (`.OfType()` as the ledger roots): every `ItemInstance` with - `Lot != 0`. - - `ItemManager.cs:72` (`GetLot(CraftedItemInstance)`): takes `ItemInstance`. -- **Authority map:** - - Owners: - - `FireControl.Commit` decides the loot. - - `ItemManager` mints lots. - - `EquippedCargoBay` places units. - - The field-kind record owns what a field yields. - - Inputs: frozen damage, damage type and penetration; the composition at commit; remaining hit points at commit. - - Outputs: `ShotOutcome.Loot`, units in cargo, ledger lots. - - Derived state: composition (never stored); the source-to-lot index. - - Forbidden writers: - - any loot draw outside `Commit`, except the blast branch Q10 names; - - any mined `SimpleCommodity` with `Lot == 0`; - - any shared random stream; - - per-ore tables on `SimpleCommodityData` (the Q7 alternative that competes with the kind record). - - Deletion line: `BodyData.Resources` is retired before the composition function is added. -- **Verification:** the map's list (`:780-792` @`283ce7dc`) stands, with the fixture rebuilt on the kind record, - plus: - - `ADebrisKindAndAnAsteroidKindRollDifferentTables`: pins "per field kind" (Q2). - - `AMinedStackSurvivesSaveGc`: the negative for `SavedGame.cs:113`. It fails on the old roots. - - `StacksMergeOnlyWithinALot`: the negative for `Entity.cs:1856`. - - Census before and after: the new slots read as `defaulted_missing_slot` until Cut 7. - - Operator: mine one rock with a Thermal gun and one with a Kinetic gun, and read the cargo and its brand - rows. -- **Subtraction estimate:** 2 removed, about 170 added (the kind record costs about 20 more than per-ore fields). +**Adds:** +- **`Silhouette.Disc(float radiusCells, float precision)`.** The shared integrator is extracted from `Silhouette` + `:1302-1308` (the `Phi` sum over merged intervals, and `span`). The hull path and the disc path call one + function. No second `Phi` summation exists. +- **A chunk pass inside `Detonate`** (Q10 = A). After the entity pass (`:1084-1149`): + - `ChunksNear(worldPlanar, radius + max chunk radius)`. + - Each covered chunk takes `damage × CircleCircleOverlap(blast, chunk) / (π r_blast²)`, the same normaliser the + cell shares use (`:1092`). + - `CircleCircleOverlap` is the closed-form lens area, about 12 lines, beside `CircleSquareOverlap` (`:1153`). + - Each share goes to `Zone.Wear`. + - **One area rule:** a blast's disc is divided over whatever it covers, whether cells or chunks. Conservation + holds across both. +- **`Detonate` gains an optional loot sink**, the detonating shot, so the shooter receives the ore (Q10 = A). + - A sourceless blast passes null. None exists today, but a mine is one (F12-6). A null-sink blast wears chunks + and yields nothing. + - This is the only new parameter. `Detonate` still reads no host (12.4's rule). -### Cut 6. The AI mines on the same path +**Per-file changes** (`FireControl.cs` @`b66ba524`): +- `PFire` `:161-181`: + - For a chunk, the visibility gate (`:173`) is `ChunkInfo > TargetDetectionInfoThreshold`, and `info` (`:179`) + is `ChunkInfo` (Cut 3). **This replaces v1's "no gate, info 1".** + - `PSensor` prices a chunk exactly as it prices a ship at the same info. + - A `LockWeapon` is gated out (`:175`, Q12 = A). The arc stays (`:176`). +- `Forecast` `:206`, `HitProbability` `:228`, `Inspect` `:243-283`: the disc silhouette. `Inspect`'s `Visible` and + `Info` come from the chunk path. +- `PredictedIntercept` `:133`, `TravelDirection` `:307`: position and velocity through the ref. `Bearing` `:316` + is not called for a chunk, because a disc has no facing. +- `DeviationProbability` `:336-341`: the live chunk position is `Zone.ChunkPose` at `now`. +- `Fire` `:355-418`: freezes the ref. +- `Step` `:439`: `targetGone` includes `!ChunkExists`. +- `CommitProbability` `:506-520`: for a chunk, the disc (no `GetData(Hull)` at `:516`). +- `Commit` `:550-600`: for a chunk hit, no `Lane` (`:563-578`) and no shield block (`:585-592`). `Cell`, `Bearing` + and `Lateral` stay zero in `MakeOutcome` (`:1028`). +- `Apply` `:651-678`: + - Null fuse on a chunk: `Zone.Wear(chunk, Damage)` (`Zone.cs:260` @`283ce7dc`). + - Contact or delayed fuse on a chunk: no `ApplyBlastHit`, which needs a hull (`:747-760`). `Detonate` at the + chunk's centre at arrival. The pose is a pure function of time, so the point is exact. + - Proximity: `Detonate` as today, now with the chunk pass. + - Contact or delayed on a ship: `ApplyBlastHit` as today, and its `Detonate` now also covers nearby chunks. +- Unity presentation: `EntityInstance.cs` (4 target reads) and the four weapon managers aim at the chunk's + rendered position. Presentation only. -The map's Cut 6 (`:800-822` @`283ce7dc`) stands, re-anchored: -- the agent factory is `Zone.CreateAgent` at `Zone.cs:126-133` (`283ce7dc`), which assigns `PatrolOrbitsTask` - at `:129`; -- the `Minion` transitions are at `Minion.cs:10-21` (`b66ba524`); -- the `Activate` call to copy is `Combat.cs:121`. +**Authority map:** +- **Owner:** `FireControl`, unchanged. `Detonate` stays the one area owner. +- **New inputs:** chunk pose, velocity, radius and info, through `Zone` and `Entity.ChunkInfo`. +- **When blast damage to chunks is decided:** at detonation. Each covered chunk is judged where it is at arrival, + which is 12.4's own bystander rule (`ApplyBlastHit`'s comment at `:773-777`: "every bystander is judged against + where it actually is at arrival"). A direct hit on a chunk is still decided at `Commit`. +- **Forbidden writers:** + - any chunk hit test outside `Commit`, except `Detonate`'s area pass; + - any second area-share formula; + - any second `Phi` integrator; + - `Detonate` reading a host. +- **Shared paths:** player trigger, the Cut 5 test agent, AI `Activate` (`Combat.cs:121`), and turrets (which + never hold a chunk). Any blast from any source goes through `Detonate`. +- **Deletion line:** none. -It waits on locomotion Cut 4 (not landed; locomotion Cut 1 is the only locomotion cut on fire-control-12, per -`docs/merge-to-master-checklist.md:93`). It inherits fire control's F12-5 ("`Combat.cs:117` gates a proximity -weapon on a direct-hit forecast"): an AI miner with a proximity weapon is gated by the wrong forecast until F12-5 -lands. Name it in the cut and do not fix it there. +**Verification** (Yggdrasil, `--filter FullyQualifiedName~MiningCut4`, then the full suite): -### Cut 7. Content +| Test | Rule it pins | +|---|---| +| `AChunkShotUsesTheSameFactors` | `Accuracy`, `PSpread`, `Sigma` and `PSensor` equal a ship shot's at equal range, precision and info. One fire path. | +| `AnUnseenChunkCannotBeFiredOn` | The gate is the detection rule, not a constant. | +| `DiscMassIsTheClosedForm` | `POnHull = Φ(R/σ) − Φ(−R/σ)`: the shared integrator. | +| `HullSilhouetteUnchangedByTheExtraction` | Every existing `Silhouette` test and the `ProbeC` equivalence still pass. The negative for the extraction. | +| `AChunkThatBreaksMidFlightIsAMiss` | `Step`'s gone rule. | +| `ChunkHitsWearTheChunk`, `AHitOnABrokenChunkChangesNothing` | Wear; re-pins `34994732` through the real path. | +| `ABlastConservesAcrossCellsAndChunks` | The shares delivered to cells plus chunks, plus the uncovered area, equal the damage. Same lattice style as 12.4's 1,944-disc conservation probe. | +| `ABlastBesideAnUntargetedRockWearsIt` | Q10 = A. | +| `CircleCircleOverlapIsTheLens` | Closed form, with the tangent, contained and disjoint cases (12.4's F1 tangent lesson). | +| `LaunchersCannotFireOnAChunk` | Q12 = A. | +| `ChunkDiceAreTheShotsOwn` | The same galaxy and shot id give the same outcome. | -The map's Cut 7 (`:826-839`) stands, with three changes: -- It drops the Resource Scanner as the chunk sensor (correction 4). -- It authors the field-kind records (Q7 = A′) instead of per-ore fields. -- It runs on the adopted variants release, if any family is authored as variants (Dependencies). +Also: Stryker over the new branches. Operator: shoot a lit rock with each damage type; it shrinks and breaks, +and misses at range look plausible. -Its `AetherDb mining-content` command must honour variants Q1a: no plain `Upsert` at a variant key. +**Subtraction estimate:** about 5 removed (the integrator extraction) and about 115 added (the chunk pass and the +lens area add about 35 over v1). + +### Cut 5. Composition, the loot roll, the mined lot, and the same-path test agent + +- **Repo/branch:** `codex/mining`, after Cut 4. +- **Written for:** Q4 A, Q5 A, Q6a A, Q6b A, Q7 A′, Q9 C, Q10 A and Q11, all ruled on 2026-09-30. + +**Deletes first:** +- `BodyData.Resources` (`ZoneData.cs:78-79` @`283ce7dc`, key 4) becomes a retired-key comment. Its only reader, + `MineAsteroid`, died in Cut 2. +- `Agents/Tasks/Mining.cs` and its `.meta` (@`283ce7dc`: 19 lines, no reader, not persisted per `AgentTask.cs:11`, + and not registered in any union). AI mining is deferred (Q9 = C), and the campaign that picks it up writes its own + task. + - Leave the `TaskType.Mine` enum member (`AgentTask.cs:27`). Removing it would renumber the members after it for + no gain. + +**Adds:** +- **`FieldKindData` gains `Entries`**, a `List`. Each `FieldYield` is: + - `Commodity`: a `CultRecordRef` (simple commodities only, Q11); + - `Abundance` (float); + - `Affinity`: an object list of `(DamageType, float)` pairs, absent meaning 1. + + **Every list here is an object list, not a dictionary.** Variants address object-list elements by id and replace + dictionaries whole (variants Q7). So a "rich belt" variant can override one entry's abundance. +- **`Zone.ChunkComposition(ChunkId, Span<…>)`**: derived. `Abundance_o × exp(ChunkCompositionVariance × g(StableHash(field key, index, o)))`, + normalized, from the belt's kind (Cut 3). Never stored. +- **The loot roll (Q4 A, Q5 A)**, for one hit with damage *D* on a chunk of size *s*: + - units = `min(D, remaining HP) / HP(s) × Yield(s)`, the floor plus one Bernoulli draw on the remainder; + - the weight of ore *o* = `Affinity_o(d) × Comp(o)^(1/Depth)`, with `Depth = 1 + P × MiningDepthPerPenetration`; + - one draw per unit. +- **Where each roll happens:** + - **Direct hit:** inside `Commit`, after the hit draw, from the shot's own generator (`FireControl.cs:540` + @`b66ba524`). Frozen into `ShotOutcome.Loot` (commodity and count pairs). + - **Blast (Q10 = A):** inside `Detonate`'s chunk pass, per covered chunk, with that chunk's share as *D*. The + draws come from a generator seeded by the same `(zone, shot)` pair as the shot's commit dice, plus a fixed + blast salt, walked in `ChunksNear` order (belt key, then index). That keeps the dice the shot's own, and + deterministic. + - **This amends target invariant 5.** The old wording was "the loot is decided at Commit". The new wording: + "loot is decided by the owner that decides the damage, from the shot's own dice". That is `Commit` for a direct + hit, and detonation for a blast, following 12.4's bystander rule. I state it here, not as a new fork: it follows + from Q10 = A plus 12.4's ruled model. +- **`ItemManager.ExtractedLot(zone, field, commodity)`** (Q6b A): find-or-mint, with a source-to-lot index derived + at construction and never persisted. +- **`Extracted`** (`Provenance.cs:116` @`b66ba524`) gains `Body` at key 2 (`CultRecordRef`, nil on older + records). +- **`Lot` moves** from `CraftedItemInstance` (`ItemInstance.cs:38`) to `ItemInstance`, keeping key 11. Coordinate + with variants C4 (A.6). +- **The deposit (Q6a A):** units go straight into the shooter's cargo at arrival (a direct hit) or at detonation (a + blast), through `TryStore` (`Entity.cs:1805`). A full hold loses the overflow. +- **Tunables:** `MiningDepthPerPenetration` on `GameplaySettings`; `AsteroidYield` (`ExponentialLerp`) and + `ChunkCompositionVariance` on `PlanetSettings`. None may be CultMath-typed while settings are Unity-serialized. +- **The same-path test agent (Q9 = C, replacing Cut 6).** A headless test drives a ship through exactly the + player's calls: + 1. `Entity.SetTarget(TargetRef(chunk))` (Cut 3); + 2. `weapon.Activate()` (the call `Combat.cs:121` and the player's trigger make); + 3. `Zone.Update` ticks. + + There is no `Agent`, no `AgentTask` and no `Minion` state. It proves the path an AI will later drive, without + building the AI. + +**Per-file changes** (`b66ba524`): +- `FireControl.Commit`, `ApplyDirectHit` and `Detonate`: the roll and the deposit, as above. +- `Entity.cs:1856`: the stack merge matches `Data.Key` only today; it must also match `Lot`. +- `Entity.cs:1934`: the split constructs a `SimpleCommodity` without `Lot`; it must copy `Lot`. +- `SavedGame.cs:113`: the ledger roots are `.OfType()` today; they become every + `ItemInstance` with `Lot != 0`. +- `ItemManager.cs:72`: `GetLot(CraftedItemInstance)` takes `ItemInstance`. + +**Authority map:** +- **Owners:** + - `Commit` decides direct-hit loot, and `Detonate` decides blast loot, each from the shot's dice. + - `ItemManager` mints lots. + - `EquippedCargoBay` places units. + - `FieldKindData` owns what a field yields. + - `FieldKinds.Assign` owns which kind a belt is. +- **Inputs:** frozen damage, damage type and penetration; the chunk's composition and remaining hit points at the + deciding moment. +- **Outputs:** `ShotOutcome.Loot`, units in cargo, ledger lots. +- **Derived state:** composition (never stored); the source-to-lot index. +- **Forbidden writers:** + - any loot draw outside `Commit` and `Detonate`'s chunk pass; + - any mined `SimpleCommodity` with `Lot == 0`; + - any shared random stream; + - per-ore tables on `SimpleCommodityData`; + - any extraction call that bypasses `FireControl`. +- **Shared paths:** the player's trigger and the test agent (identical calls); save GC; Continue, where the index is + rebuilt. +- **Deletion line:** `BodyData.Resources` is retired, and `Mining.cs` deleted, before the roll is added. + +**Verification** (Yggdrasil, `--filter FullyQualifiedName~MiningCut5`, then the full suite): + +| Test | Rule it pins | +|---|---| +| `RightDamageTypeRaisesTheOresShare` | The Q4 table, as a fixture on a kind record. | +| `PenetrationFlattensTowardRareOre`, `ZeroPenetrationReadsTheRawComposition` | Q4's depth term. | +| `AChunkYieldsItsAuthoredTotalWhateverBreaksIt` | Invariant 7, summed over direct hits and blasts from different weapons. | +| `DirectHitLootIsDecidedAtCommit` | Loot frozen in `ShotOutcome`, performed at arrival. | +| `BlastLootIsDecidedAtDetonationFromTheShotsDice` | The amended invariant 5. The same galaxy and shot give the same ore. | +| `ABlastPaysTheShooter` | Q10 = A. | +| `ASourcelessBlastWearsButYieldsNothing` | The null loot sink. | +| `MinedUnitsCarryAnExtractedLotNamingZoneAndField` | Provenance, including `Extracted.Body`. | +| `UnitsFromOneSourceStack`, `UnitsFromTwoSourcesDoNot` | Q6b A, and the negative for `Entity.cs:1856`. | +| `ASplitStackKeepsItsLot` | The negative for `Entity.cs:1934`. | +| `AMinedStackSurvivesSaveGc` | The negative for `SavedGame.cs:113`. It fails on the old roots. | +| `AFullHoldLosesTheOverflow` | Q6a A. | +| `CompositionIsStableAcrossContinue` | Derived, never stored. | +| `TwoKindsRollDifferentTables` | Per field kind (Q2, Q11). | +| `ATestAgentMinesThroughThePlayersPath` | Q9 = C. After N seconds: ore in the hold, every unit's lot `Extracted` from that belt, and every unit traceable to a `ShotOutcome` or a detonation. The negative: grep for any call to `Zone.Wear` or `ItemManager.ExtractedLot` outside `FireControl.cs`. | + +Also: +- Stryker over the formula, the blast pass and the mint. +- Census: the new `FieldKindData` slots read as `defaulted_missing_slot` until Cut 7. +- Operator: mine one lit rock with a Thermal gun and one with a Kinetic gun, and read the cargo and its brand rows. + +**Subtraction estimate:** about 21 removed (`Resources` 2, `Mining.cs` 19). About 190 added: the roll, the blast +loot, the mint, the kind entries and the test agent. The test agent replaces Cut 6's ~90 lines. + +### Cut 6. Deleted (Q9 = C) + +AI mining is deferred. Its same-path proof is the test agent in Cut 5. The Codex Cut 6 (`:800-822` @`283ce7dc`), +its locomotion Cut 4 dependency and its inheritance of F12-5 all leave this campaign. + +The campaign that builds AI mining starts from: +- `Zone.CreateAgent` (`Zone.cs:126-133` @`283ce7dc`); +- the `Minion` transitions (`Minion.cs:10-21` @`b66ba524`); +- the one writer `Entity.SetTarget`; +- `Combat.cs:121`'s `Activate`. + +### Cut 7. Content (operator review sheet, one catalog commit) + +This is a content cut, applied through an `AetherDb mining-content [apply]` command. It is a dry run by default, +and the precedent is `targeting-catalog`. The operator reviews a sheet before `apply`. The command honours variants +Q1a: no plain `Upsert` at a variant key. + +**1. Ores.** The 14 legacy ores missing from live, restored with their legacy mass, specific heat, conductivity and +category (values are in `legacy-0414`, per the Codex map's `:830-832`): +- Minerals: Crystal, Silicon, Carbon, Nitrogen; +- Metals: Iron, Titanium, Copper, Lead; +- Compounds: Ethanol, Hydrocarbons, Acid, Oxygen, Carbon Dioxide; +- Organics: Plant Matter. + +Simple commodities only (Q11). + +**2. Field kinds.** One `FieldKindData` "Asteroid" record: +- `CrossSection` per cell: recommend about 5. That is dull rock, below Longinus's 7.6 and Djinni's 15 per cell at + their maximum reflector values (section 0). +- `GenerationWeight` 1. +- `Entries` over the live and restored ores, each with an `Abundance` and `Affinity` over **all six damage types**, + now that Q8 = B makes every type reachable. + +Further kinds ("rich", "ice", debris) are the operator's to add. Each is a variant of "Asteroid" overriding entries +by id, once variants C4 lands. + +**3. Drill Bit (Q1 A).** +- A `ConstantWeaponData` beam at Kinetic, with high penetration and range 5-20. +- DPS, efficiency and penetration are from legacy `MiningToolData` (DPS 1-2.5, efficiency 2-5, penetration 1-5, + range 5-20). +- No live weapon uses `ConstantWeaponData` (census), so the Drill Bit is also the first live constant weapon. The + operator's check covers the beam's presentation. + +**4. Corrosive and Ionizing weapons (Q8 = B, against v1's recommendation).** + +*The balance fact this rests on (read at `b66ba524`):* **damage type does nothing in combat today.** +- `WeaponData.DamageType` (`Weapon.cs:22-23`) is frozen into the shot (`FireControl.cs:1477`) and passed to + `Shield.CanTakeHit` and `TakeHit` (`Shield.cs:166`, `:186`). Both ignore their `type` argument. +- Armour absorption (`ArmorAbsorb`, `ItemAbsorb`) and `Detonate` take no type. +- The only other reader is `AbsorbEvent`, which is presentation (`CapabilityEvents.cs:28`). +- Mining's affinity (Cut 5) becomes **the first simulation reader of damage type.** +- Therefore a new-type weapon's combat power is fixed entirely by its non-type stats. Its identity shows only in + mining and presentation. + +*Anchors (census, merged tree):* +- Kinetic 9, Electric 3, Optical 3, Thermal 3. +- Sustained single-target DPS (`Damage × Count / Cooldown`, min to max quality) for the auto class: + - Autocannon 6; + - ClearPath 20-180; + - Spectra 40-340; + - ColdFire 16-160; + - CShot RainbowLite 33-600. +- Burst weapons (DeathCluster, FastBlast+-) are magazine-limited and are not comparable by DPS. + +*Records proposed.* One per type. Each is a minimal override of an existing weapon, so its combat envelope equals +its base's by construction, and balance risk is zero on day one. + +| New record | Type | Base (class, hardpoint) | Overrides | Why this base | +|---|---|---|---|---| +| (name: operator's) | Corrosive | ClearPath (`AutoWeaponData`, Ballistic) | `Name`, `DamageType` | Short-range stream: a sprayer. Range 250-600, DPS 20-180, cheap (price 50,000, mass 50). | +| (name: operator's) | Ionizing | Spectra (`AutoWeaponData`, Energy) | `Name`, `DamageType` | Beam-like optical: a particle beam. Range 800-1750, DPS 40-340. | + +Optional second step, flagged for review and not default: give the Ionizing record `Penetration` 0.25 (Autocannon's +value) with damage × 0.8 to pay for it. That makes it the rare-ore tool through Q4's depth term. It changes combat, +because penetration drives 12.3's lanes, so it needs the operator's eye and F12-2's retune. + +*Presentation:* the effect prefab and icon stay the base's until art exists. The Corrosive and Ionizing presentation +look is the operator's call, and so are the names. + +*Variants:* both records are ideal variants: a base plus two overrides, one of them nested in +`Behaviors[weapon].DamageType`. A nested override needs element ids (C2), and Aetheria adopting them needs C4. +- If Cut 7 runs before C4: author them as plain records, and list them in C4's family re-author pass. +- If it runs after: author them as variants. + +Either way, nobody copies a record by hand twice. + +**5. Tunables:** values for `AsteroidYield`, `ChunkCompositionVariance` and `MiningDepthPerPenetration`. + +**Verification:** +- `census` and `dangling` before and after. +- Cut 5's loot fixture runs against the live catalog. It asserts that every ore in every kind is reachable, meaning + some live weapon's damage type has an affinity above 1 for it. +- `EveryDamageTypeHasALiveWeapon` pins Q8 = B. +- Operator: review the sheet; mine with the new Corrosive and Ionizing weapons; feel the Drill Bit. ## A.4 Subtraction ledger (estimate, whole campaign from here) | Cut | Removed | Added | Formats and targets | |---|---|---|---| | 1-2 (landed) | 250 | 181 src, 681 test | union 26 retired; `ZonePack` key 6 | -| 3 | ~0 (optionally ~15, the scanner `Update`) | ~70, plus ~90 mechanical site edits | `Target` retyped | -| 4 | ~5 (integrator extraction) | ~80 | `PendingShot` and `ShotOutcome` target retyped | -| 5 | 2 | ~170 | a field-kind document type; `Extracted` key 2; `Lot` moves to `ItemInstance` key 11; `BodyData` key 4 retired | -| 6 | ~15 | ~90 | none | -| 7 | 0 | content | catalog records | +| 3 | ~0 (optionally ~15, the scanner `Update`) | ~140, plus ~95 mechanical site edits | `Target` retyped; `FieldKindData` document type; `AsteroidBeltData.Kind` (nullable) | +| 4 | ~5 (integrator extraction) | ~115 | `PendingShot` and `ShotOutcome` target retyped; `Detonate` gains a chunk pass and a loot sink | +| 5 | ~21 (`Resources`, `Mining.cs`) | ~190 | `FieldKindData.Entries`; `Extracted` key 2; `Lot` moves to `ItemInstance` key 11; `BodyData` key 4 retired | +| 6 | deleted | 0 | none | +| 7 | 0 | content | 14 ores, 1 field kind, the Drill Bit, 2 weapons | No new package, assembly or executable target. The net is additive from Cut 3 on. Each addition buys a named -capability: targetable chunks, the loot roll, provenance, and AI mining. +capability: detectable, targetable chunks, the loot roll, blast mining, and provenance. Deleting Cut 6 removed +about 90 lines of AI code from the plan. -## A.5 Operator questions (mining), one fork each +## A.5 Operator questions (mining) -Q1-Q3 are ruled. Q4-Q9 are restated with context. Q10-Q13 are new, raised by 12.4 and by the Q2 ruling. +### Ruled, 2026-09-30 (recorded, not reopened) -**Q4. The loot weight formula.** (Blocks Cut 5.) +| Q | Ruling | Where it lands | +|---|---|---| +| Q4 | A, the formula as written | Cut 5 | +| Q5 | A, per hit | Cut 5 | +| Q6a | A, straight into cargo; overflow lost | Cut 5 | +| Q6b | A, one lot per (zone, field, commodity) source | Cut 5 | +| Q7 | A′, a per-kind entry list (commodity, abundance, affinity), derived, never stored per chunk | Cuts 3 and 5 | +| Q8 | **B**, author Corrosive and Ionizing weapons (against v1's recommendation) | Cut 7 §4 | +| Q9 | **C**, AI mining deferred; the path is proven by a test agent (against v1's recommendation) | Cut 5; Cut 6 deleted | +| Q10 | A, a blast covers every chunk it overlaps; the shooter gets the loot | Cuts 4 and 5 | +| Q11 | field kinds are authored catalog records; tables yield simple commodities only | Cuts 3, 5 and 7 | +| Q12 | A, launchers cannot mine | Cuts 3 and 4 | +| Q13 | A, reach is the longest active weapon range. Detection correction: chunks are detected by the existing reflectance rule, not deferred to EW | Cut 3 | -*Context.* You asked for loot weighted by "some arcane combination of the damage type, the chunk's composition, -and the stats of the weapon you hit it with". The map's proposal for one hit by weapon *w* (damage type *d*, -damage *D*, penetration *P*) on chunk *c*: -- **Units** = `min(D, remaining HP) / HP(size) × Yield(size)`: the floor, plus one Bernoulli draw on the remainder. - A chunk yields the same total however it is broken; damage only gets you there faster. -- **Ore weight** = `Affinity_ore(d) × Comp_c(ore)^(1/Depth)`, with `Depth = 1 + P × MiningDepthPerPenetration`. - - Affinity is "the right tool for the ore". - - Penetration flattens the composition toward rare ores, but never removes the common one. This is the 2020 - `pow(x, 1/penetration)` rule, rewritten so penetration 0 means the raw composition. -- Worked table (`:257-264` @`283ce7dc`): the right damage type roughly triples an ore's share, and penetration - multiplies a rare ore's share further. -- Penetration is being retuned (fire-control F12-2). `MiningDepthPerPenetration` is the one knob that absorbs it. +Earlier rulings: Q1 A, Q2 A (one slot, two kinds; field-kind generalization), Q3 A. + +### Open, two forks raised by the Q13 correction + +**Q14. How does a ship's knowledge of a rock build up over time?** (Blocks Cut 3's `ChunkInfo`.) + +*Context.* Your correction puts chunks on the same detection rule as ships: +- visibility = cross-section × light (`Reflector.cs:46`); +- each sensor adds info in proportion to visibility, sensitivity and angle over distance; +- info decays. + +For ships this is a per-tick integrator. Every sensor visits every entity every tick and keeps a running value +per pair (`Sensor.cs:157-189`). Knowledge rises over a few seconds, then holds or fades. That per-tick loop is +exactly what cannot run over rocks: the probe zone has 809, and the loop costs about 184 ns per body per tick per +sensor, which is the kind of per-rock cost you asked to slim down. + +The gain arithmetic itself is shared in every option below; Cut 3 extracts it into one function. The question is +only how it is integrated over time for a rock. *Options.* -- **A:** the formula as written. -- **B:** A, plus `DamageSpread` as a yield multiplier ("breadth"). It changes no weights. -- **C:** A without the penetration term. +- **A. Settled value, on demand.** A rock's info is the value the per-tick rule would settle at, for this observer, + now. It is computed when something asks (targeting, the fire gate), stored nowhere, and costs nothing per tick. + - What you lose: the rise time. A lit rock is known at its settled level the moment it is in range. A rock never + tries to hide, so that delay carries little play. + - Pings: a ping's boost is `visibility × …` (`Sensor.cs:170-175`), so a dark rock gains nothing from a ping in + any option. + - A parity test pins that a ship with the same reflector, held still, settles at exactly A's value. +- **B. Per-tick accumulation within reach.** Each sensor-bearing ship integrates rocks within its longest weapon + range every tick. + - It has the ships' exact dynamics, rise time included. + - Cost per tick grows with ships × rocks in reach, and it keeps state per ship per rock, which needs pruning. +- **C. Lazy integration.** Store (value, last-time) per ship per rock, and integrate the elapsed time when queried. + - It is close to B's dynamics with no per-tick cost. + - The state grows with every rock a ship ever looked at, and it is approximate when light changes between + queries. -*Recommend A.* Every term is one sentence, and every weapon stat in it reads as a benefit (your reciprocal-formula -rule). B can be added later without changing a weight. +*Recommend A.* It is the same rule with no new state and no per-tick cost, and it matches the campaign's founding +invariant: chunk quantities are pure functions of time. The one behaviour it drops, the rise time, belongs to things +that move and hide, and rocks do neither. -**Q5. Is the loot rolled per hit, or once when the chunk breaks?** (Blocks Cut 5.) - -*Context.* Per-hit rolling keeps only hit points as state. Rolling at break needs either the breaking weapon -alone to decide everything, or per-ore accumulation per chunk. - -*Options.* -- **A:** per hit, units proportional to damage. -- **B:** once at break, weighted by the weapon that broke it. -- **C:** once at break, weights accumulated per hit. - -*Recommend A.* Your sentence ties the weight to "the weapon you hit it with". Under B, a chip shot from the wrong -gun at the end decides the whole rock. - -**Q6. Where does the ore go, and how big is a lot?** (Blocks Cut 5.) Two sub-forks, one decision each. - -*Q6a. Destination.* -- **A:** straight into the shooter's cargo at arrival. A full hold loses the overflow. -- **B:** a floating pickup. -- *Recommend A.* Pickups are Unity-only (`ItemPickup`, and collection in `ShieldManager`). An AI can never collect - them, which breaks the same-inputs ruling. - -*Q6b. Lot granularity.* -- **A:** one lot per (zone, field, commodity) source, reused until garbage collection. -- **B:** one lot per hit. -- *Recommend A.* Stacks merge only within one lot, so B makes every hit its own cargo stack and grows the ledger - per shot. A is also the shape backward generation will synthesize. - -Lot quality is 1. Nothing reads a `SimpleCommodity`'s quality today (`ItemManager.cs:117,226` @`b66ba524` read -crafted lots only). - -**Q11 (new; answer it before Q7). What is a field kind, and what does each kind yield?** - -*Context.* Your Q2 ruling generalized mining to "chunks of a field", with "various kinds of debris fields" beside -asteroid belts, and said a debris field "rolls salvage through the same weighted roll". Today the only field in -the code is `AsteroidBeltData`, and there is no kind concept. The loot entries the map designed are ores -(`SimpleCommodityData`). Salvage could mean commodities, or it could mean gear. - -The question is what the kind is for: -- Is a kind a content record an author makes more of: "rich belt", "ice belt", "wreck field of faction X"? -- Is it a small fixed set of simulation categories: rock, debris? -- Can a kind's table name items other than simple commodities, such as a damaged `GearData` from a wreck? - -I am not recommending here, because the answer defines the concept. What depends on it: -- where composition lives (Q7); -- whether mined units are always `SimpleCommodity`, which Cut 5's lot and stacking rules assume; -- whether debris salvage enters provenance as `Extracted` or as a different origin. - -**Q7 (restated). Where does composition live?** (Blocks Cut 5; follows Q11.) +**Q16. How does a belt get its field kind, including belts in runs that started before this campaign?** (Blocks +Cut 3.) *Context.* -- `BodyData.Resources` exists on every body. It has had no writer since 2020, its only reader died in Cut 2, and - 0 of the 8 belts in the probe save have data in it. -- The map recommended per-ore `Abundance` and `Affinity` fields on `SimpleCommodityData`, with composition - derived. That was written before your ruling that tables are per field kind. +- Your Q11 ruling makes field kinds authored records. A chunk's reflectivity (Cut 3) and its yield table (Cut 5) + both come from its belt's kind. +- Today belts are generated with no kind (`AsteroidBeltData`, `ZoneData.cs:101-105` @`283ce7dc`), and a saved run + keeps its belts for the whole run. +- Only one kind will exist at first ("Asteroid"), but you will add more. *Options.* -- **A′ (new):** composition is derived from a per-kind catalog record: a list of entries, each a commodity, - abundance and affinity. A field names its kind. Nothing is stored per chunk. -- **A:** the original. Per-ore fields on `SimpleCommodityData` give one global table for every field kind. -- **B:** `ZoneGenerator` writes each belt's `Resources` at generation, and chunk jitter is derived. Runs started - before Cut 5 stay empty until New Game. +- **A. Derived, never stored.** A belt's kind is a pure function of the belt's identity and the catalog's kinds + (weighted pick). Nothing is saved. Adding or reweighting a kind silently re-kinds belts the player has already + mined. +- **B. Stored at generation, assigned once for old belts.** New belts get a kind when generated. A belt loaded + without one is assigned by the same function the first time it loads, and saved. Adding a kind later never + changes an existing belt. +- **C. Stored at generation only.** Belts from older runs stay kind-less: dark, unminable and unseen, until New + Game. -*Recommend A′,* if Q11 says kinds are content. -- It is the only option where a debris field and a belt can differ, which is what Q2 ruled. -- It is also the shape document variants pay off on. "Rich belt" is a variant of "common belt" that overrides one - entry's abundance by element id. That works only if the entries are a list of objects, not a dictionary: the - variants ruling Q7 replaces dictionaries whole. - -**Q8. The catalog has no Corrosive or Ionizing weapon, now or in its history.** (Blocks Cut 7.) - -*Context.* -- Live weapons use four damage types (census, section 0), and the enum has six. -- Both legacy msgpacks use the same four. -- An ore whose affinity peaks on a missing type cannot be reached. - -*Options.* -- **A:** author affinities over the four types that exist, and record the gap. -- **B:** author new Corrosive and Ionizing weapons in Cut 7. - -*Recommend A.* New weapon types are a content decision, not a mining one. - -**Q9. Which AI ships mine?** (Blocks Cut 6.) - -*Context.* Nothing assigns a `Mining` task. Every NPC gets `PatrolOrbitsTask` (`Zone.cs:129` @`283ce7dc`). - -*Options.* -- **A:** in a zone with fields, an NPC whose loadout has a weapon with nonzero affinity for some ore there gets a - `Mining` task, at an authored share. -- **B:** only dedicated miner hulls or factions mine. That needs the faction-territory "extraction" role. -- **C:** defer AI mining, and prove the path with a test agent only. - -*Recommend A* for this campaign. B can later replace A's eligibility rule without touching the path. - -**Q10 (new). What does a blast do to chunks, and who gets the ore?** (Blocks Cut 4's proximity branch and -Cut 5's blast loot.) - -*Context.* -- 12.4 made every blast an area: `Detonate` applies damage in proportion to the overlap of the blast disc with - each covered cell, over every entity, live at detonation (`FireControl.cs:1074-1150` @`b66ba524`). -- It takes no shooter, so it cannot route loot. -- Chunks are not entities, so today a blast ignores them entirely. -- The mining map assumed only the targeted chunk counts. -- One mechanism fact matters: **a chunk's pose is a pure function of time.** `Commit` can compute exactly where - every chunk will be at arrival, so blast loot can still be decided at commit, as the target's invariant 5 - demands. The only thing that can change between commit and arrival is a chunk breaking, and `Wear` already - refuses a hit on a broken chunk. - -*Options.* -- **A:** a blast covers every chunk its disc overlaps. Each chunk takes its area share. `Commit` computes the - covered chunks at the arrival pose and rolls their loot from the shot's generator into the shooter's cargo. - `Detonate` gains a chunk pass through `ChunksNear`. -- **B:** a blast covers only the targeted chunk, with its area share. No other chunk is touched. -- **C:** blasts do not mine. Only direct hits wear chunks. - -*Recommend A.* -- It is 12.4's own rule, "a blast has no direction", applied to one more kind of body, so there is one area rule. -- B is a special case that makes a proximity burst next to a rock you did not target do nothing. -- The cost of A is one `ChunksNear` query per blast. The map measured about 55 µs for the largest probe belt. - -**Q12 (new, from the map's own open line `:217-220`). Can launchers mine?** - -*Context.* -- Lock and guided launchers need a lock. `LockWeapon.cs:86` @`b66ba524` locks only a hostile target, and a rock is - never hostile. -- So GT 3K, LRMM72, SRMM72, pswarm and scorched void policy cannot fire at a chunk. -- The map said "Rule it if that is wrong", and nobody has. - -*Options.* -- **A:** launchers cannot mine. -- **B:** a chunk can be locked, and the lock rule gains a chunk branch. - -*Recommend A.* It keeps the lock path as it is, and most launchers are blast weapons, which reach chunks through -Q10 = A anyway when a proximity round is fired at a nearby ship. It is your call if you want missiles as mining -tools. - -**Q13 (new). Until the EW campaign, how far away can a chunk be targeted?** - -*Context.* -- Your Q2 ruling deferred chunk detection. A chunk is visible unconditionally, and targeting it is an on-demand - query. -- The reticle pick is local. -- "Next target" and the AI's pick need a radius, or they walk every rock in the zone (809 in the probe zone). -- The map had tied that radius to the Resource Scanner, which your ruling parked. - -*Options.* -- **A:** the longest range among the ship's active weapons. -- **B:** a `GameplaySettings` constant. -- **C:** reticle only; cycling skips chunks. - -*Recommend A.* It needs no new tunable. Weapon range already gates fire, so a rock you can cycle to is a rock you -can shoot. The EW campaign replaces it. +*Recommend B.* One assignment function serves both paths (generation and first load), so there is no second +rule. A belt keeps the identity the player learned. A is the only option where a catalog edit rewrites the world +under a running save. ## A.6 Dependencies (mining) - **Fire-control-12 on master:** hard, for every cut from M0 on. +- **Both CultLib roots in every build:** CultCache `45c2f40` and CultMath `6d5e209` (shared precondition). The + `VerifyCultLibRevision` guard does not fire on the Linux verify host. That is a separate defect, handed to Self. - **The no-target fuse ruling:** the same lines as Cut 4 (`FireControl.cs:389`). Land it first or fold it in. -- **F12-2, the penetration retune:** Q4's `MiningDepthPerPenetration` absorbs it. There is no ordering constraint. -- **F12-5, AI and proximity blasts:** Cut 6 inherits it. -- **Locomotion Cut 4:** Cut 6. +- **F12-2, the penetration retune:** Q4's `MiningDepthPerPenetration` absorbs it. It gates Cut 7 §4's optional + Ionizing penetration step. +- **F12-6, mines:** when mines ship, they are sourceless blasts. They wear chunks and yield nothing (Cut 4's null + sink). - **Settings-globals:** Cut 5's tunables join `GameplaySettings` and `PlanetSettings` wherever those live. None may be CultMath-typed while settings are Unity-serialized. +- **No longer dependencies (Cut 6 deleted):** locomotion Cut 4, and F12-5 (AI and proximity blasts). - **Document variants:** - **C2 (element ids, in flight on `hands/variants-c2a`):** - - "Ids everywhere" (variants Q3) requires an element-id member on every object-list element type. For - mining, that is the Q7 = A′ yield-entry type (new) and `ChunkWearPack` (`ZoneData.cs:51` @`283ce7dc`, - a list element in `ZonePack`). - - If C4 lands first, Cut 5 adds ids to its own new types. If Cut 5 lands first, C4's sweep lists them. - - **Slot coordination:** Cut 5 claims key 11 on `ItemInstance`, the `Lot` move. C4 must not put - `ItemInstance`'s element id at 11. Whichever lands second checks. - - **C3 and C4 (Studio, then Aetheria adoption at `caching-unity-v1.5.0`):** Cut 7 content authored as variants - (field-kind families; a Drill Bit as a variant of an existing beam, if you name a base) waits for C4. - Content authored as plain records does not. The `AetherDb` content command honours variants Q1a either way. + - "Ids everywhere" (variants Q3) requires an element-id member on every object-list element type. For mining, + that is `FieldYield` (new, Cut 5), the affinity pair type inside it (new), and `ChunkWearPack` + (`ZoneData.cs:51` @`283ce7dc`, a list element in `ZonePack`). + - If C4 lands first, Cuts 3 and 5 add ids to their own new types. If they land first, C4's sweep lists them. + - **Slot coordination:** Cut 5 claims key 11 on `ItemInstance` (the `Lot` move), and Cut 3 claims the next + free key on `AsteroidBeltData`. C4 must not put an element id on either. Whichever lands second checks. + - **C3 and C4 (Studio, then Aetheria adoption at `caching-unity-v1.5.0`):** + - Field-kind families ("rich belt" as a variant of "Asteroid") and the two new weapons (variants of ClearPath + and Spectra, with a nested `DamageType` override) are the natural variant content. + - If Cut 7 lands before C4, it authors them as plain records and adds them to C4's re-author list. - Aetheria pins CultLib `45c2f40` and `caching-unity-v1.4.0` today (`Directory.Build.props`). No mining cut needs a CultLib bump. - **For the C4 owner, not mining:** four catalog records hold a null `Behaviors` element: Refinery, Deep Ore Extractor, Assembly Line and Shipyard (section 0, present on `b66ba524`). C4's `MintElementIds` pass over `Aetheria.cc` has to decide what a null list element is: refuse it, drop it, or skip it. The variants cut map - does not say. These are the station factory gear the mining map leaves alone (`:430-433` @`283ce7dc`). - ---- - + does not say. From 22fb4021392ddf789bd11057bf08c056e2df6e10 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:00:30 +0200 Subject: [PATCH 16/45] Mining Cuts 1-2: pin five chunk-surface rules that survived mutation Every mutant of the Cut 1-2 chunk surface (Zone.ChunkExists, ChunkRadius, Wear, EvaluateBelt, ChunkPose, PackZone/unpack, AsteroidBelt.Pose and its sizes, ChunkId) was run against MiningCut1Tests and MiningCut2Tests. Six survived; five are behaviour and get a test each: - ChunkExists is false for a body that is not a debris field (a planet) and for a key naming no body. - A hit on the exact respawn instant lands on the live chunk (Wear agrees with ChunkExists and ChunkRadius about the boundary). - A chunk worn after respawning packs with no BrokenUntil. - ChunkPose carries the worn size, and zero for a broken chunk. - Chunk rotation wraps at one turn (the fixture never reached one before). The Cut 2 fixture gains a non-belt body, and its settings the three curves that body's constructor reads. The sixth survivor, a ChunkId hash that ignores the index, changes no observable behaviour. Tests only; no production change. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut1Tests.cs | 22 +++++ .../Aetheria.Shared.Tests/MiningCut2Tests.cs | 88 ++++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs index 3091fad4d..1467ae316 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -149,6 +149,28 @@ public sealed class MiningCut1Tests : IDisposable } } + // The fixture's rotation speeds are under 1.1 rad/s, so the accumulated ~2 s of the test above never + // reaches one turn and cannot tell a wrapped rotation from an unwrapped one. Forty seconds turns the + // faster asteroids more than once. + [Fact] + public void ChunkRotationWrapsAtOneTurn() + { + var (zone, beltKey, asteroids, settings) = BuildFixture(); + zone.Update(40f); + var parentPosition = OrbitPosition(40.0, RootDistance, RootPhase, settings); + + var wrapped = 0; + for (var i = 0; i < asteroids.Length; i++) + { + if (40.0 * asteroids[i].RotationSpeed > PI * 2) wrapped++; + var expected = ExpectedPose(40.0, asteroids[i], parentPosition, settings, settings.AsteroidSize.Evaluate(asteroids[i].Size)); + var actual = zone.ChunkPose(beltKey, i); + AssertClose(expected.z, actual.z, 0.001f, $"asteroid {i} rotation"); + Assert.InRange(actual.z, 0f, PI * 2); + } + Assert.True(wrapped > 0, "sanity: the fixture must turn at least one asteroid past one full rotation"); + } + [Fact] public void ChunkPoseDoesNotDependOnUpdateCadence() { diff --git a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs index 345d6cf40..647406669 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs @@ -43,7 +43,11 @@ public sealed class MiningCut2Tests : IDisposable OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, AsteroidHitpoints = new ExponentialLerp { Minimum = 20f, Maximum = 220f, Exponent = 1.6f }, - AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f } + AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f }, + // The zone builds a Planet for the fixture's non-belt body; its constructor reads these three. + BodyRadius = new ExponentialCurve(), + GravityRadius = new ExponentialCurve(), + GravityDepth = new ExponentialCurve() }; private sealed class Fixture @@ -53,6 +57,7 @@ public sealed class MiningCut2Tests : IDisposable public PlanetSettings Settings; public CultRecordKey BeltA; public CultRecordKey BeltB; + public CultRecordKey Planet; public Asteroid[] AsteroidsA; public Asteroid[] AsteroidsB; } @@ -92,6 +97,8 @@ public sealed class MiningCut2Tests : IDisposable var beltAKey = MakeBelt(9, 700f, 0.05f); var beltBKey = MakeBelt(5, 1600f, 0.61f); + // A body that is not a debris field: a chunk of it must not exist, whatever its index. + var planetKey = cache.Upsert(new PlanetData { Orbit = new CultRecordRef(rootKey) }).Key; var beltAData = (AsteroidBeltData) cache.Get(beltAKey); var beltBData = (AsteroidBeltData) cache.Get(beltBKey); @@ -99,7 +106,7 @@ public sealed class MiningCut2Tests : IDisposable var pack = new ZonePack { Orbits = { new CultRecordRef(rootKey) }, - Planets = { new CultRecordRef(beltAKey), new CultRecordRef(beltBKey) }, + Planets = { new CultRecordRef(beltAKey), new CultRecordRef(beltBKey), new CultRecordRef(planetKey) }, Radius = 5000f, Mass = 10000f, Time = 0 @@ -114,7 +121,7 @@ public sealed class MiningCut2Tests : IDisposable return new Fixture { Zone = zone, Items = items, Settings = settings, - BeltA = beltAKey, BeltB = beltBKey, + BeltA = beltAKey, BeltB = beltBKey, Planet = planetKey, AsteroidsA = beltAData.Asteroids, AsteroidsB = beltBData.Asteroids }; } @@ -428,6 +435,81 @@ public sealed class MiningCut2Tests : IDisposable var stillPacked = pack.ChunkWear?.Exists(w => w.Field.Equals(chunk.Field) && w.Index == chunk.Index) ?? false; Assert.False(stillPacked, "at exactly the respawn instant the entry already carries no live information"); } + + // Soul-debt pass (mining-cut-refresh A.1 item 11): the rules below were unpinned when every mutant of the + // chunk surface was run against the Cut 1-2 tests. Each fails under its own mutation. + [Fact] + public void OnlyADebrisFieldHasChunks() + { + var f = BuildFixture(); + Assert.True(f.Zone.Planets.ContainsKey(f.Planet), "sanity: the fixture's planet is a body of the zone"); + Assert.False(f.Zone.ChunkExists(new ChunkId(f.Planet, 0)), "a planet is a body, not a field of chunks"); + Assert.False(f.Zone.ChunkExists(new ChunkId(new CultRecordKey(Guid.NewGuid().ToString("N")), 0)), "a key that names no body has no chunks"); + } + + // The respawn instant belongs to the live chunk on every reader: ChunkExists and ChunkRadius already say so + // (RespawnAndPruneBoundaryIsInclusiveOfExactlyNow); Wear, the writer, must agree, or a hit landing on the + // instant is swallowed by a chunk every reader shows. + [Fact] + public void AHitOnTheExactRespawnInstantLandsOnTheLiveChunk() + { + var f = BuildFixture(); + const int index = 2; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + f.Zone.Update(respawn); + Assert.True(f.Zone.ChunkExists(chunk)); + + Assert.True(f.Zone.Wear(chunk, hp + 1f), "a hit on the instant of respawn breaks the respawned chunk again"); + Assert.False(f.Zone.ChunkExists(chunk)); + } + + // A chunk that has respawned and been worn again is damaged and not broken. Its saved wear says so: no + // respawn time is carried over from the break it already recovered from. + [Fact] + public void WearAfterRespawnPacksWithoutTheOldRespawnTime() + { + var f = BuildFixture(); + const int index = 1; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + f.Zone.Update(respawn + 1f); + Assert.False(f.Zone.Wear(chunk, hp * 0.3f)); + + var packed = f.Zone.PackZone().ChunkWear.Find(w => w.Field.Equals(chunk.Field) && w.Index == index); + Assert.NotNull(packed); + Assert.Equal(hp * 0.3f, packed.Damage, 4); + Assert.Null(packed.BrokenUntil); + } + + // ChunkPose is the sim's read of a chunk's size as well as its place: worn chunks are smaller, broken ones + // have none. Reading the undamaged size here would show a shot-out rock at full size to everything that + // asks through ChunkPose. + [Fact] + public void ChunkPoseCarriesTheChunksWornSize() + { + var f = BuildFixture(); + const int index = 3; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + + var undamaged = f.Zone.ChunkPose(f.BeltA, index).w; + Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), undamaged, 4); + + Assert.False(f.Zone.Wear(chunk, hp * 0.6f)); + var worn = f.Zone.ChunkPose(f.BeltA, index).w; + Assert.Equal(f.Zone.ChunkRadius(chunk), worn, 4); + Assert.True(worn < undamaged, "a worn chunk is smaller than an untouched one"); + + Assert.True(f.Zone.Wear(chunk, hp)); + Assert.Equal(0f, f.Zone.ChunkPose(f.BeltA, index).w); + } } internal static class MiningCut2TestExtensions From eb1401c20d588d8ba10aab8be364354ae5149784 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:19:11 +0200 Subject: [PATCH 17/45] Mining M0: correct the map against the v2 refresh Applies corrections 1-11 of the refresh's A.1 to docs/mining-cut.md: the status header records the 2026-09-30 rulings, Q14 and Q16 stay open (they block Cut 3), and M-Soul content is unknown (the mutation sweep at 22fb4021 stands in). Cuts 3 and 4 are replaced with the refresh's versions; Cut 6 is deleted with a history pointer (Q9 = C); the scanner-as-chunk-sensor plan and the fallback range tunable are struck; Q2's detection paragraph is replaced with the operator's 2026-09-30 correction; the FireControl target table is marked history at 8bd25f6f. The refresh's Cut 5 and Cut 7 texts are not folded in; the header says so. Co-Authored-By: Claude Sonnet 5.5 --- docs/mining-cut.md | 529 ++++++++++++++++++++++++++++++++------------- 1 file changed, 374 insertions(+), 155 deletions(-) diff --git a/docs/mining-cut.md b/docs/mining-cut.md index 78f41c56c..49cab4e1e 100644 --- a/docs/mining-cut.md +++ b/docs/mining-cut.md @@ -1,12 +1,15 @@ # Mining: Target and Cut Map -Date: 2026-09-25 +Date: 2026-09-25 (corrected for M0, 2026-09-30) -Status: target and cut map, from an Imagination pass. Nothing here has landed. The first half is the +Status: target and cut map, from an Imagination pass. Cuts 1 and 2 have landed on `codex/mining` (see the status +header in Part III), which was merged with master at M0 to carry fire control. The first half is the **target** (the ends). The second half is the **cut map** (the means). When the campaign closes, split the target out as `docs/mining-target.md` and keep this file as history. -Anchors are against `codex/fire-control-12` HEAD `8bd25f6f`, read from a clean worktree of that commit. Claims +Anchors are against `codex/fire-control-12` HEAD `8bd25f6f`, read from a clean worktree of that commit. **Every +anchor in a fire-control file is stale at `b66ba524`** (`FireControl.cs` is 1,614 lines there; `Splash` is gone); Cuts 3 +and 4 below carry the re-anchored versions, and the `8bd25f6f` anchors elsewhere are history. Claims marked **(probe)** were measured by running code; claims marked **(read)** come from source at the cited anchor. Probes live in the session scratchpad and are not kept in the repo: @@ -21,10 +24,10 @@ Probes live in the session scratchpad and are not kept in the repo: - The legacy record: `scratchpad/legacy-0305.json` and `legacy-0414.json` (decoded `AetherDB.msgpack` history and `Legacy/AetherDB.2021-04-14.msgpack`), and the deleted `GameData/SimpleCommodityData/*.json` at `c6ffcad3^`. -**Scope: mining only.** Fire control is mid-rewrite on this branch (Cut 12.3 and 12.4 of -`docs/fire-control-cut.md`, being worked in the main tree by other agents). Cuts 3 and 4 below edit `FireControl.cs` -and `Entity.cs` and must land after fire-control Cut 12 closes, or rebase onto it. Cut 1 and Cut 2 touch no -fire-control code. §Overlap names every shared line. +**Scope: mining only.** Fire control Cut 12.4 closed 2026-09-29 (`c50029d5`; 362 tests at that close) and is on +master (`dcd7bbc5`, 2026-09-30), merged into this branch at M0. Cuts 3 and 4 below edit `FireControl.cs` and +`Entity.cs`, code fire control rewrote after this map was first written, so they are re-anchored to `b66ba524`. Cut 1 +and Cut 2 touched no fire-control code. §Overlap names every shared line. --- @@ -114,11 +117,11 @@ nothing per tick. - Backward economy generation, routes and piracy (next scope, `docs/three-gates-scope.md:141-145`). This campaign writes the first real `Extracted` lots, and generation will later synthesize more of the same shape. -- Refining, crafting from ore, prices for ore (every live simple commodity has `Price 0` **(probe)**). +- Refining, crafting from ore, prices for ore (every live simple commodity has `Price 0` except Ammo, priced 1000 **(merged-tree census)**). - AI cargo offload at a home station (the 2020 controller's `GoHome`). An AI miner with full cargo stops mining. -- Electronic-warfare clutter ("a ship ... can float in a debris field or asteroid belt and pass as junk", - `docs/three-gates-scope.md:155-157`). This is parked there. Q2's recommendation keeps the door open, because a - chunk enters through the shared detection gate. +- Electronic-warfare clutter and masking beyond reflected light ("a ship ... can float in a debris field or asteroid belt + and pass as junk", `docs/three-gates-scope.md:155-157`) stays parked there, and so does black-body emission for + chunks. Detection of a chunk by reflected light is **in** this campaign (Q2 correction, 2026-09-30; Cut 3). - Orbits beyond belts. `Zone.Update` still steps the planet orbits (`:156-161`), which cost about 31 us/tick in the probe zone. That is cheap and out of scope. @@ -132,7 +135,7 @@ nothing per tick. | **Chunk pose** (position, rotation, velocity) | Derived: `(ChunkId, zone time)`. | Never stored. Evaluated on demand. | One function on `AsteroidBelt` (Cut 1). Forbidden writers: any stored pose array, any per-tick loop. | | **Chunk wear** (damage taken, broken-until time) | Keyed by `ChunkId` inside the owning `Zone`. | Damage accumulates at Apply. When it reaches the chunk's hitpoints, the chunk breaks with `RespawnAt = zone time + AsteroidRespawnTime(size)`. At `time >= RespawnAt` it is whole again, read lazily with no timer. Persisted in `ZonePack` (new nullable key 6) with the zone's own `Time`, and pruned of expired entries at pack. | `FireControl.Apply` through one `Zone` method is the only writer. `Zone` owns the storage. | | **Chunk composition** | Derived: `(belt identity, ChunkId, catalog ore table)`. | Never stored (recommended, Q7). A retune of the catalog retunes every belt. | The catalog author, through per-ore data on `SimpleCommodityData`. The derivation function is the only reader of that data for belts. | -| **Ore affinity and abundance** | Per ore: new nullable fields on `SimpleCommodityData` (keys 12 and 13; keys 6, 7, 8 and 11 are retired and must not be reused, `ItemData.cs:304`). | Authored in Studio. Changes with the catalog. | Catalog author. | +| **Ore affinity and abundance** | Per ore: new nullable fields on `SimpleCommodityData` (keys 12 and 13 are free. Only key 11 is retired on `SimpleCommodityData`; keys 6, 7 and 8 are live, inherited from `ItemData` (`SpecificHeat`, `Conductivity`, `Price`, `ItemData.cs:291-298` @`b66ba524`). The comment at `ItemData.cs:304` wrongly lists 6, 7, 8 and 11). | Authored in Studio. Changes with the catalog. | Catalog author. | | **Global loot tunables** | `GameplaySettings` / `PlanetSettings` fields (Cut 5). | Authored. Today they live in `Settings.asset`. They move with the settings-globals campaign (`docs/settings-globals-cut.md`, mapped, not landed). | Catalog author. | | **Mined lot** | `LotId` in the run's `ProvenanceLedger`. `Lot { Design = the commodity, Origin = Extracted { Zone, Commodity, Body }, Quality, Roles = null }`. | Minted at the first extraction from a (zone index, belt, commodity) source (Q6). Reused for later units from that source. Collected by `Reachable` when no instance reaches it. A later extraction after collection mints a fresh lot. | `ItemManager.ExtractedLot(zone, belt, commodity)` is the only writer. Its source→lot index is derived at load, not persisted. | | **Mined unit** | A `SimpleCommodity` instance, `Data` = commodity, `Lot` = the source lot (`Lot` moves to the `ItemInstance` base, key 11). | Stored in cargo. Stacks merge only within the same `Data` and `Lot`. A split copies `Lot`. | `EquippedCargoBay` owns placement. The mint owns `Lot`. | @@ -146,6 +149,8 @@ other way. ## Operator questions, in the order they block cuts +The 2026-09-30 rulings on Q4-Q13 are recorded in the Part III status header. Where a question below was written before its ruling, the ruling wins and the recommendation text is history. + Self delivers these one at a time (memory: pace operator questions). Cut 1 needs no ruling. The CPU-versus-GPU belt renderer is a stated default: CPU, one orbit function, so no parity test is needed. Reopen it on request. @@ -163,7 +168,7 @@ damage channel with its own DPS, efficiency, penetration and range. Nothing assi content keeps its meaning through the one fire path. - B: Keep `MiningTool` as a second extraction channel beside weapons. - **Recommended: A.** B is a second hit path with its own dice and its own yield rule, which is exactly the - split Cut 3 of fire control removed. `ResourceScanner` is Q2's business. + split Cut 3 of fire control removed. `ResourceScanner` stays parked (Q2 correction, 2026-09-30). **Q2 (blocks Cut 3). How does a chunk become a target?** **Ruled 2026-09-25: A, one slot, two kinds.** Operator: "Note that I have been saying chunks specifically because this should generalize to more than just @@ -173,16 +178,7 @@ field**; asteroid belts are one kind of field and debris fields another. `ChunkI Cut 2); new chunk-surface code speaks of chunks and fields, never belts or rocks. Composition and loot tables (Q4, Q7) are per field kind: a debris field rolls salvage through the same weighted roll. -**Detection is deferred to the electronic-warfare campaign.** Operator, correcting Self's "rocks never hide": "But -uh, chunks have the same kind of sensor envelope as a hull, that's what makes masking and hiding possible. They -have black body emission and an albedo and a cross section." Then: "Put the fields in, defer the signature and -detection work for the later EW campaign." So, as **explicit placeholders the EW campaign owns and replaces**: -a chunk passes the visibility gate unconditionally with PSensor 1 (weapon range and arc still gate); targeting a -chunk is an on-demand query against the field, with no per-tick chunk detection; `ResourceScanner` parks (its data -stays; its purpose is decided with detection). Handed to EW: chunks present a sensor envelope like a hull -(black-body emission, albedo, cross-section), which makes masking and hiding in fields possible; per-chunk -evaluation must stay on demand or be aggregated per field for cost (per-pair detection is ~184 ns per observed body -per tick, the reason chunks-as-entities was rejected). +**Detection (operator correction, 2026-09-30, replacing the 2026-09-25 "deferred to the electronic-warfare campaign" text).** Operator: "We can just assign a reflectivity and cross section to each chunk just like we do with entities, then their visibility depends on how much light is shining on them, no new mechanism needed ... This is what I actually intended with my ruling about deferring detection." The options as mapped, kept for the record: The options below were costed against the live code: @@ -190,7 +186,9 @@ costed against the live code: - **A. One slot, two kinds.** `Entity.Target` becomes `ReactiveProperty`, where `TargetRef` is a small readonly value that is either an `Entity` or a `ChunkId`, with value equality. Readers that need a ship read `Target.Value.Entity` (null for a chunk). FireControl's factor functions take a `TargetRef`, with the chunk - branch at each target-shaped read. Blast radius **(read)**: about 60 `Target` reads in 12 files + branch at each target-shaped read. Blast radius **(read)**: the count has grown since the first read. At `b66ba524`: 28 `Target` reads in `ActionGameManager.cs`, 22 in + `FireControl.cs`, 11 in `Entity.cs`, plus Unity weapon-manager sites (`GuidedProjectileManager`, `LightningGunManager`, + `ConstantParticleWeaponManager`, `GuidedProjectile`) the first count omitted. The first read was about 60 in 12 files (`ActionGameManager.cs` 20, `FireControl.cs` 11, `Entity.cs` 8, `TurretController.cs` 7, `LockWeapon.cs` 5, `EntityInstance.cs` 3, `Weapon.cs` 2, `Combat.cs`, `Minion.cs`, `Ship.cs:84`, `ActionBarSlot.cs`), plus `PendingShot.Target` and `ShotOutcome.Target`. Most sites are mechanical (`.Entity`). @@ -209,18 +207,17 @@ costed against the live code: rock" is the one-owner sentence, and it matches the parked electronic-warfare direction: "every body presents a signature through the shared detection interface". Consequences that come with A, named here and not decided later: -- **Visibility (superseded 2026-09-25 by the EW deferral above).** A chunk passes the visibility gate within the shooter's chunk detection range, and its PSensor is - 1, because rocks do not hide. Recommend that range be the equipped `ResourceScanner`'s `Range`, with a - `GameplaySettings.UnaidedChunkRange` fallback. That is the `TargetingSystem`/`UnaidedTracking` precedent - (`FireControl.cs:121-125`), and it gives `ResourceScanner` a job: it is to chunks what the targeting system is to - ships. Its dead survey half (`ResourceScanner.cs:71-107`, `// TODO: Implement Scanning!`) is deleted. +- **Visibility.** Superseded twice; the live design is Cut 3 (a chunk is detected by reflected light through the + shared sensor gain rule). The scanner-as-chunk-sensor plan, the fallback range tunable, unconditional visibility and + PSensor 1 are all struck. `ResourceScanner` stays parked. - **Stance.** `StanceAllowsFire` (`Weapon.cs:101`) allows fire on a chunk. - **Lock weapons.** Launchers cannot lock a chunk. `LockWeapon.cs:86` requires `IsHostileTo`, and rocks are not hostile, so guided and lock launchers (GT 3K, LRMM72, SRMM72, pswarm, scorched void policy) cannot mine. Rule it if that is wrong. -- **Blasts (fire-control 12.4).** A blast shot targeting a chunk delivers `damage × area(blast disc ∩ chunk disc) / - area(blast disc)`, which is 12.4's own area rule with the chunk disc as the only cell. Untargeted chunks near a - blast are unaffected, so there is no per-blast scan of belts. +- **Blasts (fire-control 12.4).** Superseded by Q10 (ruled A, 2026-09-30): a blast covers every chunk it overlaps, and + the shooter gets the loot (Cut 4). The text this replaces was written against `Splash`, which Cut 12.4 replaced with + `Detonate(Zone, float2 worldPlanar, float radius, float damage, DamageType)` (`FireControl.cs:1074` @`b66ba524`); that + function takes no shooter and walks `zone.Entities` only (`:1084`), so a blast beside a rock did nothing to it. **Q3 (blocks Cut 2). Do chunks respawn, and does wear survive save/continue?** **Ruled 2026-09-25: A.** Today `MineAsteroid` sets `RespawnTimers` (`Zone.cs:316`), but nothing ever decrements them, and `AsteroidExists` @@ -267,7 +264,7 @@ So the right damage type roughly triples an ore's share, and penetration multipl Alternatives to put to the operator: - A: the formula above (Damage sets quantity; damage type and penetration set weights). - B: A, plus `DamageSpread` as breadth: a yield multiplier `1 + DamageSpread × k`, which strips more rock per hit at - the raw composition. It is a benefit, and GT 3K, plight, pswarm and scorched void policy author it **(probe)**. + the raw composition. It is a benefit, and GT 3K, plight, pswarm, SRMM72 and scorched void policy author it **(probe)**. - C: A, with no penetration term (damage type and composition only). This is the simplest to read. - **Recommended: A.** Every term is one sentence, and every weapon stat in it is a benefit. B is a clean add later, because it changes no weight. Note: penetration is scheduled for a retune (fire-control F12-2, "Retuning @@ -295,7 +292,7 @@ CultMath-typed while settings are Unity-serialized (memory: Unity-serialized Cul - Lot quality: 1 for extracted lots. Nothing reads a `SimpleCommodity`'s quality (price and tier read `CraftedItemInstance` only, `ItemManager.cs:114,224`). Grade can arrive as its own ruling later. -**Q7 (blocks Cut 5). Where does composition live?** +**Q7 (blocks Cut 5). Where does composition live?** **Resolved 2026-09-30: A′** (a per-kind entry list of commodity, abundance and affinity, derived, never stored per chunk), with Q11 (field kinds are authored catalog records yielding simple commodities). The per-ore `Abundance` on `SimpleCommodityData` recommended below is superseded: it gives one table for every field kind, which the Q2 ruling (tables are per field kind) contradicts. `BodyData.Resources` (key 4, `ZoneData.cs:53-54`) is on every body and has never had a writer since the 2020 generator (`17673cd2`) was commented out. Its only reader is `MineAsteroid` (`Zone.cs:322-323`). No live belt has resources **(probe: 8 belts, 0 authored)**. @@ -310,14 +307,14 @@ resources **(probe: 8 belts, 0 authored)**. because a new draw there would shift every later placement in newly generated zones. **Q8 (blocks Cut 7). The catalog has no Corrosive or Ionizing weapon, now or in its history.** Live weapons use -Kinetic (10), Optical (3), Electric (3) and Thermal (3) **(probe)**. `legacy-0305` and `legacy-0414` use the same +Kinetic (9), Optical (3), Electric (3) and Thermal (3) **(probe)**. `legacy-0305` and `legacy-0414` use the same four types **(probe)**. An ore whose affinity peaks at Corrosive or Ionizing is unreachable. - A: Author affinities over the four types that exist, and add Corrosive and Ionizing weapons when content wants them. - B: Author new Corrosive and Ionizing weapons in Cut 7. - **Recommended: A**, with the gap recorded in Cut 7's review sheet. -**Q9 (blocks Cut 6). Which AI ships mine?** Nothing assigns a `Mining` task today. `Zone.CreateAgent` +**Q9 (blocks Cut 6). Which AI ships mine?** **Ruled 2026-09-30: C** (defer; against the recommendation below). Cut 6 is deleted. Nothing assigns a `Mining` task today. `Zone.CreateAgent` (`Zone.cs:122-129`) gives every NPC a `PatrolOrbitsTask`. - A: In a zone with belts, an NPC whose loadout has a weapon with nonzero affinity to some belt ore gets a `Mining` task on the nearest belt, with an authored share (`GameplaySettings.AgentMinerShare`). @@ -393,6 +390,8 @@ from the always-empty `beltData.Resources`, and mints nothing: the `SimpleCommod ## How FireControl sees a target (what a chunk must answer) +**History, anchored at `8bd25f6f`.** Every line number below is stale (`FireControl.cs` is 1,614 lines at `b66ba524`, `Splash` is gone, and the chunk visibility and info rows are superseded by Cut 3). The re-anchored, live version is in Cuts 3 and 4. + **(read, `FireControl.cs` at `8bd25f6f`)**: | Read | Where | For a chunk | @@ -418,13 +417,13 @@ integrator, used by the hull path and `Silhouette.Disc` alike, so no second copy ## The catalog and the legacy record (probe) - **Live simple commodities (13):** Minerals: Hydrogen, Potassium, Rock. Metals: Gold, New Metals, Uranium. - Compounds: Methane, Water. Organics: Algae, Animal Products, Bacteria, Fungi. Ammo: Ammo. Every one is priced 0. + Compounds: Methane, Water. Organics: Algae, Animal Products, Bacteria, Fungi. Ammo: Ammo. Every one is priced 0 except Ammo (1000). The catalog oid moved after `8bd25f6f` (`4f218e85`, `dd437580`, locomotion Cut 1): `1862ddc4` then, `2aa79c0a` at `b66ba524`. - **Legacy (30, identical in `legacy-0305` and `legacy-0414`):** the 13 live ones, plus Crystal, Silicon, Carbon, Nitrogen (Minerals); Iron, Titanium, Copper, Lead (Metals); Ethanol, Hydrocarbons, Acid, Oxygen, Carbon Dioxide (Compounds); Plant Matter (Organics); and Autocannon, Charged Shotgun, Flak Cannon and Auto Shotgun ammo. That is 17 missing from live, 14 of them plausible ores. - **Resource-distribution data does not survive.** The 2020 fields (`ResourceDensity` map layers, `Minimum`, - `Maximum`, `Exponent`, `Floor`; `17673cd2`) are the retired keys 6, 7, 8 and 11. No values remain in the deleted + `Maximum`, `Exponent`, `Floor`; `17673cd2`) were keys 6, 7, 8 and 11 on `SimpleCommodityData`. Only key 11 stays retired there; 6, 7 and 8 are live `ItemData` keys (`SpecificHeat`, `Conductivity`, `Price`). No values remain in the deleted JSON records at `c6ffcad3^` (for example, `Uranium.json` carries only name, mass and category) or in either msgpack. - **Mining gear:** Drill Bit and Resource Scanner exist in legacy only (Q1). Surface Ore Extractor and Deep Ore @@ -464,9 +463,8 @@ lot. That includes every existing loadout ammo stack. Reading such a lot through `Forecast`, `HitProbability`, `Inspect`, `CommitProbability`, `Commit`, `Apply`, `Silhouette`, `PendingShot` and `ShotOutcome`, and they touch `Splash`'s successor from 12.4. They must follow Cut 12.4. Cuts 1, 2 and 5-7 do not touch `FireControl.cs`, except Cut 5's loot-draw lines inside `Commit`, which also wait for 12.4. -- **Locomotion (`codex/locomotion`).** Its Cut 4 rewrites `MoveTo` and the heading planner. Cut 6 here drives the - approach through whatever `MoveToState` is live and adds no motion code, so it waits for, or rebases onto, - locomotion Cut 4. +- **Locomotion (`codex/locomotion`).** Its Cut 4 rewrites `MoveTo` and the heading planner. Cut 6 is deleted (Q9 = C), so + this campaign no longer depends on locomotion Cut 4. - **Settings globals (`docs/settings-globals-cut.md`).** Cut 5's new tunables join `GameplaySettings` and `PlanetSettings` wherever those live when it lands. - The uncommitted `AetheriaInput.cs` change in the main tree ("Cycle Target Item") is fire-control work. Cut 3's @@ -478,11 +476,45 @@ lot. That includes every existing loadout ammo stack. Reading such a lot through ## Status header -Status: cut map. Ends are owned by Part I of this document; Part III owns the means. +Status: cut map. Ends are owned by Part I of this document; Part III owns the means. Updated at M0, 2026-09-30. -Rulings (operator): Q1 = A, Q2 = A (with the field generalization and the EW deferral), Q3 = A (2026-09-25). Q4-Q9 open. +**M0 (2026-09-30):** `codex/mining` merged with master (fire control, `dcd7bbc5`) and this map corrected against +`docs/mining-cut-refresh.md` (v2). Cuts 3 and 4 below are the refresh's versions. The refresh's Cut 5 and Cut 7 texts are +the current proposals and are not yet folded into this file; where they differ, the refresh wins. -Open: Q4-Q9. Cut 3 waits only on fire-control Cut 12 closing (it edits `FireControl.cs`). +Rulings (operator), 2026-09-25: Q1 = A, Q2 = A (one slot, two kinds; field generalization), Q3 = A. + +Rulings (operator), 2026-09-30: + +| Q | Ruling | +|---|---| +| Q4 | A, the formula as written (Cut 5) | +| Q5 | A, per hit (Cut 5) | +| Q6a | A, straight into cargo; overflow lost (Cut 5) | +| Q6b | A, one lot per (zone, field, commodity) source (Cut 5) | +| Q7 | A′, a per-kind entry list (commodity, abundance, affinity), derived, never stored per chunk (Cuts 3 and 5) | +| Q8 | **B**, author Corrosive and Ionizing weapons (Cut 7), against the earlier recommendation | +| Q9 | **C**, AI mining deferred; the path is proven by a test agent in Cut 5. Cut 6 is deleted | +| Q10 | A, a blast covers every chunk it overlaps; the shooter gets the loot (Cuts 4 and 5) | +| Q11 | field kinds are authored catalog records; tables yield simple commodities only (Cuts 3, 5, 7) | +| Q12 | A, launchers cannot mine (Cuts 3 and 4) | +| Q13 | A, reach is the longest active weapon range | +| Q2 correction | Chunks are detected by the existing reflectance rule (reflectivity and cross-section, lit by the suns), not deferred to EW. Struck: the scanner as chunk sensor, the fallback range tunable, unconditional visibility, PSensor 1. `ResourceScanner` stays parked | + +**Q14 (how a ship's info on a rock integrates over time) and Q16 (how a belt gets its field kind) remain open.** They +block Cut 3. Self's recommendations (Q14 A, settled value on demand; Q16 B, stored at generation and assigned once for +old belts) are proposals, not operator rulings; Cut 3 is written for them. + +**M-Soul content unknown:** no Soul report for Cuts 1-2 exists; the mutation sweep at `22fb4021` stands in. Cut 3 +does not start until M-Soul is closed or empty. + +**Still owed:** +- From Cut 1: a Unity compile of the `ZoneRenderer` and `AsteroidBeltUI` change (M0 runs it), an isolated Stryker rerun, + and the operator's fly-through. Nothing after `d9c887c1` records any of them. +- From Cut 2: Soul has not verified the two-commit fix (`34994732`, `93bfdebd`). It folds into Cut 3's Soul pass. + +Test counts: the map's 274 after Cut 2 were counted on its own `8bd25f6f` base. On the merged tree (`fc90e936`, master plus +mining) the suite is 498 tests, all passing on Yggdrasil (CultLib `45c2f40`, CultMath `6d5e209`). **Cut 2: landed 2026-09-25** at `3b9f0f1d`, `c924bae0`, `daa64560`. 272 tests; production net −73 lines; `AetherDb census` byte-identical; break threshold kept strictly `>`; `ZonePack` key 6 nullable, proven by a raw-MessagePack older-record test. Spec discrepancy fixed: the retirement comment for union tag 26 no longer names `MiningToolData`, which the cut's own negative grep forbids. Soul found one real defect, dormant until Cut 4 wires `FireControl.Apply` into `Wear`: a hit on a chunk that was already broken reset `BrokenUntil` and resurrected it on the spot (any shot resolving after the chunk broke — @@ -522,11 +554,13 @@ Follow-ups outside this campaign: |---|---|---| | 1 | Subtraction: belts evaluated on demand. No threads, no stored poses. | none | | 2 | Subtraction plus one owner: the dead mining path goes, and chunk identity and wear get one owner | Q1, Q3 | -| 3 | Targetable chunks: the one slot, the one chunk query, the HUD | Q2; fire-control Cut 12 closed | -| 4 | Hit resolution against chunks through FireControl (damage only, no loot) | 3 | +| M0 | Placement: merge master, correct the map | fire-control-12 on master | +| M-Soul | Fix slot for the parallel Soul pass over Cuts 1-2 (content unknown) | Soul report | +| 3 | A chunk is a target, detected by reflected light; the field-kind record appears | M0, M-Soul; Q14, Q16 | +| 4 | A shot at a chunk goes through FireControl; blasts cover chunks | 3; the fire-control no-target fuse ruling | | 5 | Composition, loot roll, provenance, deposit | 4; Q4-Q7 | -| 6 | AI mining on the same path | 5; Q9; locomotion Cut 4 | -| 7 | Content: ores, affinities, abundance, Drill Bit, Resource Scanner (operator review) | 5; Q1, Q8 | +| ~~6~~ | **Deleted** (Q9 = C); the test agent moved into Cut 5 | none | +| 7 | Content: ores, affinities, abundance, Drill Bit, Corrosive and Ionizing weapons (operator review) | 5; Q1, Q8 | Cuts 1 and 2 are kept apart so Soul can falsify "nothing changed" (Cut 1) separately from "the dead path is gone and wear has one owner" (Cut 2). @@ -653,91 +687,294 @@ and wear has one owner" (Cut 2). --- -### Cut 3. A chunk is a target +### Cut 3. A chunk is a target, and is detected by reflected light -- **Repo/branch:** Aetheria, `codex/mining`, rebased onto the fire-control Cut 12 close. Depends on Cut 2 and Q2. - Written for Q2=A. -- **Deletes first:** none of substance. Reshapes follow. -- **Adds:** - - `TargetRef` (readonly struct: `Entity Entity`, `ChunkId? Chunk`, value equality, `IsNone`). - - `Zone.ChunksNear(float2 position, float range, List into)`. It is the one chunk query: it skips belts - whose annulus (`GetOrbitPosition(parent)`, `Radius`) cannot reach, evaluates the rest on demand, and skips broken - chunks. The player's reticle pick, target cycling and the AI all call it. - - `Zone.ChunkVelocity(ChunkId)`: analytic, `2πD/T · (−sin θ, cos θ)` plus the parent `Orbit.Velocity` (`Zone.cs:160`). - - `FireControl.ChunkDetectionRange(Entity)`: the equipped `ResourceScanner`'s `Range` when active, else - `GameplaySettings.UnaidedChunkRange` (the `Tracking` shape, `FireControl.cs:121-125`). -- **Per-file changes** (anchors at `8bd25f6f`; re-anchor after the Cut 12 rebase): - - `Entity.cs:46`: `Target` becomes `ReactiveProperty`. `:201`, `:227`, `:231-234`, `:298-299` compare - and clear through `.Entity`. `:317` and `:328` (`TrySelectTargetItem`, `ResolvedTargetItem`) are entity-only, - via `.Entity`. `:1060` `TargetRange` reads the chunk position for a chunk. - - `ActionGameManager.cs:372-404`: the reticle, next and previous handlers include chunks from `Zone.ChunksNear` - within `ChunkDetectionRange`. `TargetNearest` stays enemies-only (`:380-388`, its own comment: it drives weapon - lock). The HUD at `:1266-1275` shows the chunk's size, and its wear as hitpoints, for a chunk target. The - visibility fills are ship-only. `UpdateFireControlDebug(Entity)` (`:1284`) takes `TargetRef`. - - `Weapon.cs:101` `StanceAllowsFire`: a chunk is allowed. `:110-117` `ArcAllowsFire`: reads the target position - through the ref. - - `LockWeapon.cs:80-98`, `TurretController.cs:60-100`, `Combat.cs:28-126`, `Minion.cs:14,18,21`, `Ship.cs:84`, - `EntityInstance.cs:207,236,405`: `.Entity` and null-checks. Turrets and combat never hold a chunk (they write - only enemies). -- **Authority map:** - - Owner: `Entity.Target`, the one slot. - - Inputs: the player's targeting handlers and the AI states. - - Outputs: the current `TargetRef`. - - Derived state: `TargetItem` (ship-only, nulled on any change as today, `Entity.cs:286`); `TargetRange`. - - Forbidden writers: any second target field; any chunk selection that bypasses `Zone.ChunksNear`. - - Shared paths: player reticle, cycling, AI (Cut 6), and dock/undock reactivation (`Entity.cs:298-299`). - - Deletion line: no `ReactiveProperty Target` remains. -- **Verification:** - - tests: - - `ReticleAndCycleOfferChunksWithinDetectionRange`, headless through the same query the handler calls. - - `AScannerExtendsChunkDetection` (and `UnaidedFallbackWithoutOne`). - - `ABrokenChunkIsNotOffered`. - - `TargetingAChunkClearsTheAimedItem`. - - `TargetRefEquality`: the same chunk held twice is equal. - - Every existing fire-control test still passes unchanged. That is the negative check that ship targeting did - not move. - - negative: `rg -n "ReactiveProperty Target\b"` is empty. - - operator: target a rock with the reticle, cycle through rocks and ships, and dock and undock with a rock - targeted. +- **Repo/branch:** Aetheria, `codex/mining`, after M-Soul. +- **Written for:** Q13 = A with the detection correction, Q14 = A, Q16 = B (recommended). ---- +#### How detection works today (read at `b66ba524`) -### Cut 4. A shot at a chunk goes through FireControl +1. **Emitters write into `Entity.VisibilitySources`** (`Entity.cs:63`, a `Dictionary`). Every + entry is one source's contribution: + - `Reflector.cs:46`: `CrossSection × Zone.GetLight(Position.xz)`. This is the rule the operator named. + - Black-body radiation: `Entity.cs:1269`. + - Weapons: `InstantWeapon.cs:257`, `ConstantWeapon.cs:166`. + - Thrusters: `Thruster.cs:110-111`. + - Sensor pings: `Sensor.cs:117`. + - `Visibility.cs:44` and `Radiator.cs:121`. -- **Repo/branch:** Aetheria, `codex/mining`. Depends on Cut 3 and fire-control 12.4. -- **Deletes first:** none. Reshape `PendingShot.Target` and `ShotOutcome.Target` (`FireControl.cs:860,997`) to `TargetRef`. -- **Adds:** `Silhouette.Disc(float radiusCells, float precision)`, which returns one interval `[-R, R)`, `A = 0`, - and `Span = 2R`. The hull path and the disc path share one integrator, extracted from `:710-718`. `ShotOutcome` - gains `float3 ImpactPoint` for presentation: the chunk position at commit. -- **Per-file changes** (re-anchor after 12.4): - - `PFire` `:161-181`: for a chunk, the visibility gate is range ≤ `ChunkDetectionRange`, `info = 1`, and a - `LockWeapon` is gated out. - - `Forecast`, `HitProbability`, `Inspect` (`:194-265`): for a chunk, the silhouette is the disc. - - `PredictedIntercept` `:133-139` and `TravelDirection`: position and velocity through the ref. - - `Fire` `:329-393`: freezes the ref. - - `Step` `:398-443`: `targetGone` includes `!ChunkExists`. - - `CommitProbability` and `Commit` (`:476-559`): for a chunk, the disc and no shield. - - `Apply` `:564-582`: a chunk hit calls `Zone.Wear(chunk, Damage)`. - - Unity presentation: `EntityInstance.cs:207,236` and the effect managers aim at the chunk's rendered position when - the target is a chunk. That is presentation only. -- **Authority map:** - - Owner: `FireControl`, unchanged. - - New inputs: the chunk pose, velocity and radius through `Zone`. - - Forbidden: any hit test against chunk geometry outside `Commit`, and any read of a chunk pose in `Apply` (R4). - - Shared: player trigger, AI `Activate`, turrets (which cannot hold chunks). - - Deletion line: none. -- **Verification:** - - tests: - - `AChunkShotUsesTheSameFactors`: Accuracy, PSpread and Sigma equal a ship shot's at equal range and precision. - - `DiscMassIsTheClosedForm`: `POnHull = Φ(R/σ) − Φ(−R/σ)`. - - `ABiggerRockIsEasierToHit`. - - `AChunkThatBreaksMidFlightIsAMiss`. - - `ChunkHitsWearTheChunk`. - - `LaunchersCannotFireOnAChunk`. - - `ChunkDiceAreTheShotsOwn`: the same galaxy and shot give the same outcome. - - Stryker over the new branches. - - negative: no `Target.Value.Position`-shaped chunk read outside `Zone.Chunk*` (grep). - - operator: shoot a rock with each damage type. It shrinks and breaks, and misses are plausible at range. + Sources decay each tick (`Entity.cs:1083-1087`, `VisibilityDecay` 0.5). +2. **`Entity.Visibility`** (`Entity.cs:136`) is the sum of the sources. +3. **Each `Sensor` behaviour on an observer** loops over every entity in the zone, every tick + (`Sensor.cs:157-189`). + - It adds a gain into `EntityInfoGathered[target]` (`Entity.cs:64`, a `ReactiveDictionary`). + - Passive gain is `target.Visibility × Sensitivity × SensitivityCurve(angle/π) × dt / dist` (`:179-184`). + - Ping gain is `target.Visibility × Sensitivity × PingBoost × dist`, once per ping (`:170-175`). + - It then decays: `next *= 1 − TargetInfoDecay × dt` (`:186`; `TargetInfoDecay` 0.5 in shipped + `Settings.asset`). +4. **Crossing `TargetDetectionInfoThreshold`** (0.1) adds the target to or removes it from `VisibleEntities`, via + the `ObserveReplace` subscriber at `Entity.cs:237-253`. +5. **Fire control reads both.** `PFire` gates on `VisibleEntities.Contains` (`FireControl.cs:173`) and prices + `PSensor` from the info value (`:179`). The same info also gates the armour and gear reveal thresholds + (0.5 and 0.8) through `IsRevealed`. +6. **Light** is `Zone.GetLight(float2)` (`Zone.cs:369` @`283ce7dc`). It sums `PowerPulse(d / LightRadius, 8)` + over every sun within its `LightRadius`. Beyond every sun's radius, light is 0. + +#### How a chunk plugs in, without becoming an entity + +Two things are needed: +- **A chunk's visibility**, `V(chunk, t)`: `cross-section(chunk) × GetLight(pose(chunk, t))`. This is + `Reflector.cs:46`'s rule, applied to a pose that is already a pure function of time (Cut 1). No stored source, + and no decay: a chunk has only the reflected term, because rocks carry no thrusters, weapons or heat + (black-body emission is out of scope, per the operator's correction). +- **An observer's info on a chunk.** The gain rule is the existing one. Q14 decides how it integrates over time. + Recommended: the closed-form equilibrium of the per-tick rule, evaluated on demand, with no per-tick state. + +**No adapter, and why that is the goal.** The adapter would be a pseudo-entity per chunk, or an `IDetectable` +wrapper, so that `Sensor.Execute`'s loop over `Zone.Entities` sees rocks. That is rejected option C of the Codex +Q2 in a costume: +- It puts every chunk into every sensor's per-tick loop. The map measured 184 ns per observed body per tick, + which is 149 µs per sensor-bearing ship for the 809-chunk probe zone. +- It puts chunks into `EntityInfoGathered`, whose `ObserveReplace` subscriber builds `VisibleEntities`, + `VisibleEnemies` and `VisibleFriendlies`. Every consumer of those collections would then have to learn that + some "entities" are rocks. + +The shared owner has to be **the rule, not the container**. The cut extracts the gain terms at `Sensor.cs:170-184` +into one static function, and both paths call it: +- the entity loop, per tick, unchanged in behaviour; +- the chunk query, on demand. + +Nothing about a chunk is stored per observer. + +#### Deletes first + +- The Codex scanner-as-sensor plan: no `ChunkDetectionRange`, no `UnaidedChunkRange`, and no scanner read + anywhere in the target path. +- The v1 placeholders: unconditional chunk visibility, and PSensor fixed at 1. +- Optional, Self's call: the parked `ResourceScanner` behaviour still runs a per-tick `Update` over three stats no + code reads (`ResourceScanner.cs:35-58` @`283ce7dc`). Delete the `Update` and the properties, and keep + `ResourceScannerData` authored. + +#### Adds + +- **`Sensor.Gain(...)`**, a static function extracted verbatim from `Sensor.cs:170-175` (ping) and `:179-184` + (passive). Inputs: target visibility, sensitivity, the curve at the angle, distance, dt, and whether this is a + ping. `Sensor.Execute` calls it. This is a pure extraction. +- **`FieldKindData`**, a new catalog document type (Q11: authored catalog records). Cut 3 introduces it with only + what detection needs: + - `Name` (`[CultName]`); + - `CrossSection` (float): reflectivity per schematic cell of chunk area; + - `GenerationWeight` (float), for Q16. + + Cut 5 adds the yield entries. Only one kind exists at first, "Asteroid". +- **The belt names its kind.** `AsteroidBeltData` gains a nullable `CultRecordRef Kind`, at the next + free key after `Asteroids` `Key(9)` (`ZoneData.cs:101-105` @`283ce7dc`). + - `ZoneGenerator` writes it at generation, through one function `FieldKinds.Assign(belt key, catalog)`: a + `StableHash` pick weighted by `GenerationWeight`. + - Under Q16 = B, a belt loaded without a kind is assigned by the same function on first load and written back. + One derivation serves both the generation path and the load path. +- **`Zone.ChunkVisibility(ChunkId)`** = `kind.CrossSection × π(ChunkRadius / SchematicCellSize)² × GetLight(ChunkPose.xy)`. + - Area in schematic cells keeps the numbers comparable to hull reflectors. The census has Longinus at 500-150 + over 66 cells and Djinni at 2500-500 over 166, so about 2-15 per cell. + - A worn chunk shrinks and dims. A broken chunk has radius 0, so it is invisible. +- **`Entity.ChunkInfo(ChunkId)`**: the info this observer has on the chunk, from its active `Sensor` behaviours + through `Sensor.Gain`, integrated per Q14. Visible means `ChunkInfo > TargetDetectionInfoThreshold`. +- **`TargetRef`**: a readonly struct holding `Entity Entity` and `ChunkId? Chunk`, with value equality and `IsNone`. + It lives beside `ChunkId` (`Zone.cs:527` @`283ce7dc`). +- **`Zone.ChunksNear(float2 position, float range, List into)`**: the one chunk query. + - Belts whose annulus cannot reach are skipped. + - The rest are evaluated through `ChunkPose` (`Zone.cs:285`). + - Broken chunks are skipped through `ChunkExists` (`:231`). + - It knows nothing about detection. Callers filter by `ChunkInfo`. +- **`Zone.ChunkVelocity(ChunkId)`**: analytic velocity plus the parent `Orbit.Velocity`. +- **`Entity.SetTarget(TargetRef)`**: the one writer. The player's handlers and the Cut 5 test agent both call it + (target invariant 3, "set by one writer path that both the player and the AI call"). Today every handler writes + `Target.Value` directly (`ActionGameManager.cs:377,385,395,403`), so there is no one writer to share. +- **Reach (Q13 = A):** cycling and any programmatic chunk pick use `ChunksNear(position, longest active weapon + Range.Max)`, then filter to chunks the observer can see. + +#### Per-file changes (`b66ba524` unless marked) + +- `Sensor.cs:170-184`: call `Sensor.Gain`. Behaviour must stay identical; the whole existing suite is the check. +- `Entity.cs:46`: `Target` becomes `ReactiveProperty`, written only through `SetTarget`. +- `Entity.cs:201`, `:227`: entity removal clears the slot only when it holds that entity. +- `Entity.cs:229`: `TargetedBy` fires for entities only. +- `Entity.cs:233`: `TargetedByCount` counts entity targets only. +- **`Entity.cs:298-299`:** today it clears a target missing from `EntityInfoGathered`, which would clear every + chunk target on the next pass. For a chunk, it clears when `ChunkInfo ≤ TargetDetectionInfoThreshold`, the same + "lost track" rule expressed through the chunk path. A broken chunk has 0 visibility, so it is lost the same way. +- `Entity.cs:317`: `TrySelectTargetItem` is entity-only. +- `Entity.cs:1078`: `TargetRange` reads the chunk position for a chunk. +- `ActionGameManager.cs:365-377` (reticle pick): pick the chunk under the reticle from `ChunksNear` at the + reticle, if visible. +- `ActionGameManager.cs:381-388` (`TargetNearest`): stays enemies-only. It drives lock. +- `ActionGameManager.cs:390-404` (next and previous): include visible chunks within Q13's reach. +- `ActionGameManager.cs:412-414` (Cycle Target Item): entity-only. +- `ActionGameManager.cs:582`, `:745`, `:769`, `:1046`, `:1224`, `:1239`, `:1266-1267`, `:1355-1373`: `.Entity` + reads, or the chunk position for the indicator. +- `ActionGameManager.cs:1285`: `UpdateFireControlDebug` takes `TargetRef`. +- `Weapon.cs:101` `StanceAllowsFire`: true for a chunk. `Weapon.cs:110` `ArcAllowsFire`: position through the ref. +- `LockWeapon.cs:86`: a chunk never locks (Q12 = A, ruled). +- `TurretController`, `Combat.cs`, `Minion.cs`, `Ship.cs`, `EntityInstance.cs`, `PropertiesPanel.cs`, + `FieldDriver.cs`, `ActionBarSlot.cs`: `.Entity` and null checks. + +#### Authority map + +- **Owners:** + - `Entity.Target` is the one slot, and `SetTarget` is its one writer. + - `Sensor.Gain` is the one detection gain rule. + - `FieldKindData` owns a chunk's reflectivity. + - `FieldKinds.Assign` owns which kind a belt is. +- **Inputs:** player handlers and the test agent; sensor stats; chunk pose and radius; sun light. +- **Outputs:** the current `TargetRef`; `ChunkVisibility`; `ChunkInfo`. +- **Derived state:** + - `ChunkVisibility` and `ChunkInfo` are pure functions of time and are never stored. + - `TargetItem` is entity-only. + - `TargetRange`. +- **Forbidden writers:** + - any second target field; + - any direct `Target.Value =` outside `SetTarget`; + - any chunk in `Zone.Entities` or `EntityInfoGathered`; + - any per-tick chunk detection loop; + - any chunk-specific copy of the gain arithmetic; + - any unconditional visibility for chunks. +- **Shared paths:** reticle, cycling, the test agent, dock and undock re-activation, and belt generation and load + (through `FieldKinds.Assign`). +- **Deletion line:** no `ReactiveProperty Target` remains; no `Target.Value =` outside `SetTarget`; no + gain arithmetic outside `Sensor.Gain`. + +#### Verification + +Yggdrasil, the M0 command with `--filter FullyQualifiedName~MiningCut3`, then the full suite. + +| Test | Rule it pins | +|---|---| +| `SensorGainExtractionChangesNothing` | The existing detection tests, and a recorded `EntityInfoGathered` trace over N ticks for a fixed scene, are byte-equal before and after the extraction. | +| `ChunkInfoMatchesAnEntityHeldStill` | Q14's parity. An entity with a `Reflector` of the same cross-section, at the chunk's position and held still, converges under the per-tick loop to the value `ChunkInfo` returns (tolerance from the discrete fixed point). **One rule, two integrators.** | +| `AChunkInDarknessIsNotVisible` | A chunk outside every sun's `LightRadius` has `ChunkVisibility` 0 and cannot be targeted or fired on. | +| `ABrighterOrBiggerChunkIsSeenFurther` | Monotone in cross-section, radius and light. | +| `AWornChunkDims`, `ABrokenChunkIsInvisible` | Visibility follows `ChunkRadius`. | +| `CyclingReachIsTheLongestActiveWeaponRange` | Q13. A visible chunk beyond it is not offered. | +| `AChunkTargetSurvivesTheInfoPassWhileSeen` | The negative for `Entity.cs:298-299`. It fails on the naive retype. | +| `AChunkTargetIsDroppedWhenItGoesDark` | The lost-track rule, through the chunk path. | +| `TargetRefEquality` | Value equality; a chunk never equals an entity. | +| `ChunkTargetIsNeverLocked` | Q12 = A. | +| `FieldKindAssignmentIsOneFunction` | Generation and first-load assignment give the same kind for the same belt key; the kind is written back once. | +| every existing fire-control and sensor test | Pass unchanged. This is the negative that ship detection and targeting did not move. | + +Also: +- Negative greps: `git grep -n "ReactiveProperty Target\b"` and `git grep -n "Target.Value ="` outside + `Entity.SetTarget` are empty. +- Stryker over `Sensor.Gain`, `Zone.ChunkVisibility`, `Entity.ChunkInfo`, `ChunksNear` and `TargetRef`. +- Census before and after: `AsteroidBeltData` is a run-store type and is absent from the catalog, and + `FieldKindData` shows one record once Cut 7's content lands. Until then, Cut 3 ships a test fixture kind and a + one-record catalog write through `AetherDb`, flagged for operator review. +- Unity compile: Self. +- Operator: + - target a lit rock with the reticle, and cycle through rocks and ships; + - fly into a belt far from the sun and confirm its rocks cannot be picked; + - dock and undock with a rock held. + +#### Subtraction estimate + +About 0 removed (optionally about 15 for the scanner `Update`). About 140 added: +- `Sensor.Gain` extraction nets about 0; +- `FieldKindData` plus assignment, about 40; +- `ChunkVisibility` and `ChunkInfo`, about 30; +- `TargetRef`, `ChunksNear`, `SetTarget`, about 70. + +Plus about 95 mechanical site edits. One new catalog document type (`FieldKindData`), and one nullable +run-store slot (`AsteroidBeltData.Kind`). + +### Cut 4. A shot at a chunk goes through FireControl, and blasts cover chunks (re-anchored to 12.4) + +- **Repo/branch:** `codex/mining`, after Cut 3. +- **Written for:** Q10 = A, Q12 = A (both ruled). +- **Lands after:** the fire-control no-target fuse ruling (`docs/fire-control-cut.md`, 12.4 status, "a fused + weapon fired with no target bursts at the shooter's own position", `FireControl.cs:389`). That ruling's fix + edits the same `target != null` branches this cut retypes (`:367`, `:372-378`, `:389`). If the ruling is still + open, this cut preserves today's behaviour for the no-target case and says so in its commit. + +**Deletes first:** none. `PendingShot.Target` (`:1468`) and `ShotOutcome.Target` (`:1598`) become `TargetRef`. + +**Adds:** +- **`Silhouette.Disc(float radiusCells, float precision)`.** The shared integrator is extracted from `Silhouette` + `:1302-1308` (the `Phi` sum over merged intervals, and `span`). The hull path and the disc path call one + function. No second `Phi` summation exists. +- **A chunk pass inside `Detonate`** (Q10 = A). After the entity pass (`:1084-1149`): + - `ChunksNear(worldPlanar, radius + max chunk radius)`. + - Each covered chunk takes `damage × CircleCircleOverlap(blast, chunk) / (π r_blast²)`, the same normaliser the + cell shares use (`:1092`). + - `CircleCircleOverlap` is the closed-form lens area, about 12 lines, beside `CircleSquareOverlap` (`:1153`). + - Each share goes to `Zone.Wear`. + - **One area rule:** a blast's disc is divided over whatever it covers, whether cells or chunks. Conservation + holds across both. +- **`Detonate` gains an optional loot sink**, the detonating shot, so the shooter receives the ore (Q10 = A). + - A sourceless blast passes null. None exists today, but a mine is one (F12-6). A null-sink blast wears chunks + and yields nothing. + - This is the only new parameter. `Detonate` still reads no host (12.4's rule). + +**Per-file changes** (`FireControl.cs` @`b66ba524`): +- `PFire` `:161-181`: + - For a chunk, the visibility gate (`:173`) is `ChunkInfo > TargetDetectionInfoThreshold`, and `info` (`:179`) + is `ChunkInfo` (Cut 3). **This replaces v1's "no gate, info 1".** + - `PSensor` prices a chunk exactly as it prices a ship at the same info. + - A `LockWeapon` is gated out (`:175`, Q12 = A). The arc stays (`:176`). +- `Forecast` `:206`, `HitProbability` `:228`, `Inspect` `:243-283`: the disc silhouette. `Inspect`'s `Visible` and + `Info` come from the chunk path. +- `PredictedIntercept` `:133`, `TravelDirection` `:307`: position and velocity through the ref. `Bearing` `:316` + is not called for a chunk, because a disc has no facing. +- `DeviationProbability` `:336-341`: the live chunk position is `Zone.ChunkPose` at `now`. +- `Fire` `:355-418`: freezes the ref. +- `Step` `:439`: `targetGone` includes `!ChunkExists`. +- `CommitProbability` `:506-520`: for a chunk, the disc (no `GetData(Hull)` at `:516`). +- `Commit` `:550-600`: for a chunk hit, no `Lane` (`:563-578`) and no shield block (`:585-592`). `Cell`, `Bearing` + and `Lateral` stay zero in `MakeOutcome` (`:1028`). +- `Apply` `:651-678`: + - Null fuse on a chunk: `Zone.Wear(chunk, Damage)` (`Zone.cs:260` @`283ce7dc`). + - Contact or delayed fuse on a chunk: no `ApplyBlastHit`, which needs a hull (`:747-760`). `Detonate` at the + chunk's centre at arrival. The pose is a pure function of time, so the point is exact. + - Proximity: `Detonate` as today, now with the chunk pass. + - Contact or delayed on a ship: `ApplyBlastHit` as today, and its `Detonate` now also covers nearby chunks. +- Unity presentation: `EntityInstance.cs` (4 target reads) and the four weapon managers aim at the chunk's + rendered position. Presentation only. + +**Authority map:** +- **Owner:** `FireControl`, unchanged. `Detonate` stays the one area owner. +- **New inputs:** chunk pose, velocity, radius and info, through `Zone` and `Entity.ChunkInfo`. +- **When blast damage to chunks is decided:** at detonation. Each covered chunk is judged where it is at arrival, + which is 12.4's own bystander rule (`ApplyBlastHit`'s comment at `:773-777`: "every bystander is judged against + where it actually is at arrival"). A direct hit on a chunk is still decided at `Commit`. +- **Forbidden writers:** + - any chunk hit test outside `Commit`, except `Detonate`'s area pass; + - any second area-share formula; + - any second `Phi` integrator; + - `Detonate` reading a host. +- **Shared paths:** player trigger, the Cut 5 test agent, AI `Activate` (`Combat.cs:121`), and turrets (which + never hold a chunk). Any blast from any source goes through `Detonate`. +- **Deletion line:** none. + +**Verification** (Yggdrasil, `--filter FullyQualifiedName~MiningCut4`, then the full suite): + +| Test | Rule it pins | +|---|---| +| `AChunkShotUsesTheSameFactors` | `Accuracy`, `PSpread`, `Sigma` and `PSensor` equal a ship shot's at equal range, precision and info. One fire path. | +| `AnUnseenChunkCannotBeFiredOn` | The gate is the detection rule, not a constant. | +| `DiscMassIsTheClosedForm` | `POnHull = Φ(R/σ) − Φ(−R/σ)`: the shared integrator. | +| `HullSilhouetteUnchangedByTheExtraction` | Every existing `Silhouette` test and the `ProbeC` equivalence still pass. The negative for the extraction. | +| `AChunkThatBreaksMidFlightIsAMiss` | `Step`'s gone rule. | +| `ChunkHitsWearTheChunk`, `AHitOnABrokenChunkChangesNothing` | Wear; re-pins `34994732` through the real path. | +| `ABlastConservesAcrossCellsAndChunks` | The shares delivered to cells plus chunks, plus the uncovered area, equal the damage. Same lattice style as 12.4's 1,944-disc conservation probe. | +| `ABlastBesideAnUntargetedRockWearsIt` | Q10 = A. | +| `CircleCircleOverlapIsTheLens` | Closed form, with the tangent, contained and disjoint cases (12.4's F1 tangent lesson). | +| `LaunchersCannotFireOnAChunk` | Q12 = A. | +| `ChunkDiceAreTheShotsOwn` | The same galaxy and shot id give the same outcome. | + +Also: Stryker over the new branches. Operator: shoot a lit rock with each damage type; it shrinks and breaks, +and misses at range look plausible. + +**Subtraction estimate:** about 5 removed (the integrator extraction) and about 115 added (the chunk pass and the +lens area add about 35 over v1). --- @@ -797,29 +1034,12 @@ and wear has one owner" (Cut 2). --- -### Cut 6. The AI mines on the same path +### Cut 6. Deleted (Q9 = C) -- **Repo/branch:** Aetheria, `codex/mining`, rebased onto locomotion Cut 4. Depends on Cut 5 and Q9. -- **Deletes first:** `Agents/Tasks/Mining.cs` is reshaped. It is not persisted, so its MessagePack attributes go. - It keeps `Belt` (`CultRecordKey`) and a wanted-ore reference. -- **Adds:** `MiningState` in `Minion` (`Minion.cs:7-22`), entered when `Task is Mining` and no enemy is targeted. - It picks a chunk via `Zone.ChunksNear` and writes `Ship.Target` (the same slot the player writes). It approaches - through the live `MoveToState` toward `ChunkPosition`. It selects the weapon group whose damage type has the - highest `Affinity` for the wanted ore, gates on `FireControl.HitProbability ≥ AgentMinHitProbability`, and calls - `weapon.Activate()`, which is exactly `Combat.cs:117-124`. It retargets when `!ChunkExists`, and stops when no - cargo bay can take a unit. The combat transition keeps priority: a visible enemy preempts mining - (`Minion.cs:14,18`). `Zone.CreateAgent` (`Zone.cs:122-129`) assigns `Mining` by Q9's rule. -- **Authority map:** - - Owner: `MiningState` decides what to shoot. - - Forbidden: any AI-only extraction call. The only way ore enters an AI hold is `FireControl.Apply`. - - Shared paths: the same target slot, `HitProbability`, `Activate`, `Commit` and `Apply` as the player. -- **Verification:** - - tests: - - `AnAgentMinesThroughFireControl`: a headless zone, an agent with a Mining task, N seconds of ticks, then ore in - its hold whose lots are `Extracted` from that belt, and every unit traceable to a `ShotOutcome`. - - `AnEnemyPreemptsMining`. - - `TheAgentPicksTheAffineGroup`. - - operator: watch an NPC mine a belt. +AI mining is deferred, and its same-path proof is the test agent in Cut 5. The original Cut 6 text (`MiningState`, the +`Mining` task, the locomotion Cut 4 dependency) is in git history at `22fb4021` and in +`docs/mining-cut-refresh.md`, Cut 6. The campaign that builds AI mining starts from `Zone.CreateAgent`, the `Minion` +transitions, `Entity.SetTarget` and `Combat.cs:121`. --- @@ -831,9 +1051,8 @@ and wear has one owner" (Cut 2). Hydrocarbons, Acid, Oxygen, Carbon Dioxide, Plant Matter), restored with their legacy mass, specific heat, conductivity and category **(probe values in `legacy-0414`)**. The 3 missing ammo types are out of scope. - `Abundance` and `Affinity` for each belt ore, over the four damage types that exist (Q8). - - The Drill Bit as a mining weapon (Q1), and the Resource Scanner as the chunk sensor (Q2), restored from legacy - with their authored ranges. - - `AsteroidYield`, `ChunkCompositionVariance`, `MiningDepthPerPenetration` and `UnaidedChunkRange` values. + - The Drill Bit as a mining weapon (Q1), restored from legacy with its authored ranges. + - `AsteroidYield`, `ChunkCompositionVariance`, and `MiningDepthPerPenetration` values. - The Corrosive and Ionizing gap, recorded (Q8). - **Verification:** `census` and `dangling` before and after. The loot-table fixture from Cut 5 runs against the live catalog, asserting that every belt ore is reachable by at least one live weapon's damage type. @@ -849,7 +1068,7 @@ and wear has one owner" (Cut 2). | 3 | ~0 | ~80, plus ~60 mechanical site edits | `Target` retyped | | 4 | ~0 | ~70 | `PendingShot`/`ShotOutcome` target retyped | | 5 | 2 (`BodyData.Resources`) | ~150 | `SimpleCommodityData` keys 12 and 13; `Extracted` key 2; `ItemInstance.Lot` moves to the base | -| 6 | ~15 (`Mining.cs` attributes) | ~90 | none | +| 6 | deleted (Q9 = C) | 0 | none | | 7 | 0 | content only | catalog records | Net code is roughly flat against about 240 lines deleted. Every addition buys a named capability: targetable chunks, From 39cec96a1ed9bfce68e0c4c68ef13265c932cae9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:14:55 +0200 Subject: [PATCH 18/45] Mining maps: Q14 A and Q16 B are operator rulings; Cut 3 unblocked Co-Authored-By: Claude Opus 5.5 --- docs/mining-cut-refresh.md | 9 ++++----- docs/mining-cut.md | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/mining-cut-refresh.md b/docs/mining-cut-refresh.md index c9901868e..54d3d9d81 100644 --- a/docs/mining-cut-refresh.md +++ b/docs/mining-cut-refresh.md @@ -33,7 +33,7 @@ proposal. The moddable-ships half of v1 lives in `docs/moddable-ships-cut.md` on `codex/moddable-ships`. Section 0's ship rows are kept for the combined test count. -## Rulings added 2026-09-30 (Self, after v2) +## Rulings added 2026-09-30 (Self, after v2; operator confirmed Q14 A and Q16 B the same day) - **Q14 A.** A ship's knowledge of a rock is the value the per-tick sensor rule would settle at, computed on demand through the shared `Sensor.Gain`. It is stateless and costs nothing per tick. @@ -262,14 +262,13 @@ The Codex numbering is kept so the map's history still reads, and Soul fix slots |---|---|---| | M0 | Placement: merge master, correct the map | fire-control-12 on master | | M-Soul | Fix batch from the parallel Soul pass over Cuts 1-2 (content unknown here) | Soul's report | -| 3 | A chunk is a target, detected by reflected light; the field-kind record appears | M0; **Q14**, **Q16** | +| 3 | A chunk is a target, detected by reflected light; the field-kind record appears | M0 (Q14 A, Q16 B ruled) | | 4 | A shot at a chunk goes through FireControl (re-anchored to 12.4); blasts cover chunks | 3; the fire-control no-target fuse ruling | | 5 | Composition, loot, the mined lot, and the same-path test agent | 4 | | ~~6~~ | **Deleted** (Q9 = C). The test agent moved into Cut 5. | — | | 7 | Content: field kinds, ores, the Drill Bit, and new Corrosive and Ionizing weapons | 5; variants C4 for families authored as variants | -Every mining question except Q14 and Q16 is ruled. Neither of those blocks Cut 4 or 5 if Cut 3 lands on the -recommendations. +Every mining question is ruled. ### M0. Placement and map correction @@ -315,7 +314,7 @@ recommendations. ### Cut 3. A chunk is a target, and is detected by reflected light - **Repo/branch:** Aetheria, `codex/mining`, after M-Soul. -- **Written for:** Q13 = A with the detection correction, Q14 = A, Q16 = B (recommended). +- **Written for:** Q13 = A with the detection correction, Q14 = A, Q16 = B (all ruled). #### How detection works today (read at `b66ba524`) diff --git a/docs/mining-cut.md b/docs/mining-cut.md index 49cab4e1e..d9f601a28 100644 --- a/docs/mining-cut.md +++ b/docs/mining-cut.md @@ -501,9 +501,8 @@ Rulings (operator), 2026-09-30: | Q13 | A, reach is the longest active weapon range | | Q2 correction | Chunks are detected by the existing reflectance rule (reflectivity and cross-section, lit by the suns), not deferred to EW. Struck: the scanner as chunk sensor, the fallback range tunable, unconditional visibility, PSensor 1. `ResourceScanner` stays parked | -**Q14 (how a ship's info on a rock integrates over time) and Q16 (how a belt gets its field kind) remain open.** They -block Cut 3. Self's recommendations (Q14 A, settled value on demand; Q16 B, stored at generation and assigned once for -old belts) are proposals, not operator rulings; Cut 3 is written for them. +**Q14 A and Q16 B are operator rulings (confirmed 2026-09-30: "Q14. A (thought I ruled on that previously). Q16. +B.").** Cut 3 is written for them and is unblocked. **M-Soul content unknown:** no Soul report for Cuts 1-2 exists; the mutation sweep at `22fb4021` stands in. Cut 3 does not start until M-Soul is closed or empty. @@ -556,7 +555,7 @@ Follow-ups outside this campaign: | 2 | Subtraction plus one owner: the dead mining path goes, and chunk identity and wear get one owner | Q1, Q3 | | M0 | Placement: merge master, correct the map | fire-control-12 on master | | M-Soul | Fix slot for the parallel Soul pass over Cuts 1-2 (content unknown) | Soul report | -| 3 | A chunk is a target, detected by reflected light; the field-kind record appears | M0, M-Soul; Q14, Q16 | +| 3 | A chunk is a target, detected by reflected light; the field-kind record appears | M0, M-Soul (Q14 A, Q16 B ruled) | | 4 | A shot at a chunk goes through FireControl; blasts cover chunks | 3; the fire-control no-target fuse ruling | | 5 | Composition, loot roll, provenance, deposit | 4; Q4-Q7 | | ~~6~~ | **Deleted** (Q9 = C); the test agent moved into Cut 5 | none | @@ -690,7 +689,7 @@ and wear has one owner" (Cut 2). ### Cut 3. A chunk is a target, and is detected by reflected light - **Repo/branch:** Aetheria, `codex/mining`, after M-Soul. -- **Written for:** Q13 = A with the detection correction, Q14 = A, Q16 = B (recommended). +- **Written for:** Q13 = A with the detection correction, Q14 = A, Q16 = B (all ruled). #### How detection works today (read at `b66ba524`) From cb4a1dbd8aba5e16f4743ffaf66e7f74801e7070 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:30:07 +0200 Subject: [PATCH 19/45] Mining Cut 3: record the per-tick detection trace before Sensor.Gain is extracted SensorGainExtractionChangesNothing drives a fixed scene (two reflecting ships at different ranges and bearings, a ping part way through) and pins the observer's EntityInfoGathered trace, bit for bit, by hash. The golden value is recorded against the pre-extraction Sensor.Execute. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/MiningCut3Tests.cs diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs new file mode 100644 index 000000000..d90f983fa --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -0,0 +1,205 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Cut 3 (docs/mining-cut-refresh.md): a chunk is a target, detected by reflected light. One detection gain rule +// (Sensor.Gain) serves both the per-tick entity loop and the on-demand chunk query, and the chunk query returns +// the value the per-tick rule settles at (Q14 A). A belt's field kind is chosen once and saved (Q16 B). +public sealed class MiningCut3Tests : IDisposable +{ + private const float Dt = .05f; + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut3-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + private int _fixtureCount; + + public MiningCut3Tests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static GameplaySettings GameSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + // Settings.asset's own values for the three detection tunables. + VisibilityDecay = .5f, + TargetInfoDecay = .5f, + TargetDetectionInfoThreshold = .1f, + SchematicCellSize = 2f + }; + + // Flat terrain (no zone depth, no gravity wells), one sun whose light reaches 1000 units, and the chunk + // curves MiningCut2Tests uses, so sizes and wear vary over the fixture's range. + private static PlanetSettings PlanetSettings() => new PlanetSettings + { + OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, + AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, + AsteroidHitpoints = new ExponentialLerp { Minimum = 20f, Maximum = 220f, Exponent = 1.6f }, + AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f }, + BodyRadius = new ExponentialCurve(), + GravityRadius = new ExponentialCurve(), + GravityDepth = new ExponentialCurve(), + WaveDepth = new ExponentialCurve(), + WaveRadius = new ExponentialCurve(), + WaveSpeed = new ExponentialCurve(), + WaveFrequency = new ExponentialCurve(), + LightRadius = new ExponentialCurve { Constant = 1000f } + }; + + // A sensitivity that falls off away from the sensor's facing, so the angle term is exercised, not a constant. + private static BezierCurve Falloff() => new BezierCurve + { + Keys = new[] { new float4(0, 1, 0, 0), new float4(1, .3f, 0, 0) } + }; + + private sealed class Scene + { + public CultCache Cache; + public ItemManager Items; + public Zone Zone; + public HullData Hull; + public GearData Eye; + } + + private Scene BuildScene() + { + var dir = Path.Combine(_root, $"scene{_fixtureCount++}"); + Directory.CreateDirectory(dir); + var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hull = new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }; + cache.Upsert(hull); + var eye = Gear("Eye", new SensorData + { + Sensitivity = Constant(3f), + SensitivityCurve = Falloff(), + PingBoost = Constant(.02f), + PingEnergy = Constant(0f), + PingVisibility = Constant(5f), + PingRange = Constant(300f), + PingCooldown = Constant(4f) + }); + cache.Upsert(eye); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + + var sunOrbit = cache.Upsert(new OrbitData()).Key; + var sun = cache.Upsert(new SunData { Orbit = new CultRecordRef(sunOrbit) }).Key; + var pack = new ZonePack + { + Orbits = { new CultRecordRef(sunOrbit) }, + Planets = { new CultRecordRef(sun) }, + Radius = 5000f, + Mass = 10000f, + Time = 0 + }; + var zone = new Zone(items, PlanetSettings(), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null); + return new Scene { Cache = cache, Items = items, Zone = zone, Hull = hull, Eye = eye }; + } + + private static GearData Gear(string name, BehaviorData behavior) => new GearData + { + Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { behavior } + }; + + private static EquippableItem Mint(Scene s, ItemData design) => new EquippableItem + { + Data = s.Cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = s.Items.Lots.Add(new Lot { Design = s.Cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + // A ship held at a planar position, facing +z. `sensor` fits the scene's sensor; `crossSection` fits a + // reflector of that cross-section (its own design, so ships in one scene can differ). + private Ship SpawnShip(Scene s, float2 at, bool sensor = false, float crossSection = 0f) + { + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + if (sensor) Assert.True(ship.TryEquip(Mint(s, s.Eye))); + if (crossSection > 0f) + { + var mirror = Gear($"Mirror {crossSection}", new ReflectorData { CrossSection = Constant(crossSection) }); + s.Cache.Upsert(mirror); + Assert.True(ship.TryEquip(Mint(s, mirror))); + } + s.Zone.Entities.Add(ship); + Hold(ship, at); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + return ship; + } + + private static void Hold(Entity entity, float2 at) + { + entity.Position = float3(at.x, entity.Position.y, at.y); + entity.Velocity = float2.zero; + entity.Direction = float2(0, 1); + } + + // One simulation tick for the given entities, each held in place, in order. Zone time does not advance, so + // chunk poses and sun positions stay where they are. + private static void Tick(params (Entity entity, float2 at)[] held) + { + foreach (var (entity, at) in held) + { + Hold(entity, at); + entity.Update(Dt); + } + } + + private static uint Bits(float value) => BitConverter.ToUInt32(BitConverter.GetBytes(value), 0); + + // The per-tick entity detection loop is unchanged by the extraction of its gain rule: a fixed scene (two + // reflecting ships at different ranges and bearings, a ping part way through) yields the same + // EntityInfoGathered trace, bit for bit, as it did before Sensor.Gain existed. The golden hash was recorded + // against the pre-extraction Sensor.Execute. + private const string Golden = "6C23DF2584646E976A11CB0B8013A18499F5DCCABD86C7A840F201899984D737"; + + [Fact] + public void SensorGainExtractionChangesNothing() + { + var s = BuildScene(); + var eyeAt = float2(0, 50); + var nearAt = float2(30, 120); + var farAt = float2(-200, 90); + var observer = SpawnShip(s, eyeAt, sensor: true); + var near = SpawnShip(s, nearAt, crossSection: 40f); + var far = SpawnShip(s, farAt, crossSection: 400f); + + var trace = new List(); + for (var tick = 0; tick < 60; tick++) + { + if (tick == 5) observer.Sensor.Ping(); + Tick((near, nearAt), (far, farAt), (observer, eyeAt)); + trace.AddRange(BitConverter.GetBytes(Bits(observer.EntityInfoGathered[near]))); + trace.AddRange(BitConverter.GetBytes(Bits(observer.EntityInfoGathered[far]))); + } + + // Not a degenerate scene: both targets gathered some info, neither saturated. + Assert.InRange(observer.EntityInfoGathered[near], .01f, .99f); + Assert.InRange(observer.EntityInfoGathered[far], .01f, .99f); + var hash = Convert.ToHexString(SHA256.HashData(trace.ToArray())); + Assert.True(hash == Golden, $"the EntityInfoGathered trace changed: hash {hash}"); + } +} From a1d27cfc04114980a8b16740b5670390b02ffede Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:56:13 +0200 Subject: [PATCH 20/45] Mining Cut 3: extract the detection gain rule into Sensor.Gain Sensor.Execute's ping and passive gain arithmetic moves, term for term and in the same multiplication order, into one static function, Sensor.Gain. The entity loop calls it; Sensor.PassiveRate calls it for the on-demand chunk query that follows. SensorGainExtractionChangesNothing holds the recorded EntityInfoGathered trace bit for bit. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/Behaviors/Sensor.cs | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 228657b5d..7619e90fd 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -153,35 +153,24 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer _pingCooldown -= dt / Evaluate(_data.PingCooldown); - // TODO: Handle Active Detection / Visibility From Reflected Radiance var forward = Direction.xz; foreach (var entity in Entity.Zone.Entities) { if (entity == Entity) continue; - + var diff = entity.Position.xz - Entity.Position.xz; - var angle = acos(dot(forward, normalize(diff))); var dist = length(diff); float previous, next; Entity.EntityInfoGathered.TryGetValue(entity, out previous); if (!_pingedEntities.Contains(entity) && dist < _pingRadius) { _pingedEntities.Add(entity); - next = saturate( - previous + - entity.Visibility * - Evaluate(_data.Sensitivity) * - Evaluate(_data.PingBoost) * - dist); + next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true)); } else { - next = saturate( - previous + - entity.Visibility * - Evaluate(_data.Sensitivity) * - _data.SensitivityCurve.Evaluate(angle / PI) * - dt / dist); + var angle = acos(dot(forward, normalize(diff))); + next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false)); } next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt; //Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%"); @@ -189,4 +178,23 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer } return true; } + + // Mining Cut 3 (docs/mining-cut-refresh.md): the one detection gain rule -- the info one observation of a + // target adds. A ping adds `visibility × sensitivity × PingBoost × distance` once; a passive tick adds + // `visibility × sensitivity × curve(angle/π) × dt / distance`. `response` is PingBoost for a ping and the + // sensitivity curve at the bearing for a passive tick. The entity loop above and the chunk query + // (Entity.ChunkInfo, through PassiveRate) both call this; nothing else multiplies these terms. + public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping) => + ping + ? visibility * sensitivity * response * distance + : visibility * sensitivity * response * dt / distance; + + // The passive gain per second this sensor draws from a target of `visibility` at the planar `position`: + // Gain over one second, with the bearing measured from this sensor's own facing exactly as Execute does. + public float PassiveRate(float visibility, float2 position) + { + var diff = position - Entity.Position.xz; + var angle = acos(dot(Direction.xz, normalize(diff))); + return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false); + } } \ No newline at end of file From d76756fd6802217e0566f6c92658a1d4acad7737 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:56:24 +0200 Subject: [PATCH 21/45] Mining Cut 3: field kinds, chunk visibility, the chunk query, and TargetRef - FieldKindData, a catalog document (Q11): a kind's reflectivity per schematic cell and its generation weight. AetheriaStores routes it to the catalog. - AsteroidBeltData.Kind, key 10 (Q16 B). FieldKinds.Assign is the one pick (a weighted choice by a stable hash of the belt's key); FieldKinds.Ensure gives a kindless belt its kind and saves it. ZoneGenerator calls it at generation and Zone calls it on first load, so an old belt is assigned once by the same rule and keeps its kind after later catalog edits. - Zone.ChunkVisibility: Reflector's rule for a chunk -- kind cross-section x area in schematic cells x the light on it. Stored nowhere; a worn chunk dims, a broken one or a kindless belt's is dark. - Zone.ChunksNear: the one chunk query, skipping belts whose annulus cannot reach. AsteroidBelt gains InnerRadius for that skip. - TargetRef: an entity, a chunk, or none, with value equality and no ==. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 2 +- Assets/Scripts/ServerShared/Zone.cs | 68 +++++++++++++++++++ Assets/Scripts/ServerShared/ZoneData.cs | 59 ++++++++++++++++ Assets/Scripts/ServerShared/ZoneGenerator.cs | 5 +- 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index bee8a4c9f..86e76163a 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -6,7 +6,7 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 418a55f79..9b9e409e7 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -101,6 +101,9 @@ public class Zone switch (planet) { case AsteroidBeltData belt: + // Mining Cut 3 (Q16 B): a belt from before field kinds existed gets its kind here, once, by + // the rule generation uses, and keeps it. + FieldKinds.Ensure(body.Key, belt, cache); AsteroidBelts[body.Key] = new AsteroidBelt(belt); break; case SunData sun: @@ -305,6 +308,37 @@ public class Zone return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings, size); } + // Mining Cut 3 (docs/mining-cut-refresh.md): how bright a chunk is -- Reflector's rule (cross-section × the light + // on it), with the field kind's reflectivity per schematic cell times the chunk's area in cells. Computed when + // asked; nothing about it is stored. A worn chunk shrinks and dims; a broken one, or one of a kindless belt, is dark. + public float ChunkVisibility(ChunkId chunk) + { + if (!ChunkExists(chunk)) return 0f; + var kind = _itemManager.ItemData.Get(((AsteroidBeltData) Planets[chunk.Field]).Kind); + if (kind == null) return 0f; + var cells = ChunkRadius(chunk) / _itemManager.GameplaySettings.SchematicCellSize; + return kind.CrossSection * PI * cells * cells * GetLight(ChunkPose(chunk.Field, chunk.Index).xy); + } + + // Mining Cut 3: the one chunk query -- every chunk that exists and whose centre lies within `range` of + // `position`, written into `into` (cleared first). Belts whose annulus cannot reach are skipped whole. It knows + // nothing about detection; callers filter by what an observer can see. + public void ChunksNear(float2 position, float range, List into) + { + into.Clear(); + foreach (var belt in AsteroidBelts) + { + var centre = GetOrbitPosition(Orbits[belt.Value.Data.Orbit.Key].Data.Parent.Key); + var fromCentre = length(position - centre); + if (fromCentre - range > belt.Value.Radius || fromCentre + range < belt.Value.InnerRadius) continue; + for (var i = 0; i < belt.Value.Data.Asteroids.Length; i++) + { + var chunk = new ChunkId(belt.Key, i); + if (ChunkExists(chunk) && length(ChunkPose(belt.Key, i).xy - position) <= range) into.Add(chunk); + } + } + } + public void EvaluateBelt(CultRecordKey belt, Span into) { var beltData = Planets[belt] as AsteroidBeltData; @@ -503,11 +537,14 @@ public class AsteroidBelt { public AsteroidBeltData Data; public float Radius { get; } + // Mining Cut 3: the belt's inner edge, so the chunk query can skip a belt whose annulus it cannot reach. + public float InnerRadius { get; } public AsteroidBelt(AsteroidBeltData data) { Data = data; Radius = data.Asteroids.Max(a => a.Distance); + InnerRadius = data.Asteroids.Min(a => a.Distance); } // Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used. @@ -555,6 +592,37 @@ public readonly struct ChunkId : IEquatable public override int GetHashCode() => (Field.GetHashCode() * 397) ^ Index; } +// Mining Cut 3 (docs/mining-cut-refresh.md, Q2 A): what an entity targets -- another entity, a chunk, or nothing. +// One slot holds either kind (Entity.Target, written only by Entity.SetTarget). A chunk never equals an entity. +// There is deliberately no == operator: a comparison against null or an entity must say which side it means. +public readonly struct TargetRef : IEquatable +{ + public readonly Entity Entity; + public readonly ChunkId? Chunk; + + public TargetRef(Entity entity) + { + Entity = entity; + Chunk = null; + } + + public TargetRef(ChunkId chunk) + { + Entity = null; + Chunk = chunk; + } + + public static TargetRef None => default; + public bool IsNone => Entity == null && !Chunk.HasValue; + + public static implicit operator TargetRef(Entity entity) => new TargetRef(entity); + public static implicit operator TargetRef(ChunkId chunk) => new TargetRef(chunk); + + public bool Equals(TargetRef other) => ReferenceEquals(Entity, other.Entity) && Nullable.Equals(Chunk, other.Chunk); + public override bool Equals(object obj) => obj is TargetRef other && Equals(other); + public override int GetHashCode() => Chunk.HasValue ? Chunk.Value.GetHashCode() : Entity?.GetHashCode() ?? 0; +} + // Cut 2: wear on one chunk. BrokenUntil is the absolute zone time the chunk respawns at; null means the chunk // carries damage (or none) but is not currently broken. Respawn is read lazily by comparing to zone time -- // nothing decrements or clears this on a timer. diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 7b3515841..2216355d6 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -5,6 +5,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using GameCult.Caching; using MessagePack; using Newtonsoft.Json; @@ -102,6 +103,64 @@ public class AsteroidBeltData : BodyData { [JsonProperty("asteroids"), Key(9)] public Asteroid[] Asteroids; + + // Mining Cut 3 (docs/mining-cut-refresh.md, Q16 B): the belt's field kind, chosen once and saved. Unset on a + // belt from before this key existed (MessagePack leaves a missing trailing key at its default); Zone assigns + // it on first load through FieldKinds.Ensure, the same function generation uses. + [JsonProperty("kind"), Key(10)] + public CultRecordRef Kind; +} + +// Mining Cut 3 (docs/mining-cut-refresh.md, Q11): a kind of debris field, an authored catalog record. A chunk's +// reflectivity comes from its field's kind; Cut 5 adds the yield table. +[CultDocument("aetheria.fieldkinddata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class FieldKindData +{ + [CultName, JsonProperty("name"), Key(0)] + public string Name = ""; + + // Reflectivity per schematic cell of chunk area: a chunk reflects CrossSection × its area in cells × the light + // on it (Zone.ChunkVisibility), the rule Reflector applies to a hull. + [JsonProperty("crossSection"), Key(1)] + public float CrossSection; + + // How often generation picks this kind, relative to the others (FieldKinds.Assign). Zero never picks it. + [JsonProperty("generationWeight"), Key(2)] + public float GenerationWeight = 1; +} + +// Mining Cut 3 (Q16 B): the one owner of which kind a belt is. +public static class FieldKinds +{ + // A weighted pick among the catalog's kinds, by a stable hash of the belt's key: the same belt and the same + // catalog always give the same kind. Kinds are ordered by key so the pick does not depend on store order. + // Unset when the catalog has no kind with positive weight. + public static CultRecordRef Assign(CultRecordKey field, CultCache catalog) + { + var kinds = catalog.GetAll() + .Where(kind => kind.GenerationWeight > 0f) + .Select(kind => (Key: catalog.RefOf(kind).Key, Weight: kind.GenerationWeight)) + .OrderBy(kind => kind.Key.Value, StringComparer.Ordinal) + .ToArray(); + if (kinds.Length == 0) return default; + + var pick = field.Value.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight); + foreach (var kind in kinds) + { + if (pick < kind.Weight) return new CultRecordRef(kind.Key); + pick -= kind.Weight; + } + return new CultRecordRef(kinds[kinds.Length - 1].Key); + } + + // Gives a belt with no kind its kind and saves it; a belt that has one keeps it, whatever the catalog now says. + // Generation (ZoneGenerator) and first load (Zone) both call this, so there is one assignment rule. + public static void Ensure(CultRecordKey field, AsteroidBeltData belt, CultCache cache) + { + if (belt.Kind.IsSet()) return; + belt.Kind = Assign(field, cache); + if (belt.Kind.IsSet()) cache.Upsert(belt); + } } [CultDocument("aetheria.gasgiantdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 2144f718c..e3b7ae340 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -197,7 +197,10 @@ public static class ZoneGenerator } // A body is named for the first eight characters of the key the cache mints for it - planetData.Name = cache.Upsert(planetData).Key.Value.Substring(0, 8); + var bodyKey = cache.Upsert(planetData).Key; + planetData.Name = bodyKey.Value.Substring(0, 8); + // Mining Cut 3 (Q16 B): a belt's field kind is chosen here, at generation, and saved with it. + if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure(bodyKey, generatedBelt, cache); return cache.Upsert(planetData); }).ToList(); From 15723f403712a078c3708d273e93a59f7c76177d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:56:39 +0200 Subject: [PATCH 22/45] Mining Cut 3: a chunk is a target, detected by reflected light - Entity.Target is one slot of TargetRef, read-only from outside (IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is taken only while the entity can see it, so a rock in darkness cannot be picked. Every handler, agent, turret and test writes through SetTarget. - Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at, computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1. Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection threshold through the chunk path. - The lost-track rule reads through the chunk path: Activate's reconciliation and a per-tick check of the one slot drop a chunk target that has gone dark. - Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range among active weapons -- the one reach rule for cycling and programmatic picks. - Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items, the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk), turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4). TargetRange and the arc gate read a chunk's planar position. - ActionGameManager: the reticle and next/previous offer visible ships and visible chunks in reach; nearest stays enemies-only; the target panel, indicator and fire-control debug take TargetRef. EntityInstance reads .Entity. - MiningCut3Tests pin parity, darkness, monotone brightness, wear and break, reach, the chunk query, target survival and loss, TargetRef equality, lock, and field-kind assignment at generation and first load. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 101 ++-- Assets/Scripts/Gameplay/EntityInstance.cs | 4 +- Assets/Scripts/ServerShared/Agents/Minion.cs | 6 +- .../ServerShared/Agents/States/Combat.cs | 2 +- .../ServerShared/Behaviors/LockWeapon.cs | 13 +- .../Behaviors/TurretController.cs | 16 +- .../Scripts/ServerShared/Behaviors/Weapon.cs | 5 +- Assets/Scripts/ServerShared/Entity.cs | 85 +++- Assets/Scripts/ServerShared/FireControl.cs | 17 +- Assets/Scripts/ServerShared/Ship.cs | 2 +- .../FireAuthorityTests.cs | 2 +- .../FireControlCut11Tests.cs | 2 +- .../FireControlCut123Tests.cs | 2 +- .../FireControlCut124Tests.cs | 40 +- .../FireControlCut12Tests.cs | 4 +- .../FireControlCut4Tests.cs | 2 +- .../FireControlCut5Tests.cs | 12 +- .../FireControlCut6Tests.cs | 2 +- .../FireControlCut6cTests.cs | 2 +- .../FireControlCut6dTests.cs | 2 +- .../FireControlCut7Tests.cs | 2 +- .../FireControlCut8Tests.cs | 8 +- .../FireControlCut9Tests.cs | 6 +- .../FireControlPerWeaponTests.cs | 22 +- .../Aetheria.Shared.Tests/FireControlTests.cs | 2 +- .../IffAndCombatTests.cs | 6 +- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 471 +++++++++++++++++- .../RestoredHullsTests.cs | 10 +- .../TargetingSystemTests.cs | 6 +- 29 files changed, 712 insertions(+), 142 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index c62d980e4..adc695371 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -362,19 +362,22 @@ public class ActionGameManager : MonoBehaviour // reads as non-hostile here, so the first press declares hostile. Input.Player.ToggleStance.performed += context => { - var target = CurrentEntity.Target.Value; + var target = CurrentEntity.Target.Value.Entity; if (target == null) return; CurrentEntity.SetIff(target, !CurrentEntity.IsHostileTo(target)); }; #region Targeting + // Mining Cut 3 (docs/mining-cut-refresh.md): the reticle and cycling offer visible ships and the visible + // chunks within reach (Entity.VisibleChunksInReach, Q13), through the one writer, Entity.SetTarget. Input.Player.TargetReticle.performed += context => { - if (!CurrentEntity.VisibleEntities.Any()) return; - var underReticle = CurrentEntity.VisibleEntities.Where(x => x != CurrentEntity) - .MaxBy(x => dot(normalize(x.Position - CurrentEntity.Position), CurrentEntity.LookDirection)); - CurrentEntity.Target.Value = CurrentEntity.Target.Value == underReticle ? null : underReticle; + var candidates = TargetCandidates(); + if (candidates.Length == 0) return; + var underReticle = candidates + .MaxBy(x => dot(normalize(x.position - CurrentEntity.Position), CurrentEntity.LookDirection)).target; + CurrentEntity.SetTarget(CurrentEntity.Target.Value.Equals(underReticle) ? TargetRef.None : underReticle); }; // Nearest is enemies-only (it drives weapon lock), and picks the closest target, not the farthest. @@ -382,33 +385,33 @@ public class ActionGameManager : MonoBehaviour { if(CurrentEntity.VisibleEnemies.Any()) { - CurrentEntity.Target.Value = CurrentEntity.VisibleEnemies.Where(x => x != CurrentEntity) - .MinBy(x => length(x.Position - CurrentEntity.Position)); + CurrentEntity.SetTarget(CurrentEntity.VisibleEnemies.Where(x => x != CurrentEntity) + .MinBy(x => length(x.Position - CurrentEntity.Position))); } }; Input.Player.TargetNext.performed += context => { - if (!CurrentEntity.VisibleEntities.Any()) return; - var targets = CurrentEntity.VisibleEntities.Where(x => x != CurrentEntity).OrderBy(x => length(x.Position - CurrentEntity.Position)).ToArray(); + var targets = TargetCandidates().OrderBy(x => length(x.position - CurrentEntity.Position)).Select(x => x.target).ToArray(); + if (targets.Length == 0) return; var currentTargetIndex = Array.IndexOf(targets, CurrentEntity.Target.Value); - CurrentEntity.Target.Value = targets[(currentTargetIndex + 1) % targets.Length]; + CurrentEntity.SetTarget(targets[(currentTargetIndex + 1) % targets.Length]); }; Input.Player.TargetPrevious.performed += context => { - if (!CurrentEntity.VisibleEntities.Any()) return; - var targets = CurrentEntity.VisibleEntities.Where(x => x != CurrentEntity).OrderBy(x => length(x.Position - CurrentEntity.Position)).ToArray(); + var targets = TargetCandidates().OrderBy(x => length(x.position - CurrentEntity.Position)).Select(x => x.target).ToArray(); + if (targets.Length == 0) return; var currentTargetIndex = Array.IndexOf(targets, CurrentEntity.Target.Value); - CurrentEntity.Target.Value = targets[(currentTargetIndex + targets.Length - 1) % targets.Length]; + CurrentEntity.SetTarget(targets[(currentTargetIndex + targets.Length - 1) % targets.Length]); }; // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's revealed // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), same - // predicate (FireControl.IsRevealed) the AI path uses. + // predicate (FireControl.IsRevealed) the AI path uses. Entity-only: a chunk has no subsystems. Input.Player.CycleTargetItem.performed += context => { - var target = CurrentEntity.Target.Value; + var target = CurrentEntity.Target.Value.Entity; if (target == null) return; var revealed = target.Equipment.Where(x => FireControl.IsRevealed(CurrentEntity, x)).ToArray(); if (revealed.Length == 0) @@ -595,7 +598,7 @@ public class ActionGameManager : MonoBehaviour ConsoleController.AddCommand("iff", args => { var console = ConsoleController.Instance; - var target = _currentEntity?.Target.Value; + var target = _currentEntity?.Target.Value.Entity; if (target == null) { console.AppendLogLine("iff: no target selected"); return; } var mode = args.Length > 0 ? args[0] : ""; if (mode != "hostile" && mode != "neutral" && mode != "clear") @@ -1076,9 +1079,10 @@ public class ActionGameManager : MonoBehaviour _targetSubscriptions.Clear(); UpdateTargetPanel(target); - if (target != null) + var targetEntity = target.Entity; + if (targetEntity != null) { - if (target.Shield != null) + if (targetEntity.Shield != null) { TargetShieldsBackground.color = new Color(ShieldColor.r, ShieldColor.g, ShieldColor.b, .4f); TargetShieldsIcon.color = ShieldColor; @@ -1092,7 +1096,7 @@ public class ActionGameManager : MonoBehaviour } // Subscribe to incoming hits from the player ship to display the hit marker - _targetSubscriptions.Add(target.IncomingHit.Where(e => e == CurrentEntity).Subscribe(_ => + _targetSubscriptions.Add(targetEntity.IncomingHit.Where(e => e == CurrentEntity).Subscribe(_ => { HitMarker.SetActive(true); _hitMarkerTime = HitMarkerDuration; @@ -1152,17 +1156,37 @@ public class ActionGameManager : MonoBehaviour SchematicDisplay.ShowShip(CurrentEntity); } - private void UpdateTargetPanel(Entity target) + // Mining Cut 3: any target gets the indicator; only a ship target gets the ship panel. + private void UpdateTargetPanel(TargetRef target) { - TargetIndicator.gameObject.SetActive(target != null); - TargetShipPanel.gameObject.SetActive(target != null); - if (target != null) + TargetIndicator.gameObject.SetActive(!target.IsNone); + TargetShipPanel.gameObject.SetActive(target.Entity != null); + if (target.Entity != null) { - TargetShipPanel.Display(target, true); - TargetSchematicDisplay.ShowShip(target, CurrentEntity); + TargetShipPanel.Display(target.Entity, true); + TargetSchematicDisplay.ShowShip(target.Entity, CurrentEntity); } } + // Mining Cut 3: what the reticle and cycling can pick -- visible ships, and visible chunks within reach -- each + // with a world position to measure bearing and distance against. + private readonly List _chunksInReach = new List(); + private (TargetRef target, float3 position)[] TargetCandidates() + { + CurrentEntity.VisibleChunksInReach(_chunksInReach); + return CurrentEntity.VisibleEntities.Where(x => x != CurrentEntity) + .Select(x => (target: (TargetRef) x, position: x.Position)) + .Concat(_chunksInReach.Select(chunk => (target: (TargetRef) chunk, position: ChunkWorldPosition(chunk)))) + .ToArray(); + } + + // A chunk's simulated position is planar (Zone.ChunkPose); the renderer draws it at the asteroid layer's height. + private float3 ChunkWorldPosition(ChunkId chunk) + { + var pose = CurrentEntity.Zone.ChunkPose(chunk.Field, chunk.Index); + return float3(pose.x, Settings.PlanetSettings.AsteroidVerticalOffset, pose.y); + } + private void Die(CauseOfDeath cause) { var deathTime = Time.time; @@ -1246,7 +1270,7 @@ public class ActionGameManager : MonoBehaviour { foreach (var indicator in _visibleHostileIndicators) { - indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value); + indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity); indicator.Value.Place.Target = indicator.Key.Position.ToUnity(); if (!indicator.Key.Active) indicator.Value.Fill.enabled = false; @@ -1261,7 +1285,7 @@ public class ActionGameManager : MonoBehaviour } foreach (var indicator in _visibleFriendlyIndicators) { - indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value); + indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity); indicator.Value.Place.Target = indicator.Key.Position.ToUnity(); if (!indicator.Key.Active) indicator.Value.Fill.enabled = false; @@ -1288,8 +1312,8 @@ public class ActionGameManager : MonoBehaviour ship.MovementDirection = Input.Player.Move.ReadValue().ToCultMath(); } - var target = CurrentEntity.Target.Value; - UpdateFireControlDebug(target); + UpdateFireControlDebug(CurrentEntity.Target.Value); + var target = CurrentEntity.Target.Value.Entity; if (target != null) { var threshold = Settings.GameplaySettings.TargetDetectionInfoThreshold; @@ -1314,7 +1338,7 @@ public class ActionGameManager : MonoBehaviour _ => "MISS" }; - private void UpdateFireControlDebug(Entity target) + private void UpdateFireControlDebug(TargetRef target) { if (DebugInfoText == null) return; @@ -1335,7 +1359,8 @@ public class ActionGameManager : MonoBehaviour return; } - var d = FireControl.Inspect(selectedWeapon, CurrentEntity, target); + // Mining Cut 3: fire control prices ships; a chunk target reads as none there until Cut 4. + var d = FireControl.Inspect(selectedWeapon, CurrentEntity, target.Entity); var pendingLine = "pending: none"; for (var i = CurrentEntity.Zone.PendingShots.Count - 1; i >= 0; i--) { @@ -1358,7 +1383,8 @@ public class ActionGameManager : MonoBehaviour var lastLine = _debugLastShots.TryGetValue(selectedItem, out var lastShot) ? $"last {lastShot.ShotId}: {ResultLabel(lastShot.Result)} cell {lastShot.Cell.x},{lastShot.Cell.y}" : "last: none"; - var gates = target == null + var gates = target.Chunk.HasValue ? "target: chunk" + : target.Entity == null ? "target: none" : $"gates designated {d.Designated} arc {d.InArc}"; @@ -1389,8 +1415,11 @@ public class ActionGameManager : MonoBehaviour if (CurrentEntity == null || CurrentEntity.Parent != null) return; ViewDot.Target = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint.position; - if (CurrentEntity.Target.Value != null) - TargetIndicator.Target = CurrentEntity.Target.Value.Position.ToUnity(); + var target = CurrentEntity.Target.Value; + if (target.Chunk is ChunkId chunk) + TargetIndicator.Target = ChunkWorldPosition(chunk).ToUnity(); + else if (target.Entity != null) + TargetIndicator.Target = target.Entity.Position.ToUnity(); var distance = length(ViewDot.Target.ToCultMath() - CurrentEntity.Position); foreach (var (_, barrels, crosshair) in _articulationGroups) { @@ -1403,11 +1432,11 @@ public class ActionGameManager : MonoBehaviour foreach (var (targetLock, indicator, spin) in _lockingIndicators) { - var showLockingIndicator = targetLock.Lock > .01f && CurrentEntity.Target.Value != null && CurrentEntity.IsHostileTo(CurrentEntity.Target.Value); + var showLockingIndicator = targetLock.Lock > .01f && CurrentEntity.Target.Value.Entity != null && CurrentEntity.IsHostileTo(CurrentEntity.Target.Value.Entity); indicator.gameObject.SetActive(showLockingIndicator); if(showLockingIndicator) { - indicator.Target = CurrentEntity.Target.Value.Position.ToUnity(); + indicator.Target = CurrentEntity.Target.Value.Entity.Position.ToUnity(); indicator.NoiseAmplitude = Settings.GameplaySettings.LockIndicatorNoiseAmplitude * (1 - targetLock.Lock); indicator.NoiseFrequency = Settings.GameplaySettings.LockIndicatorFrequency.Evaluate(targetLock.Lock); spin.Speed = Settings.GameplaySettings.LockSpinSpeed.Evaluate(targetLock.Lock); diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index f3d977c8c..923199200 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -236,7 +236,7 @@ public class EntityInstance : MonoBehaviour } constantWeapon.OnStartFiring += () => - _constantWeaponManagers[data].StartFiring(data, item, this, entity.Target.Value != null ? ZoneRenderer.EntityInstances[entity.Target.Value] : null); + _constantWeaponManagers[data].StartFiring(data, item, this, entity.Target.Value.Entity != null ? ZoneRenderer.EntityInstances[entity.Target.Value.Entity] : null); constantWeapon.OnStopFiring += () => _constantWeaponManagers[data].StopFiring(item); } @@ -410,7 +410,7 @@ public class EntityInstance : MonoBehaviour } LookAtPoint.position = transform.position + Entity.LookDirection.ToUnity() * - (Entity.Target.Value != null ? max(Entity.TargetRange,Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance) : 10000); + (!Entity.Target.Value.IsNone ? max(Entity.TargetRange,Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance) : 10000); LocalSpace.localPosition = transform.position = Entity.Position.ToUnity(); if (_influenceInstance) _influenceInstance.position = new Vector3(Entity.Position.x, 0, Entity.Position.z); diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index 28145c504..7540cb5c8 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -11,13 +11,13 @@ public class Minion : Agent () => Task is PatrolOrbitsTask, () => patrolState.Task = Task as PatrolOrbitsTask); - Ship.VisibleEnemies.ObserveAdd().Where(_ => Ship.Target.Value == null).Subscribe(add => Ship.Target.Value = add.Value); + Ship.VisibleEnemies.ObserveAdd().Where(_ => Ship.Target.Value.IsNone).Subscribe(add => Ship.SetTarget(add.Value)); var combatState = new CombatState(this); _rootState.AddTransition(combatState, - () => Ship.Target.Value != null, null, true, _rootState); + () => Ship.Target.Value.Entity != null, null, true, _rootState); combatState.AddTransition(_rootState, - () => Ship.Target.Value == null, null, true, _rootState); + () => Ship.Target.Value.Entity == null, null, true, _rootState); } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index d605ff8a5..c93fb3c99 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -25,7 +25,7 @@ public class CombatState : BaseState public override void Update(float delta) { - var target = _agent.Ship.Target.Value; + var target = _agent.Ship.Target.Value.Entity; if (target == null) return; _availableGroups.Clear(); diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index 7499ab6f8..a290d1481 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -77,13 +77,16 @@ public class LockWeapon : InstantWeapon public override bool Execute(float dt) { - if (_target != Entity.Target.Value) + // Mining Cut 3 (Q12 A): lock builds on an entity only. A chunk target reads as no target here, so a + // launcher never locks a rock. + var target = Entity.Target.Value.Entity; + if (_target != target) { _lock = 0; - _target = Entity.Target.Value; + _target = target; } - if (Entity.Target.Value != null && Entity.IsHostileTo(Entity.Target.Value)) + if (target != null && Entity.IsHostileTo(target)) { LockSpeed = Evaluate(_data.LockSpeed); SensorImpact = Evaluate(_data.SensorImpact); @@ -91,11 +94,11 @@ public class LockWeapon : InstantWeapon DirectionImpact = Evaluate(_data.DirectionImpact); Decay = Evaluate(_data.Decay); - var degrees = acos(dot(normalize(Entity.Target.Value.Position - Entity.Position), normalize(Entity.LookDirection))) * 57.2958f; + var degrees = acos(dot(normalize(target.Position - Entity.Position), normalize(Entity.LookDirection))) * 57.2958f; if (degrees < LockAngle) { var lerp = 1 - unlerp(0, 90, degrees); - _lock = saturate(_lock + pow(lerp, DirectionImpact) * dt * LockSpeed * pow(Entity.EntityInfoGathered[Entity.Target.Value], SensorImpact)); + _lock = saturate(_lock + pow(lerp, DirectionImpact) * dt * LockSpeed * pow(Entity.EntityInfoGathered[target], SensorImpact)); } else _lock = saturate(_lock - dt * Decay); } diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 48b4c6c4e..a5a395373 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -57,16 +57,18 @@ public class TurretController : Behavior, IInitializableBehavior public override bool Execute(float dt) { - if (Entity.Target.Value != null) + // Mining Cut 3: a turret engages entities only; it never picks a chunk, and reads one as no target. + var target = Entity.Target.Value.Entity; + if (target != null) { - var diff = Entity.Target.Value.Position - Entity.Position; + var diff = target.Position - Entity.Position; if (_predictShots) { - var targetHullData = Entity.ItemManager.GetData(Entity.Target.Value.Hull) as HullData; - var targetVelocity = float3(Entity.Target.Value.Velocity.x, 0, Entity.Target.Value.Velocity.y); + var targetHullData = Entity.ItemManager.GetData(target.Hull) as HullData; + var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); var predictedPosition = first_order_intercept( Entity.Position, float3.zero, _shotSpeed, - Entity.Target.Value.Position, targetVelocity + target.Position, targetVelocity ); predictedPosition.y = Entity.Zone.GetHeight(predictedPosition.xz) + targetHullData.GridOffset; Entity.LookDirection = normalize(predictedPosition - Entity.Position); @@ -78,7 +80,7 @@ public class TurretController : Behavior, IInitializableBehavior { // The same per-weapon decision Combat.cs makes; the range test is part of it (a shot out of range // is not designated and prices at zero). - if (FireControl.AgentFires(x, Entity, Entity.Target.Value)) + if (FireControl.AgentFires(x, Entity, target)) { x.Activate(); } @@ -93,7 +95,7 @@ public class TurretController : Behavior, IInitializableBehavior if (x.Firing) x.Deactivate(); } - Entity.Target.Value = Entity.VisibleEnemies.FirstOrDefault(e => e is Ship); + Entity.SetTarget(Entity.VisibleEnemies.FirstOrDefault(e => e is Ship)); } return true; } diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 47f2acf11..b8f70699c 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -97,8 +97,9 @@ public abstract class Weapon : Behavior, IActivatedBehavior // Single shared fire gate for player and AI shooters alike: weapons are safed by the // shooter's OWN declared stance toward its target, not the target's stance toward the shooter. - // With no target set, behaviour is unchanged (nothing to be safe about). - public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value); + // With no target set, behaviour is unchanged (nothing to be safe about). Mining Cut 3: a chunk has no stance, + // so a weapon is never safed against one. + public bool StanceAllowsFire => Entity.Target.Value.Entity == null || Entity.IsHostileTo(Entity.Target.Value.Entity); // Cut 3 (docs/fire-control-cut.md, Q2): player fire is arc-gated like everyone else's. This is the same // predicate (FireControl.InArc) Combat.cs and TurretController.cs gate AI and turret fire with, read at diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 6ad751e01..48142c9ca 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -43,7 +43,10 @@ public abstract class Entity public Entity Parent; public List Children = new List(); - public ReactiveProperty Target = new ReactiveProperty((Entity)null); + // Mining Cut 3 (docs/mining-cut-refresh.md): the one target slot -- an entity, a chunk, or none. Read-only from + // outside: SetTarget is its one writer, for the player's handlers and the agents alike. + private readonly ReactiveProperty _target = new ReactiveProperty(TargetRef.None); + public IReadOnlyReactiveProperty Target => _target; // Cut 2 (docs/fire-control-cut.md): the stored aim-point slot. Written only by TrySelectTargetItem // below, and nulled whenever Target changes (subscribed in Activate). Do not read this field directly @@ -198,7 +201,7 @@ public abstract class Entity })); _subscriptions.Add(Zone.Entities.ObserveRemove().Subscribe(remove => { - if (Target.Value == remove.Value) Target.Value = null; + if (Target.Value.Entity == remove.Value) SetTarget(TargetRef.None); EntityInfoGathered.Remove(remove.Value); EntityHostility.Remove(remove.Value); VisibleEntities.Remove(remove.Value); @@ -224,13 +227,14 @@ public abstract class Entity _subscriptions.Add(_iffOverrides.ObserveRemove().Subscribe(remove => RefreshHostilityFromOverride(remove.Key))); _subscriptions.Add(VisibleEnemies.ObserveRemove().Subscribe(remove => { - if (Target.Value == remove.Value) Target.Value = null; + if (Target.Value.Entity == remove.Value) SetTarget(TargetRef.None); })); - _subscriptions.Add(Target.Subscribe(entity => entity?.TargetedBy.OnNext(this))); + // Only an entity is ever targeted by: a chunk has no one to tell. + _subscriptions.Add(Target.Subscribe(target => target.Entity?.TargetedBy.OnNext(this))); _subscriptions.Add(TargetedBy.Subscribe(enemy => { TargetedByCount.Value++; - enemy.Target.Where(t => t != this).Take(1).Subscribe(_ => TargetedByCount.Value--); + enemy.Target.Where(t => t.Entity != this).Take(1).Subscribe(_ => TargetedByCount.Value--); })); // @@ -295,8 +299,11 @@ public abstract class Entity // Deactivate nulling Target outright, because nulling on every dock would throw away a target // that is still alive and simply never changed while docked -- the common case, and not the one // that crashed. - if (Target.Value != null && !EntityInfoGathered.ContainsKey(Target.Value)) - Target.Value = null; + // Mining Cut 3: a chunk target is held to the same "still tracked" test through the chunk path -- kept + // while this entity can see it, dropped once it cannot (a broken chunk is dark, so it is dropped too). + if (Target.Value.Entity != null && !EntityInfoGathered.ContainsKey(Target.Value.Entity) || + Target.Value.Chunk is ChunkId heldChunk && !ChunkVisible(heldChunk)) + SetTarget(TargetRef.None); if(WeaponGroups.All(wg=>!wg.items.Any())) GenerateWeaponGroups(); @@ -314,7 +321,8 @@ public abstract class Entity return true; } - if (Target.Value == null || item.Entity != Target.Value || !FireControl.IsRevealed(this, item)) + // Mining Cut 3: entity-only. A chunk has no items, so an item never belongs to a chunk target. + if (Target.Value.Entity == null || item.Entity != Target.Value.Entity || !FireControl.IsRevealed(this, item)) return false; TargetItem.Value = item; @@ -325,10 +333,62 @@ public abstract class Entity // revealed to this entity, or no longer belongs to the current Target, even though nothing wrote // TargetItem.Value at the moment that became true. Re-checked on every call; never cached. public EquippedItem ResolvedTargetItem => - TargetItem.Value != null && Target.Value == TargetItem.Value.Entity && FireControl.IsRevealed(this, TargetItem.Value) + TargetItem.Value != null && Target.Value.Entity == TargetItem.Value.Entity && FireControl.IsRevealed(this, TargetItem.Value) ? TargetItem.Value : null; + // Mining Cut 3 (docs/mining-cut-refresh.md): the one writer of Target, called by the player's handlers and the + // agents alike. A chunk is taken only while this entity can see it, so a rock in darkness cannot be picked; an + // entity is written as given, as every handler wrote it before. Returns whether the write took effect. + public bool SetTarget(TargetRef target) + { + if (target.Chunk is ChunkId chunk && !ChunkVisible(chunk)) return false; + _target.Value = target; + return true; + } + + // Mining Cut 3 (Q14 A): this entity's info on a chunk, computed when asked and stored nowhere. It is the value + // the per-tick sensor rule (Sensor.Execute, through Sensor.Gain) would settle at for a target this bright, + // here, now. Each active sensor applies x <- saturate(x + G_i dt)(1 - k dt) every tick, so with n sensors the + // info settles where the gain balances the decay: sum(G_i) / (n k), capped at 1. A ping adds nothing that + // lasts, so only the passive gain enters. + public float ChunkInfo(ChunkId chunk) + { + var visibility = Zone.ChunkVisibility(chunk); + if (visibility <= 0f) return 0f; + var position = Zone.ChunkPose(chunk.Field, chunk.Index).xy; + var rate = 0f; + var sensors = 0; + foreach (var sensor in ActiveSensors()) + { + rate += sensor.PassiveRate(visibility, position); + sensors++; + } + return sensors == 0 ? 0f : saturate(rate / (sensors * ItemManager.GameplaySettings.TargetInfoDecay)); + } + + // Mining Cut 3: the detection threshold, read through the chunk path. A chunk is visible exactly as an entity + // enters VisibleEntities: its info exceeds TargetDetectionInfoThreshold. + public bool ChunkVisible(ChunkId chunk) => ChunkInfo(chunk) > ItemManager.GameplaySettings.TargetDetectionInfoThreshold; + + // Mining Cut 3 (Q13 A): the reach for picking a chunk -- the longest range among this entity's active weapons. + // Every visible chunk within it, written into `into` (cleared first). Cycling and any programmatic chunk pick + // read this, so there is one reach rule. + public void VisibleChunksInReach(List into) + { + var reach = 0f; + foreach (var weapon in _weapons) + if (weapon.Item == null || weapon.Item.Active.Value) + reach = max(reach, weapon.Range); + Zone.ChunksNear(Position.xz, reach, into); + into.RemoveAll(chunk => !ChunkVisible(chunk)); + } + + // Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables. + private IEnumerable ActiveSensors() => + Equipment.Where(item => item.Active.Value).SelectMany(item => item.Behaviors).OfType() + .Concat(_activeConsumables.SelectMany(effect => effect.Behaviors).OfType()); + // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of // what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so // Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per @@ -1075,7 +1135,12 @@ public abstract class Entity var hullData = ItemManager.GetData(Hull) as HullData; - TargetRange = Target.Value == null ? -1 : length(Position - Target.Value.Position); + // Mining Cut 3: a chunk target is lost the moment this entity can no longer see it -- the lost-track rule + // through the chunk path. One check of the one slot per tick, not a detection loop over chunks. + if (Target.Value.Chunk is ChunkId trackedChunk && !ChunkVisible(trackedChunk)) SetTarget(TargetRef.None); + TargetRange = Target.Value.Chunk is ChunkId rangedChunk + ? length(Position.xz - Zone.ChunkPose(rangedChunk.Field, rangedChunk.Index).xy) + : Target.Value.Entity == null ? -1 : length(Position - Target.Value.Entity.Position); var localSecurityLevel = Zone.GetSecurityLevel(Position.xz); if (CurrentSecurityLevel.Value != localSecurityLevel) CurrentSecurityLevel.Value = localSecurityLevel; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 06c9d3ca5..6f413d05c 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -72,18 +72,26 @@ public static class FireControl // (Fire; operator ruling 2026-09-30, "the user is hoping that the arc gets the weapon close enough to still // splash some damage"). A weapon that only hits what it fires at still refuses. Fire reads InArc itself to // choose that flight, so the two share the one bearing test and differ only in this exemption. + // Mining Cut 3: a chunk target bears through its own position, on the plane the bearing test reads. public static bool ArcPermitsFire(Weapon weapon, Entity shooter) { var target = shooter.Target.Value; - if (target == null) return true; - return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position); + if (target.IsNone) return true; + float3 toTarget; + if (target.Chunk is ChunkId chunk) + { + var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy; + toTarget = float3(at.x, 0, at.y) - shooter.Position; + } + else toTarget = target.Entity.Position - shooter.Position; + return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, toTarget); } // Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so // a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The // answer is Solve's own -- there is no second copy of the arming test. public static bool Refuses(Weapon weapon, Entity shooter) => - Solve(weapon, weapon.Item, shooter, shooter.Target.Value).Outcome == FireOutcome.Refused; + Solve(weapon, weapon.Item, shooter, shooter.Target.Value.Entity).Outcome == FireOutcome.Refused; // The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30). // A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its @@ -522,7 +530,8 @@ public static class FireControl public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null) { var zone = source.Zone; - var target = source.Target.Value; + // Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks. + var target = source.Target.Value.Entity; var now = zone.Time; // R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 0190f4f02..d10f097ca 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -81,7 +81,7 @@ public class Ship : Entity public void EnterWormhole(float2 wormholePosition) { - Target.Value = null; + SetTarget(TargetRef.None); _wormholeAnimationProgress = 0; _enteringWormhole = true; _wormholeEntryPosition = Position.xz; diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 921961b15..806f153d7 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -183,7 +183,7 @@ public sealed class FireAuthorityTests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index ab2966025..24ae6cd4a 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -184,7 +184,7 @@ public sealed class FireControlCut11Tests : IDisposable shooter.Position = float3(37, 0, -11); target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); if (equipShield) target.Shield.Item.Enabled.Value = true; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index b01d04b76..7faac21ce 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -233,7 +233,7 @@ public sealed class FireControlCut123Tests : IDisposable shooter.Position = float3(37, 0, -11); target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter -- travel direction is exactly (0,1) if (targetFacing != null) target.Direction = targetFacing.Value; - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index d55bc6df6..b90bc4700 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -253,7 +253,7 @@ public sealed class FireControlCut124Tests : IDisposable shooter.Position = float3(-53, 0, -101); target.Position = shooter.Position + float3(0, 0, targetRange); if (targetFacing != null) target.Direction = normalize(targetFacing.Value); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); @@ -1176,7 +1176,7 @@ public sealed class FireControlCut124Tests : IDisposable var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, weaponRange: NoLockRange); Aim(e, float2(2, 1)); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); var burst = AimLine(e); var onLine = AddShip(e, burst, 1f, 300); var offLine = AddShip(e, burst + float3(-30, 0, 30), 1f, 302); @@ -1201,7 +1201,7 @@ public sealed class FireControlCut124Tests : IDisposable var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); Aim(e, float2(2, 1)); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -1252,7 +1252,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(side, -1)); var edge = float2(side * sin(radians(85f)), cos(radians(85f))); var onEdge = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 300); @@ -1273,7 +1273,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); var mount = float2(0, 1); var aim = normalize(float2(2, 1)); var onAim = AddShip(e, PointAlong(e, aim, NoLockRange), 1f, 300); @@ -1659,7 +1659,7 @@ public sealed class FireControlCut124Tests : IDisposable public void AContactRoundNeverStopsNearerThanItsArmingDistance() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); AddShip(e, PointAlong(e, float2(0, 1), 6f), 1f, 300); ShotOutcome outcome = null; @@ -1682,7 +1682,7 @@ public sealed class FireControlCut124Tests : IDisposable var column = new Shape(1, 22); foreach (var y in new[] { 0, 1, 2, 18, 19, 20, 21 }) column[new int2(0, y)] = true; var e = Build(TestSettings(), column, velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var offset = e.Target.ToWorldPoint(float2(0, 2)) - e.Target.Position.xz; e.Target.Position = float3(e.Shooter.Position.x - offset.x, 0, e.Shooter.Position.z - offset.y); @@ -1708,7 +1708,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: range, rangeTerm: starvedOfPower ? StatSource.PowerSupply : (StatSource?) null); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); if (starvedOfPower) { @@ -1730,7 +1730,7 @@ public sealed class FireControlCut124Tests : IDisposable public void ARangeExactlyEqualToTheArmingDistanceFires() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 30f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); Assert.True(FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter) != 0, "a Range equal to the arming distance must fire"); @@ -1745,7 +1745,7 @@ public sealed class FireControlCut124Tests : IDisposable public void ARefusedRoundIsNotAnnounced(float range, int fired) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: range); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var announced = 0; var committed = 0; @@ -1918,7 +1918,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 302); @@ -1944,7 +1944,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var targetBefore = e.Target.Hull.Durability; var shooterHit = 0f; @@ -1970,7 +1970,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); ShotOutcome outcome = null; @@ -1999,7 +1999,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var witness = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 300); @@ -2061,7 +2061,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(2, 1)); ShotOutcome committed = null, resolved = null; using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); @@ -2162,7 +2162,7 @@ public sealed class FireControlCut124Tests : IDisposable public void AContactRoundIgnoresAHullThatCrossesItsLineAfterFire() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f) + float3(300, 0, 0), 1f, 300); ShotOutcome committed = null; @@ -2183,7 +2183,7 @@ public sealed class FireControlCut124Tests : IDisposable public void AContactRoundStillBurstsAtAHullThatLeavesItsLineAfterFire() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 302); @@ -2209,7 +2209,7 @@ public sealed class FireControlCut124Tests : IDisposable public void AShooterMovingAfterFireDoesNotMoveTheContactPoint(bool withBlocker) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var origin = e.Shooter.Position; if (withBlocker) AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); @@ -2230,7 +2230,7 @@ public sealed class FireControlCut124Tests : IDisposable public void AContactRoundIgnoresAHullBehindTheMuzzle() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); AddShip(e, PointAlong(e, float2(0, -1), 25f), 1f, 300); ShotOutcome committed = null; @@ -2294,7 +2294,7 @@ public sealed class FireControlCut124Tests : IDisposable public void ANoLockRoundStillBurstsAfterItsShooterIsDestroyed(WeaponFuse fuse) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var witness = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 300); var resolved = new List(); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 30cf46356..c613bfa4b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -279,7 +279,7 @@ public sealed class FireControlCut12Tests : IDisposable shooter.Position = float3(37, 0, -11); target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter if (targetFacing != null) target.Direction = targetFacing.Value; - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); if (equipShield) @@ -390,7 +390,7 @@ public sealed class FireControlCut12Tests : IDisposable // No target: Fire's own null-target branch must use the fallback directly, not call TravelDirection // (which reads target.Position and would NRE). - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); var noTargetId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var noTarget = e.Zone.PendingShots.Single(s => s.ShotId == noTargetId); Assert.Equal(0f, noTarget.TravelDirection.x); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index 5ee04751a..a6c1cc1bc 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -161,7 +161,7 @@ public sealed class FireControlCut4Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index e1366721e..d27857104 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -191,7 +191,7 @@ public sealed class FireControlCut5Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); @@ -346,7 +346,7 @@ public sealed class FireControlCut5Tests : IDisposable shooter.Position = float3.zero; target.Position = targetPosition; - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); @@ -449,7 +449,7 @@ public sealed class FireControlCut5Tests : IDisposable // The shared path: Weapon.ArcAllowsFire (the trigger gate every shooter passes through) must agree, // now that it no longer carries its own special case and defers to InArc entirely. var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon); - shooter.Target.Value = target; + shooter.SetTarget(target); Assert.True(weapon.ArcAllowsFire); } @@ -465,7 +465,7 @@ public sealed class FireControlCut5Tests : IDisposable var (_, shooter, target, weaponItem) = BuildPointBlank(settings); shooter.Position = float3.zero; target.Position = float3(100, 0, 0); // directly abeam - shooter.Target.Value = target; + shooter.SetTarget(target); var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon); Assert.False(weapon.ArcAllowsFire); @@ -488,7 +488,7 @@ public sealed class FireControlCut5Tests : IDisposable var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, targetRange: 100); e.Shooter.Position = float3.zero; e.Target.Position = float3(100, 0, 0); // abeam -- outside the narrow 60-degree arc - e.Shooter.Target.Value = e.Target; + e.Shooter.SetTarget(e.Target); var resolvedCount = 0; e.Zone.ShotResolved.Subscribe(_ => resolvedCount++); @@ -590,7 +590,7 @@ public sealed class FireControlCut5Tests : IDisposable var observer = new Ship(items, target.Zone, new EquippableItem { Data = observerHullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); target.Zone.Entities.Add(observer); observer.Activate(); - observer.Target.Value = target; + observer.SetTarget(target); // With both items alive: Big (index 0) reveals at .2, Medium (index 1) at .8. observer.EntityInfoGathered[target] = .3f; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 788655802..9698d741d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -163,7 +163,7 @@ public sealed class FireControlCut6Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs index 2bd717924..844e4995b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs @@ -145,7 +145,7 @@ public sealed class FireControlCut6cTests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, true); zone.Update(0f); // warm-up: resolves the targeting system's stats before HitProbability reads them diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs index b45a1d20c..8b9d39cf1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs @@ -163,7 +163,7 @@ public sealed class FireControlCut6dTests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, true); shooter.EntityInfoGathered[target] = 1f; // full reveal: pSensor = 1 diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs index c278bcf58..63578287d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -140,7 +140,7 @@ public sealed class FireControlCut7Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, 100); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, true); zone.Update(0f); // warm-up: resolves weapon/targeting stats before Fire reads them diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index 96fb785d1..a978aed7f 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -145,7 +145,7 @@ public sealed class FireControlCut8Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, 100); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); @@ -225,7 +225,7 @@ public sealed class FireControlCut8Tests : IDisposable locker.Position = float3.zero; bystander.Position = float3(0, 0, 100); locker.LookDirection = float3(0, 0, 1); - locker.Target.Value = bystander; + locker.SetTarget(bystander); locker.EntityInfoGathered[bystander] = 1f; locker.SetIff(bystander, true); @@ -247,7 +247,7 @@ public sealed class FireControlCut8Tests : IDisposable public void DeadEntityDoesNotUpdate() { var e = Build(TestSettings()); - e.Target.Target.Value = e.Shooter; // give the target its own TargetRange to freeze + e.Target.SetTarget(e.Shooter); // give the target its own TargetRange to freeze e.Zone.Update(.1f); var rangeBeforeDeath = e.Target.TargetRange; @@ -274,7 +274,7 @@ public sealed class FireControlCut8Tests : IDisposable s.Locker.Deactivate(); Assert.False(s.Locker.Active); Assert.DoesNotContain(s.Bystander, s.Locker.EntityInfoGathered.Keys); - Assert.Equal(s.Bystander, s.Locker.Target.Value); // Deactivate does not null the entity's own Target + Assert.Same(s.Bystander, s.Locker.Target.Value.Entity); // Deactivate does not null the entity's own Target var ex = Record.Exception(() => s.Locker.Update(.1f)); Assert.Null(ex); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index d824d4278..2ebeb72f1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -333,7 +333,7 @@ public sealed class FireControlCut9Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, 100); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, true); shooter.EntityInfoGathered[target] = 1f; // full reveal: pSensor = 1 @@ -561,7 +561,7 @@ public sealed class FireControlCut9Tests : IDisposable locker.Position = float3.zero; bystander.Position = float3(0, 0, 100); locker.LookDirection = float3(0, 0, 1); - locker.Target.Value = bystander; + locker.SetTarget(bystander); locker.EntityInfoGathered[bystander] = 1f; locker.SetIff(bystander, true); @@ -596,7 +596,7 @@ public sealed class FireControlCut9Tests : IDisposable // Assert.Null(entity) would hand a live Entity into xUnit's reflection-based failure formatter, // which recurses through Zone -> Entities -> Ship -> Zone forever; a plain boolean with its own // message sidesteps that entirely. - Assert.True(s.Locker.Target.Value == null, "the invariant: an active entity's Target is always a live entity it has info on -- a stale reference to the departed Bystander survived Activate's reconciliation"); + Assert.True(s.Locker.Target.Value.IsNone, "the invariant: an active entity's Target is always a live entity it has info on -- a stale reference to the departed Bystander survived Activate's reconciliation"); var ex = Record.Exception(() => s.Locker.Update(.1f)); Assert.Null(ex); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 7d79a99b1..edca68dde 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -245,7 +245,7 @@ public sealed class FireControlPerWeaponTests : IDisposable shooter.Position = float3(-53, 0, -101); target.Position = shooter.Position + float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); @@ -371,10 +371,10 @@ public sealed class FireControlPerWeaponTests : IDisposable public void TheForecastIsTheOutcomeFireDelivers(float targetRange, float range, bool noTarget, bool outOfArc, FireOutcome outcome, float reach) { var r = Build(outOfArc ? SideMount : null, fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: targetRange); - if (noTarget) r.Shooter.Target.Value = null; + if (noTarget) r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); - var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Shooter.Target.Value); + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Shooter.Target.Value.Entity); var shotId = FireControl.Fire(r.Gun.Weapon, r.Gun.Item, r.Shooter); Assert.Equal(outcome, d.Outcome); @@ -417,7 +417,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false, bool auto = false) { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single, auto: auto); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (InstantWeapon) r.Gun.Weapon; var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon); @@ -496,7 +496,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range) { var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (ConstantWeapon) r.Gun.Weapon; var wears = 0; @@ -543,7 +543,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void ARefusedBeamNeverStarts(float range, int starts) { var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (ConstantWeapon) r.Gun.Weapon; var started = 0; @@ -666,7 +666,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private (int Starts, int Sounds, float Heat, bool Charging) Charge(float range) { var r = Build(charged: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (ChargedWeapon) r.Gun.Weapon; r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_charge_play" } } }; @@ -701,7 +701,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void ATriggeredBurstIsNotReadyBeforeItsFirstRound() { var r = Build(range: 100f); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (InstantWeapon) r.Gun.Weapon; r.Zone.Update(.01f); // resolves the gun's stats, which size the burst @@ -719,7 +719,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void AChargedWeaponPressedAgainBeforeItsReleasedRoundFliesStartsNoSecondCharge() { var r = Build(charged: true, range: 100f); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (ChargedWeapon) r.Gun.Weapon; var starts = 0; @@ -747,7 +747,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots) { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (InstantWeapon) r.Gun.Weapon; var fired = 0; @@ -772,7 +772,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void ABurstTriggeredAfterAFiredOneIsNotReadyBeforeItsFirstRound() { var r = Build(range: 100f); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (InstantWeapon) r.Gun.Weapon; r.Zone.Update(.25f); diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 49753d21f..33c90eeba 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -205,7 +205,7 @@ public sealed class FireControlTests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, 10); // dead ahead of the None-rotation hardpoint, well within range and arc - shooter.Target.Value = target; + shooter.SetTarget(target); // Cut 3: CombatState now gates on FireControl.HitProbability, which is zero for an undetected target // (the target is not in VisibleEntities) regardless of arc -- cross TargetDetectionInfoThreshold // directly, the same way a Sensor's own info accrual eventually would. diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 31e390abe..4c56b5ff6 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -245,7 +245,7 @@ public sealed class IffAndCombatTests : IDisposable var target = NewShip(items, zone, new Faction { Name = "Target" }); var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, false); // explicitly neutral weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick @@ -263,7 +263,7 @@ public sealed class IffAndCombatTests : IDisposable var target = NewShip(items, zone, new Faction { Name = "Target" }); var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, true); weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick @@ -319,7 +319,7 @@ public sealed class IffAndCombatTests : IDisposable }; var gunItem = shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)); var lockWeapon = new LockWeapon(lockWeaponData, gunItem); - shooter.Target.Value = target; + shooter.SetTarget(target); lockWeapon.Execute(1f); diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index d90f983fa..cd6d5bff9 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -77,9 +77,23 @@ public sealed class MiningCut3Tests : IDisposable public Zone Zone; public HullData Hull; public GearData Eye; + public ZonePack Pack; + public List Belts = new List(); + public List Kinds = new List(); } - private Scene BuildScene() + // A belt around the sun: its chunks orbit the origin at their own distances, so at zone time 0 a chunk of + // phase p sits at distance × (cos 2πp, sin 2πp). `kind` null leaves the belt kindless, for the zone to assign. + private sealed class BeltSpec + { + public Asteroid[] Asteroids; + public FieldKindData Kind; + } + + private static Asteroid Rock(float distance, float phase = 0f, float size = .5f) => + new Asteroid { Distance = distance, Phase = phase, Size = size, RotationSpeed = .1f }; + + private Scene BuildScene(FieldKindData[] kinds = null, params BeltSpec[] belts) { var dir = Path.Combine(_root, $"scene{_fixtureCount++}"); Directory.CreateDirectory(dir); @@ -101,6 +115,7 @@ public sealed class MiningCut3Tests : IDisposable PingCooldown = Constant(4f) }); cache.Upsert(eye); + foreach (var kind in kinds ?? new FieldKindData[0]) cache.Upsert(kind); var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); var sunOrbit = cache.Upsert(new OrbitData()).Key; @@ -113,8 +128,35 @@ public sealed class MiningCut3Tests : IDisposable Mass = 10000f, Time = 0 }; - var zone = new Zone(items, PlanetSettings(), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null); - return new Scene { Cache = cache, Items = items, Zone = zone, Hull = hull, Eye = eye }; + var scene = new Scene { Cache = cache, Items = items, Hull = hull, Eye = eye, Pack = pack }; + scene.Kinds.AddRange(kinds ?? new FieldKindData[0]); + foreach (var spec in belts) + { + var beltOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef(sunOrbit), Distance = 150f }).Key; + var belt = new AsteroidBeltData { Orbit = new CultRecordRef(beltOrbit), Asteroids = spec.Asteroids }; + if (spec.Kind != null) belt.Kind = cache.RefOf(spec.Kind); + var key = cache.Upsert(belt).Key; + pack.Orbits.Add(new CultRecordRef(beltOrbit)); + pack.Planets.Add(new CultRecordRef(key)); + scene.Belts.Add(key); + } + scene.Zone = new Zone(items, PlanetSettings(), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null); + return scene; + } + + private static FieldKindData Kind(string name, float crossSection, float weight = 1f) => + new FieldKindData { Name = name, CrossSection = crossSection, GenerationWeight = weight }; + + private static BeltSpec Belt(FieldKindData kind, params Asteroid[] asteroids) => new BeltSpec { Kind = kind, Asteroids = asteroids }; + + // A chunk's planar position now (zone time never advances in these tests). + private static float2 At(Scene s, ChunkId chunk) => s.Zone.ChunkPose(chunk.Field, chunk.Index).xy; + + // The rule as the map states it: kind cross-section × area in schematic cells (cell 2) × the light on it. + private static float ExpectedVisibility(Scene s, ChunkId chunk, FieldKindData kind) + { + var cells = s.Zone.ChunkRadius(chunk) / 2f; + return kind.CrossSection * PI * cells * cells * s.Zone.GetLight(At(s, chunk)); } private static GearData Gear(string name, BehaviorData behavior) => new GearData @@ -132,8 +174,9 @@ public sealed class MiningCut3Tests : IDisposable }; // A ship held at a planar position, facing +z. `sensor` fits the scene's sensor; `crossSection` fits a - // reflector of that cross-section (its own design, so ships in one scene can differ). - private Ship SpawnShip(Scene s, float2 at, bool sensor = false, float crossSection = 0f) + // reflector of that cross-section (its own design, so ships in one scene can differ); each of `weaponRanges` + // fits a gun of that range. + private Ship SpawnShip(Scene s, float2 at, bool sensor = false, float crossSection = 0f, params float[] weaponRanges) { var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); if (sensor) Assert.True(ship.TryEquip(Mint(s, s.Eye))); @@ -143,6 +186,12 @@ public sealed class MiningCut3Tests : IDisposable s.Cache.Upsert(mirror); Assert.True(ship.TryEquip(Mint(s, mirror))); } + foreach (var range in weaponRanges) + { + var gun = Gear($"Gun {range}", new InstantWeaponData { Range = Constant(range) }); + s.Cache.Upsert(gun); + Assert.True(ship.TryEquip(Mint(s, gun))); + } s.Zone.Entities.Add(ship); Hold(ship, at); ship.LookDirection = float3(0, 0, 1); @@ -202,4 +251,416 @@ public sealed class MiningCut3Tests : IDisposable var hash = Convert.ToHexString(SHA256.HashData(trace.ToArray())); Assert.True(hash == Golden, $"the EntityInfoGathered trace changed: hash {hash}"); } + + // Q14 A's parity: one rule, two integrators. A ship carrying a reflector of the chunk's own cross-section + // (the kind's per-cell reflectivity × the chunk's area in cells), held still where the chunk is, settles under + // the per-tick loop at the value ChunkInfo returns on demand. The discrete loop's fixed point sits one decay + // step below the continuous one, exactly: x* (1 - k dt). The observer is off the sensor's axis, so the + // bearing term is live. + [Fact] + public void ChunkInfoMatchesAnEntityHeldStill() + { + var kind = Kind("Asteroid", 1f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var chunkAt = At(s, chunk); + var eyeAt = float2(50, -150); + var observer = SpawnShip(s, eyeAt, sensor: true); + var cells = s.Zone.ChunkRadius(chunk) / 2f; + var rock = SpawnShip(s, chunkAt, crossSection: kind.CrossSection * PI * cells * cells); + + for (var tick = 0; tick < 600; tick++) Tick((rock, chunkAt), (observer, eyeAt)); + + var settled = observer.ChunkInfo(chunk); + var perTick = observer.EntityInfoGathered[rock]; + Assert.InRange(settled, .12f, .9f); // visible, unsaturated: the balance, not the cap, decides it + var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt); + Assert.True(abs(perTick - discrete) <= 1e-3f * settled, + $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); + } + + // A chunk outside every sun's light is dark: no visibility, no info, and it cannot be picked, while a lit + // chunk of the same belt and kind can. + [Fact] + public void AChunkInDarknessIsNotVisible() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(700f, .5f))); + var lit = new ChunkId(s.Belts[0], 0); + var dark = new ChunkId(s.Belts[0], 1); + Assert.Equal(0f, s.Zone.GetLight(At(s, dark))); + + var inTheDark = SpawnShip(s, At(s, dark) + float2(0, -30), sensor: true, weaponRanges: 300f); + Tick((inTheDark, At(s, dark) + float2(0, -30))); + Assert.Equal(0f, s.Zone.ChunkVisibility(dark)); + Assert.Equal(0f, inTheDark.ChunkInfo(dark)); + Assert.False(inTheDark.SetTarget(dark)); + Assert.True(inTheDark.Target.Value.IsNone); + var reachable = new List(); + inTheDark.VisibleChunksInReach(reachable); + Assert.DoesNotContain(dark, reachable); + + var inTheLight = SpawnShip(s, At(s, lit) + float2(0, -30), sensor: true); + Assert.True(inTheLight.SetTarget(lit)); + Assert.True(inTheLight.Target.Value.Equals(new TargetRef(lit))); + } + + // Visibility is the kind's reflectivity × the chunk's area in cells × the light on it: linear in + // cross-section, quadratic in radius, linear in light. Info follows visibility, so a brighter chunk is seen + // further off. + [Fact] + public void ABrighterOrBiggerChunkIsSeenFurther() + { + var dim = Kind("Dim", 1f); + var bright = Kind("Bright", 2f); + var s = BuildScene(new[] { dim, bright }, + Belt(dim, Rock(150f, 0f, .2f), Rock(150f, .25f, .8f), Rock(250f, .5f, .2f)), + Belt(bright, Rock(150f, 0f, .2f))); + var small = new ChunkId(s.Belts[0], 0); + var big = new ChunkId(s.Belts[0], 1); + var far = new ChunkId(s.Belts[0], 2); + var brightTwin = new ChunkId(s.Belts[1], 0); + + foreach (var (chunk, kind) in new[] { (small, dim), (big, dim), (far, dim), (brightTwin, bright) }) + Assert.Equal(ExpectedVisibility(s, chunk, kind), s.Zone.ChunkVisibility(chunk), 4); + + var vSmall = s.Zone.ChunkVisibility(small); + Assert.Equal(2f, s.Zone.ChunkVisibility(brightTwin) / vSmall, 4); + var radiusRatio = s.Zone.ChunkRadius(big) / s.Zone.ChunkRadius(small); + Assert.True(radiusRatio > 1.5f); + Assert.Equal(radiusRatio * radiusRatio, s.Zone.ChunkVisibility(big) / vSmall, 3); + var lightRatio = s.Zone.GetLight(At(s, far)) / s.Zone.GetLight(At(s, small)); + Assert.InRange(lightRatio, .01f, .9f); + Assert.Equal(lightRatio, s.Zone.ChunkVisibility(far) / vSmall, 3); + + // Seen further: from 180 away the dim twin is below the detection threshold and the bright one above it. + var eyeAt = At(s, small) + float2(0, -180); + var observer = SpawnShip(s, eyeAt, sensor: true); + Tick((observer, eyeAt)); + Assert.Equal(2f, observer.ChunkInfo(brightTwin) / observer.ChunkInfo(small), 3); + Assert.False(observer.ChunkVisible(small)); + Assert.True(observer.ChunkVisible(brightTwin)); + } + + // A worn chunk shrinks, so it dims by exactly its smaller area; a broken chunk has no size and is dark. + [Fact] + public void AWornChunkDims() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var whole = s.Zone.ChunkVisibility(chunk); + var hitpoints = PlanetSettings().AsteroidHitpoints.Evaluate(.5f); + + Assert.False(s.Zone.Wear(chunk, hitpoints * .5f)); + var worn = s.Zone.ChunkVisibility(chunk); + Assert.InRange(worn, whole * .05f, whole * .95f); + Assert.Equal(ExpectedVisibility(s, chunk, kind), worn, 4); + } + + [Fact] + public void ABrokenChunkIsInvisible() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var eyeAt = At(s, chunk) + float2(0, -100); + var observer = SpawnShip(s, eyeAt, sensor: true); + Assert.True(observer.ChunkVisible(chunk)); + + Assert.True(s.Zone.Wear(chunk, PlanetSettings().AsteroidHitpoints.Evaluate(.5f) * 2f)); + Assert.Equal(0f, s.Zone.ChunkVisibility(chunk)); + Assert.Equal(0f, observer.ChunkInfo(chunk)); + Assert.False(observer.SetTarget(chunk)); + } + + // Q13 A: the reach for offering a chunk is the longest range among the entity's ACTIVE weapons. Two visible + // chunks, 200 and about 300 away; guns of 120, 250 and 350, the last switched off. + [Fact] + public void CyclingReachIsTheLongestActiveWeaponRange() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, + Belt(kind, Rock(150f), Rock(length(float2(150, 100)), atan2(100f, 150f) / (2 * PI)))); + var near = new ChunkId(s.Belts[0], 0); + var farther = new ChunkId(s.Belts[0], 1); + var eyeAt = float2(150, -200); + var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 120f, 250f, 350f }); + Tick((observer, eyeAt)); // weapons read their ranges when they run + Assert.Equal(200f, length(At(s, near) - eyeAt), 2); + Assert.Equal(300f, length(At(s, farther) - eyeAt), 2); + Assert.True(observer.ChunkVisible(near)); + Assert.True(observer.ChunkVisible(farther)); + + EquippedItem Gun(float range) => observer.Equipment.Single(item => item.Data.Name == $"Gun {range}"); + var offered = new List(); + + Gun(350f).Enabled.Value = false; + observer.VisibleChunksInReach(offered); + Assert.Equal(new[] { near }, offered); + + Gun(250f).Enabled.Value = false; + observer.VisibleChunksInReach(offered); + Assert.Empty(offered); + + Gun(350f).Enabled.Value = true; + observer.VisibleChunksInReach(offered); + Assert.Equal(2, offered.Count); + Assert.Contains(near, offered); + Assert.Contains(farther, offered); + } + + // The chunk query returns exactly the chunks within range, from inside the belt's hole, from within the + // annulus and from outside it -- the belt skip never drops a chunk the brute-force answer would keep -- and it + // leaves out a broken chunk. + [Fact] + public void TheChunkQueryFindsExactlyTheChunksInRange() + { + var kind = Kind("Asteroid", 4f); + var rocks = Enumerable.Range(0, 12).Select(i => Rock(200f + i * 15f, i / 12f)).ToArray(); + var s = BuildScene(new[] { kind }, Belt(kind, rocks)); + var belt = s.Belts[0]; + Assert.True(s.Zone.Wear(new ChunkId(belt, 5), 1e6f)); // broken + + var found = new List(); + var sawSome = false; + foreach (var (from, range) in new[] + { + (float2(0, 0), 150f), (float2(0, 0), 230f), (float2(0, 0), 400f), + (float2(220, 0), 40f), (float2(-300, 50), 120f), (float2(600, 0), 360f), (float2(600, 0), 200f) + }) + { + s.Zone.ChunksNear(from, range, found); + var expected = Enumerable.Range(0, rocks.Length) + .Where(i => i != 5 && length(At(s, new ChunkId(belt, i)) - from) <= range) + .Select(i => new ChunkId(belt, i)).ToArray(); + Assert.Equal(expected, found); + sawSome |= expected.Length > 0; + } + Assert.True(sawSome); + } + + // The negative for Activate's "still tracked" reconciliation (Entity.Activate) and the per-tick lost-track + // check: a visible chunk target survives ticks and a dock/undock, and reads its range through the chunk. + [Fact] + public void AChunkTargetSurvivesTheInfoPassWhileSeen() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var eyeAt = At(s, chunk) + float2(30, -100); + var observer = SpawnShip(s, eyeAt, sensor: true); + Assert.True(observer.SetTarget(chunk)); + + for (var tick = 0; tick < 5; tick++) Tick((observer, eyeAt)); + Assert.True(observer.Target.Value.Equals(new TargetRef(chunk))); + Assert.Equal(length(float2(30, -100)), observer.TargetRange, 3); + + observer.Deactivate(); + observer.Activate(); + Assert.True(observer.Target.Value.Equals(new TargetRef(chunk))); + } + + // The lost-track rule through the chunk path: a chunk target is dropped once this entity cannot see it -- + // on the next tick, or at Activate when it went dark while docked. + [Fact] + public void AChunkTargetIsDroppedWhenItGoesDark() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(150f, .25f), Rock(150f, .5f))); + var eyeAt = float2(0, 0); + + // Its own sensor goes off: nothing sees the chunk, and the next tick drops it. + var blinded = SpawnShip(s, eyeAt, sensor: true); + var first = new ChunkId(s.Belts[0], 0); + Assert.True(blinded.SetTarget(first)); + blinded.Equipment.Single(item => item.Data.Name == "Eye").Enabled.Value = false; + Tick((blinded, eyeAt)); + Assert.True(blinded.Target.Value.IsNone); + + // The chunk breaks: it is dark to everyone, and the next tick drops it. + var watcher = SpawnShip(s, eyeAt, sensor: true); + var second = new ChunkId(s.Belts[0], 1); + Assert.True(watcher.SetTarget(second)); + Tick((watcher, eyeAt)); + Assert.False(watcher.Target.Value.IsNone); + Assert.True(s.Zone.Wear(second, 1e6f)); + Tick((watcher, eyeAt)); + Assert.True(watcher.Target.Value.IsNone); + + // It breaks while the holder is docked: Activate drops it before any tick runs. + var docked = SpawnShip(s, eyeAt, sensor: true); + var third = new ChunkId(s.Belts[0], 2); + Assert.True(docked.SetTarget(third)); + docked.Deactivate(); + Assert.True(s.Zone.Wear(third, 1e6f)); + docked.Activate(); + Assert.True(docked.Target.Value.IsNone); + } + + // One slot, two kinds, value equality: the same chunk is the same target, a chunk is never an entity, and an + // empty target is None whichever way it was made. + [Fact] + public void TargetRefEquality() + { + var s = BuildScene(); + var ship = SpawnShip(s, float2(0, 0)); + var other = SpawnShip(s, float2(10, 0)); + var field = new CultRecordKey("field"); + var chunk = new ChunkId(field, 3); + + Assert.True(new TargetRef(chunk).Equals(new TargetRef(new ChunkId(new CultRecordKey("field"), 3)))); + Assert.Equal(new TargetRef(chunk).GetHashCode(), new TargetRef(new ChunkId(new CultRecordKey("field"), 3)).GetHashCode()); + Assert.False(new TargetRef(chunk).Equals(new TargetRef(new ChunkId(field, 4)))); + Assert.False(new TargetRef(chunk).Equals(new TargetRef(new ChunkId(new CultRecordKey("other"), 3)))); + + Assert.True(new TargetRef(ship).Equals(new TargetRef(ship))); + Assert.False(new TargetRef(ship).Equals(new TargetRef(other))); + Assert.False(new TargetRef(ship).Equals(new TargetRef(chunk))); + Assert.False(new TargetRef(chunk).Equals(new TargetRef(ship))); + Assert.False(new TargetRef(ship).Equals(TargetRef.None)); + + Assert.True(TargetRef.None.IsNone); + Assert.True(new TargetRef((Entity) null).IsNone); + Assert.True(new TargetRef((Entity) null).Equals(TargetRef.None)); + Assert.False(new TargetRef(default(ChunkId)).IsNone); + Assert.False(new TargetRef(default(ChunkId)).Equals(TargetRef.None)); + Assert.False(new TargetRef(ship).IsNone); + Assert.True(((object) new TargetRef(chunk)).Equals(new TargetRef(chunk))); + } + + // Q12 A: a launcher never locks a chunk. The same lock weapon, geometry and detection build lock on a + // hostile ship; switched to a chunk, the lock drops to zero and stays there. + [Fact] + public void ChunkTargetIsNeverLocked() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var eyeAt = At(s, chunk) + float2(0, -100); + var shooter = SpawnShip(s, eyeAt, sensor: true, weaponRanges: 300f); + var enemy = SpawnShip(s, eyeAt + float2(0, 50)); + shooter.SetIff(enemy, true); + shooter.EntityInfoGathered[enemy] = 1; + shooter.LookDirection = float3(0, 0, 1); + var launcher = new LockWeapon(new LockWeaponData + { + LockSpeed = Constant(.5f), + LockAngle = Constant(180f), + DirectionImpact = Constant(1f) + }, shooter.Equipment.Single(item => item.Behaviors.Any(b => b is Weapon))); + + Assert.True(shooter.SetTarget(enemy)); + launcher.Execute(1f); + Assert.True(launcher.Lock > 0f, "positive control: the launcher locks a hostile ship"); + + Assert.True(shooter.SetTarget(chunk)); + launcher.Execute(1f); + Assert.Equal(0f, launcher.Lock); + launcher.Execute(1f); + Assert.Equal(0f, launcher.Lock); + } + + // Q16 B, the pick itself: a weighted choice by the belt's key -- the same key always gives the same kind, a + // kind of weight zero is never chosen, and the others come up in proportion to their weights. + [Fact] + public void FieldKindAssignmentIsAWeightedPickByKey() + { + var light = Kind("Light", 1f, 1f); + var never = Kind("Never", 1f, 0f); + var heavy = Kind("Heavy", 1f, 3f); + var s = BuildScene(new[] { light, never, heavy }); + var counts = new Dictionary { ["Light"] = 0, ["Never"] = 0, ["Heavy"] = 0 }; + for (var i = 0; i < 2000; i++) + { + var key = new CultRecordKey($"belt-{i}"); + var picked = FieldKinds.Assign(key, s.Cache); + Assert.True(picked.Key.Equals(FieldKinds.Assign(key, s.Cache).Key)); + counts[s.Cache.Get(picked).Name]++; + } + Assert.Equal(0, counts["Never"]); + Assert.InRange(counts["Heavy"] / 2000f, .7f, .8f); + + var empty = BuildScene(new[] { never }); + Assert.False(FieldKinds.Assign(new CultRecordKey("belt-0"), empty.Cache).IsSet()); + } + + // Q16 B, first load: a belt saved before field kinds existed gets its kind when a zone first loads it, by the + // same function generation uses, and the assignment is saved -- so a later catalog edit that would pick a + // different kind for that belt changes nothing about it. + [Fact] + public void FieldKindAssignmentIsOneFunction() + { + var a = Kind("A", 1f, 1f); + var b = Kind("B", 2f, 1f); + var s = BuildScene(new[] { a, b }, Belt(null, Rock(150f))); + var beltKey = s.Belts[0]; + var belt = (AsteroidBeltData) s.Zone.Planets[beltKey]; + Assert.True(belt.Kind.IsSet()); + Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey, s.Cache).Key)); + var assigned = belt.Kind.Key; + + // Saved: a fresh open of the same stores reads the kind back. + s.Cache.FlushAsync().Wait(); + s.Cache.Dispose(); + _caches.Remove(s.Cache); + var dir = Path.Combine(_root, $"scene{_fixtureCount - 1}"); + var reopened = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(reopened); + Assert.True(((AsteroidBeltData) reopened.Get(beltKey)).Kind.Key.Equals(assigned)); + + // Kept: a new, overwhelmingly weighted kind now wins the pick for this belt, yet loading it again leaves it. + reopened.Upsert(Kind("Overwhelming", 9f, 1e6f)); + Assert.False(FieldKinds.Assign(beltKey, reopened).Key.Equals(assigned)); + var items = new ItemManager(reopened, new ProvenanceLedger(), GameSettings(), _ => { }); + var reloaded = new Zone(items, PlanetSettings(), s.Pack, new GalaxyZone { Name = "MiningCut3Reload", Owner = null }, null); + Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned)); + } + + // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign on + // the belt's own key. Runs against a scratch copy of the shipped catalog with three kinds of different + // weights, so a pick by any other rule would disagree on some belt. + [Fact] + public void GenerationChoosesTheKindByTheSameFunction() + { + var shipped = Path.Combine(RestoredHullsTests.FindRepoRoot(), "GameData", "Aetheria.cc"); + var belts = 0; + for (uint seed = 1; seed <= 25 && belts < 3; seed++) + { + var dir = Path.Combine(_root, $"gen{seed}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(shipped, catalog); + using var cache = AetheriaStores.Open(catalog, Path.Combine(dir, "run.cc"), catalogWritable: true); + foreach (var existing in cache.GetAll().ToArray()) + { + existing.GenerationWeight = 0f; + cache.Upsert(existing); + } + cache.Upsert(Kind("Gen A", 1f, 1f)); + cache.Upsert(Kind("Gen B", 1f, 2f)); + cache.Upsert(Kind("Gen C", 1f, 4f)); + + Galaxy galaxy; + try + { + galaxy = new Galaxy(RestoredHullsTests.TutorialGalaxySettings(), RestoredHullsTests.TutorialBackgroundSettings(), + RestoredHullsTests.TutorialNameSettings(), cache, new PlayerSettings(), new DirectoryInfo(dir), _ => { }, null, seed); + } + catch (Exception) { continue; } // Galaxy's own seed-dependent flakiness (RestoredHullsTests) + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + foreach (var zone in galaxy.Zones.Take(4)) + { + var pack = ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance); + foreach (var body in pack.Planets) + { + if (!(cache.Get(body) is AsteroidBeltData belt)) continue; + belts++; + Assert.True(belt.Kind.IsSet()); + Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(body.Key, cache).Key)); + } + } + } + Assert.True(belts > 0, "no seed generated a belt; the generation path went unexercised"); + } } diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index b0a125ddf..0bde21341 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -46,7 +46,7 @@ public sealed class RestoredHullsTests return cache; } - private static string FindRepoRoot() + internal static string FindRepoRoot() { for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) @@ -59,7 +59,7 @@ public sealed class RestoredHullsTests // Shared tutorial-galaxy construction settings (Assets/Resources/Settings.asset's own values), factored out // so every test that needs a real tutorial Galaxy -- not just EntranceZoneAlwaysGetsADockedStationAcrossSeeds // -- builds it the same way instead of re-inlining the same ~20 fields. - private static ZoneGenerationSettings TutorialZoneSettings() => new ZoneGenerationSettings + internal static ZoneGenerationSettings TutorialZoneSettings() => new ZoneGenerationSettings { PlanetSafetyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 2.5f, Constant = 0 }, MassFloor = 1, SunMass = 5000, GasGiantMass = 1000, PlanetMass = 100, @@ -80,19 +80,19 @@ public sealed class RestoredHullsTests NameData = new string[0], }; - private static TutorialGenerationSettings TutorialGalaxySettings() => new TutorialGenerationSettings + internal static TutorialGenerationSettings TutorialGalaxySettings() => new TutorialGenerationSettings { ProtagonistFaction = "Miss", AntagonistFaction = "Zhe", BufferFaction = "Luc", NeutralFactions = new[] { "Aero", "Finch" }, QuestFaction = "Adras", LinkDensity = .5f, ZoneCount = 64, }; - private static SectorBackgroundSettings TutorialBackgroundSettings() => new SectorBackgroundSettings + internal static SectorBackgroundSettings TutorialBackgroundSettings() => new SectorBackgroundSettings { NoiseAmplitude = 1, NoiseOffset = .3f, NoiseGain = .7f, NoiseLacunarity = 2, NoiseFrequency = .1f, NoisePosition = 536.5106f, CloudExponent = 10, CloudAmplitude = .01f, }; - private static NameGeneratorSettings TutorialNameSettings() => + internal static NameGeneratorSettings TutorialNameSettings() => new NameGeneratorSettings { NameGeneratorMinLength = 5, NameGeneratorMaxLength = 10, NameGeneratorOrder = 3 }; // A station's orbit is a copy of its source planet's own (Parent, Distance) (ZoneGenerator.CreateLagrangeOrbit), diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index 478bc93c2..29bb3f954 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -174,7 +174,7 @@ public sealed class TargetingSystemTests : IDisposable { var (items, target, weapon, _, _, _) = BuildTarget(TestSettings()); var observer = BuildObserver(items, target.Zone); - observer.Target.Value = target; + observer.SetTarget(target); observer.EntityInfoGathered[target] = .29f; // below the single item's own tier (Armor, N==1) Assert.False(observer.TrySelectTargetItem(weapon)); @@ -186,12 +186,12 @@ public sealed class TargetingSystemTests : IDisposable Assert.Same(weapon, observer.ResolvedTargetItem); // A Target change nulls the aim point. - observer.Target.Value = null; + observer.SetTarget(TargetRef.None); Assert.Null(observer.TargetItem.Value); // Decay: re-select, then let info fall back below tier without touching TargetItem.Value at all -- // the raw field still holds the item (no loop cleared it), but the read accessor drops it. - observer.Target.Value = target; + observer.SetTarget(target); Assert.True(observer.TrySelectTargetItem(weapon)); observer.EntityInfoGathered[target] = .1f; Assert.Same(weapon, observer.TargetItem.Value); From 3d17ba035bdcfbc8a82db9751c83bb113a8c2cd3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:57:04 +0200 Subject: [PATCH 23/45] Mining Cut 3: the parked ResourceScanner stops evaluating stats nothing reads Chunks are detected by reflected light (Cut 3), not by a scanner. The behavior's per-tick Update of Range, MinimumDensity and ScanDuration had no reader anywhere; it goes, with the three properties and the interface. ResourceScannerData stays authored. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/ResourceScanner.cs | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs index 051dd5832..e54fa4a9f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs @@ -30,30 +30,16 @@ public class ResourceScannerData : BehaviorData // Cut 2 (docs/mining-cut.md, Q1=A): the dead survey Execute and its scan-target fields are gone // (ScanTarget/Asteroid/_scanTime/_scanTarget, and the always-false-until-scanned PlanetSurveyFloor write it -// worked toward). Range, MinimumDensity and ScanDuration stay authored data on this behavior until Q2 rules -// whether the scanner becomes a chunk's detection-range gate (docs/mining-cut.md Q2 consequence "Visibility"). -public class ResourceScanner : Behavior, IAlwaysUpdatedBehavior +// worked toward). Mining Cut 3 (docs/mining-cut-refresh.md): chunks are detected by reflected light, not by a +// scanner, so the per-tick evaluation of three stats nothing read is gone too. The behavior is parked: its data +// stays authored, and it does nothing. +public class ResourceScanner : Behavior { - private ResourceScannerData _data; - - public float Range { get; private set; } - public float MinimumDensity { get; private set; } - public float ScanDuration { get; private set; } - public ResourceScanner(ResourceScannerData data, EquippedItem item) : base(data, item) { - _data = data; } public ResourceScanner(ResourceScannerData data, ConsumableItemEffect item) : base(data, item) { - _data = data; - } - - public void Update(float delta) - { - Range = Evaluate(_data.Range); - MinimumDensity = Evaluate(_data.MinimumDensity); - ScanDuration = Evaluate(_data.ScanDuration); } } \ No newline at end of file From efd511832244197fd1bb7b4376e4cbc2bb0536b6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:57:41 +0200 Subject: [PATCH 24/45] AetherDb: field-kinds authors the first field kind, "Asteroid" A chunk's reflectivity comes from its belt's kind, so a catalog with no FieldKindData leaves every belt dark. The command dry-runs by default and, with "apply", authors "Asteroid" (cross-section 5 per schematic cell, weight 1) through one validated Commit batch. Both numbers are for operator review. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 46aae9575..3a0495d7b 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,8 +36,9 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); + case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], field-kinds [apply]"); return 1; } } @@ -1474,4 +1475,36 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } + + // Mining Cut 3 (docs/mining-cut-refresh.md): the first field kind. A chunk is detected by reflected light, and + // its reflectivity comes from its belt's kind, so a catalog with no kind leaves every belt dark. This authors + // the one kind Cut 3 needs, "Asteroid"; Cut 7 authors the rest. CrossSection is reflectivity per schematic + // cell of chunk area: 5 sits inside the shipped hulls' 2-15 per cell (Longinus, Djinni), so a mid-size rock + // (3-6 units across, 7-28 cells) reflects like a small ship. Both numbers are for the operator to review. + private static int FieldKindsCatalog(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var kinds = db.Cache.GetAll().ToArray(); + foreach (var kind in kinds) + Console.WriteLine($" {kind.Name,-12} cross-section {kind.CrossSection,5:0.##} per cell, weight {kind.GenerationWeight:0.##}"); + + if (kinds.Any(kind => kind.Name == "Asteroid")) + { + Console.WriteLine("\"Asteroid\" is already authored; nothing to do."); + return 0; + } + + var asteroid = new FieldKindData { Name = "Asteroid", CrossSection = 5f, GenerationWeight = 1f }; + Console.WriteLine($"Adds \"Asteroid\": cross-section {asteroid.CrossSection} per cell, weight {asteroid.GenerationWeight}."); + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to author it."); + return 0; + } + + CultRecordRefs.Validate(asteroid); + db.Cache.Commit(batch => batch.Upsert(typeof(FieldKindData), asteroid)); + Console.WriteLine("\nAuthored \"Asteroid\" in Aetheria.cc"); + return 0; + } } From 09d037cb417c905f5a587d14d9e71c91dbf17055 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:08:08 +0200 Subject: [PATCH 25/45] Mining Cut 3 tests: tick a ship once before it looks, and open the shipped catalog copy scoped A ship's equipment comes online on its first tick (UpdatePerformance), so a sensor sees nothing before it: the chunk tests tick each observer once before asking what it sees or picking a target. The generation test opens its copy of the shipped catalog through a registry scoped to the shipped assembly, as RestoredHullsTests does, so the test assembly's own catalog global is not demanded of it. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index cd6d5bff9..c67b9e752 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -301,6 +301,7 @@ public sealed class MiningCut3Tests : IDisposable Assert.DoesNotContain(dark, reachable); var inTheLight = SpawnShip(s, At(s, lit) + float2(0, -30), sensor: true); + Tick((inTheLight, At(s, lit) + float2(0, -30))); // equipment comes online on its first tick Assert.True(inTheLight.SetTarget(lit)); Assert.True(inTheLight.Target.Value.Equals(new TargetRef(lit))); } @@ -366,6 +367,7 @@ public sealed class MiningCut3Tests : IDisposable var chunk = new ChunkId(s.Belts[0], 0); var eyeAt = At(s, chunk) + float2(0, -100); var observer = SpawnShip(s, eyeAt, sensor: true); + Tick((observer, eyeAt)); Assert.True(observer.ChunkVisible(chunk)); Assert.True(s.Zone.Wear(chunk, PlanetSettings().AsteroidHitpoints.Evaluate(.5f) * 2f)); @@ -450,6 +452,7 @@ public sealed class MiningCut3Tests : IDisposable var chunk = new ChunkId(s.Belts[0], 0); var eyeAt = At(s, chunk) + float2(30, -100); var observer = SpawnShip(s, eyeAt, sensor: true); + Tick((observer, eyeAt)); Assert.True(observer.SetTarget(chunk)); for (var tick = 0; tick < 5; tick++) Tick((observer, eyeAt)); @@ -472,6 +475,7 @@ public sealed class MiningCut3Tests : IDisposable // Its own sensor goes off: nothing sees the chunk, and the next tick drops it. var blinded = SpawnShip(s, eyeAt, sensor: true); + Tick((blinded, eyeAt)); var first = new ChunkId(s.Belts[0], 0); Assert.True(blinded.SetTarget(first)); blinded.Equipment.Single(item => item.Data.Name == "Eye").Enabled.Value = false; @@ -480,6 +484,7 @@ public sealed class MiningCut3Tests : IDisposable // The chunk breaks: it is dark to everyone, and the next tick drops it. var watcher = SpawnShip(s, eyeAt, sensor: true); + Tick((watcher, eyeAt)); var second = new ChunkId(s.Belts[0], 1); Assert.True(watcher.SetTarget(second)); Tick((watcher, eyeAt)); @@ -490,6 +495,7 @@ public sealed class MiningCut3Tests : IDisposable // It breaks while the holder is docked: Activate drops it before any tick runs. var docked = SpawnShip(s, eyeAt, sensor: true); + Tick((docked, eyeAt)); var third = new ChunkId(s.Belts[0], 2); Assert.True(docked.SetTarget(third)); docked.Deactivate(); @@ -539,6 +545,7 @@ public sealed class MiningCut3Tests : IDisposable var chunk = new ChunkId(s.Belts[0], 0); var eyeAt = At(s, chunk) + float2(0, -100); var shooter = SpawnShip(s, eyeAt, sensor: true, weaponRanges: 300f); + Tick((shooter, eyeAt)); var enemy = SpawnShip(s, eyeAt + float2(0, 50)); shooter.SetIff(enemy, true); shooter.EntityInfoGathered[enemy] = 1; @@ -617,6 +624,20 @@ public sealed class MiningCut3Tests : IDisposable Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned)); } + // A writable copy of the shipped catalog plus a scratch run store, through a registry scoped to the shipped + // assembly's own document types (RestoredHullsTests' composition): this test assembly's own catalog global + // would otherwise be demanded of a catalog that was never authored with it. + private static CultCache OpenShippedCatalogCopy(string catalogPath, string runPath) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttributes(typeof(CultDocumentAttribute), false).Length > 0)); + var cache = new CultCache(registry); + cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(catalogPath, false), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(runPath), AetheriaStores.RunTypes); + return cache; + } + // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign on // the belt's own key. Runs against a scratch copy of the shipped catalog with three kinds of different // weights, so a pick by any other rule would disagree on some belt. @@ -631,7 +652,7 @@ public sealed class MiningCut3Tests : IDisposable Directory.CreateDirectory(dir); var catalog = Path.Combine(dir, "Aetheria.cc"); File.Copy(shipped, catalog); - using var cache = AetheriaStores.Open(catalog, Path.Combine(dir, "run.cc"), catalogWritable: true); + using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc")); foreach (var existing in cache.GetAll().ToArray()) { existing.GenerationWeight = 0f; From 410173eb2793ed4025d8e16bec0fa56612f40182 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 14:00:14 +0200 Subject: [PATCH 26/45] Fire control test: a catalog rewritten since slot 32 loads its weapons exactly ShippedCatalogIsNeutralOnBlastRadiusAndFuse also pinned that every stored weapon record predates slot 32 (CompatibleDrift, slot 32 defaulted). The field- kinds authoring pass rewrote every catalog record through CultCache, so the weapons now store slot 32 and load Exact. The rule the test protects stands: no weapon authors a blast radius or fuse, and a record that does drift may drift by slot 32 and nothing else. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index b90bc4700..5bf529d46 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -2600,10 +2600,13 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Null(w.Fuse); } + // Mining Cut 3: a catalog written since slot 32 existed (AetherDb field-kinds rewrote every record) stores + // the slot, so its weapons load exactly. A catalog that predates it may drift by slot 32 and nothing else. var reports = store.LastSchemaMigrationReports.Where(r => r.LocalSchemaName == "aetheria.weaponitemdata").ToList(); Assert.NotEmpty(reports); foreach (var report in reports) { + if (report.Kind == CultSchemaMigrationKind.Exact) continue; Assert.Equal(CultSchemaMigrationKind.CompatibleDrift, report.Kind); Assert.Equal(new[] { 32 }, report.DefaultedMissingSlots); Assert.Empty(report.IgnoredExtraSlots); From d24acff7b4d7beea22849e40e33f182969d15f89 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:19:09 +0200 Subject: [PATCH 27/45] Catalog: author the "Asteroid" field kind (operator review) tools/AetherDb field-kinds apply, run on Yggdrasil against catalog oid 2aa79c0a. One FieldKindData record: "Asteroid", cross-section 5 per schematic cell, generation weight 1. Without a kind every belt is dark (Cut 3), so this is what makes rocks seeable in play. The two numbers are placeholders for the operator to review; Cut 7 authors the full content. If the catalog changes upstream before this merges, rerun the command on the new catalog rather than merging the binary. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 200c93e22..39076ca16 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2aa79c0adca4e50b608f1c7154ad847777d62af83b918b6de19eb881e8fdd7e9 -size 12698108 +oid sha256:b698e224f18306f398b8509b7234127c0343e76925eebf39eff2674c8510195b +size 12699332 From cb96f77c8613e61ccc9a0d4cd87b34bb1e9ed5d8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 20:03:59 +0200 Subject: [PATCH 28/45] Mining Cut 3 tests: two-sensor parity, the cap at one, and a chunk's bearing - ChunkInfoMatchesAnEntityHeldStillUnderTwoSensors pins that the settled value divides the summed rate by the number of sensors as well as the decay. - ChunkInfoIsCappedAtOne pins the saturate the per-tick rule applies. - AChunkTargetBearsThroughItsOwnPosition pins the arc gate reading a chunk's planar position (x and z, not swapped) and that a chunk never safes a weapon. The fixture's settings gain a 90-degree default arc for it. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index c67b9e752..90c7fa042 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -43,7 +43,8 @@ public sealed class MiningCut3Tests : IDisposable VisibilityDecay = .5f, TargetInfoDecay = .5f, TargetDetectionInfoThreshold = .1f, - SchematicCellSize = 2f + SchematicCellSize = 2f, + FiringArc = 90f }; // Flat terrain (no zone depth, no gravity wells), one sun whose light reaches 1000 units, and the chunk @@ -176,10 +177,11 @@ public sealed class MiningCut3Tests : IDisposable // A ship held at a planar position, facing +z. `sensor` fits the scene's sensor; `crossSection` fits a // reflector of that cross-section (its own design, so ships in one scene can differ); each of `weaponRanges` // fits a gun of that range. - private Ship SpawnShip(Scene s, float2 at, bool sensor = false, float crossSection = 0f, params float[] weaponRanges) + private Ship SpawnShip(Scene s, float2 at, bool sensor = false, float crossSection = 0f, bool secondSensor = false, params float[] weaponRanges) { var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); if (sensor) Assert.True(ship.TryEquip(Mint(s, s.Eye))); + if (secondSensor) Assert.True(ship.TryEquip(Mint(s, s.Eye))); if (crossSection > 0f) { var mirror = Gear($"Mirror {crossSection}", new ReflectorData { CrossSection = Constant(crossSection) }); @@ -624,6 +626,68 @@ public sealed class MiningCut3Tests : IDisposable Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned)); } + // Q14 A with two sensors: each runs the per-tick rule on the same info, so each adds its gain and each applies + // the decay. Two identical sensors settle exactly where one does -- the on-demand value divides the summed rate + // by the number of sensors as well as by the decay. + [Fact] + public void ChunkInfoMatchesAnEntityHeldStillUnderTwoSensors() + { + var kind = Kind("Asteroid", 1f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var chunkAt = At(s, chunk); + var eyeAt = float2(50, -150); + var observer = SpawnShip(s, eyeAt, sensor: true, secondSensor: true); + var cells = s.Zone.ChunkRadius(chunk) / 2f; + var rock = SpawnShip(s, chunkAt, crossSection: kind.CrossSection * PI * cells * cells); + + for (var tick = 0; tick < 600; tick++) Tick((rock, chunkAt), (observer, eyeAt)); + + var settled = observer.ChunkInfo(chunk); + var perTick = observer.EntityInfoGathered[rock]; + Assert.InRange(settled, .12f, .9f); + var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt); + Assert.True(abs(perTick - discrete) <= 2e-3f * settled, + $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); + } + + // The per-tick rule saturates info at 1, so the settled value is capped there too: a bright chunk close by is + // fully known, not more than fully. + [Fact] + public void ChunkInfoIsCappedAtOne() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var eyeAt = At(s, chunk) + float2(0, -30); + var observer = SpawnShip(s, eyeAt, sensor: true); + Tick((observer, eyeAt)); + Assert.Equal(1f, observer.ChunkInfo(chunk)); + } + + // A chunk target bears through its own planar position: the arc gate passes a chunk ahead and refuses one off + // to the side (a 90-degree arc facing +z), and a chunk has no stance to safe a weapon against. + [Fact] + public void AChunkTargetBearsThroughItsOwnPosition() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f, .25f), Rock(150f))); + var ahead = new ChunkId(s.Belts[0], 0); // (0, 150) + var aside = new ChunkId(s.Belts[0], 1); // (150, 0) + var eyeAt = float2(-10, 50); // ahead is at (10, 100) from here, aside at (160, -50) + var shooter = SpawnShip(s, eyeAt, sensor: true, weaponRanges: 300f); + Tick((shooter, eyeAt)); + var gun = shooter.Weapons.Single(); + + Assert.True(shooter.SetTarget(ahead)); + Assert.True(gun.ArcAllowsFire); + Assert.True(gun.StanceAllowsFire); + + Assert.True(shooter.SetTarget(aside)); + Assert.False(gun.ArcAllowsFire); + Assert.True(gun.StanceAllowsFire); + } + // A writable copy of the shipped catalog plus a scratch run store, through a registry scoped to the shipped // assembly's own document types (RestoredHullsTests' composition): this test assembly's own catalog global // would otherwise be demanded of a catalog that was never authored with it. From 55ca6d3d22f1e7704392820e43c062aaac54e847 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:21:12 +0200 Subject: [PATCH 29/45] Mining Cut 3 tests: non-degenerate fixtures the mutation probes exposed - SensorGainExtractionChangesNothing traced a saturated sensor: a hull's own heat makes a ship ~800 bright, so the observer's info sat at 1 every tick and the hash pinned nothing (a hand probe dividing by the bearing response, and one pricing a ping as a passive tick, both survived). The scene now observes through a weak sensor (Dim eye, sensitivity 0.01) and asserts the trace is unsaturated and carries a visible ping. Golden re-recorded against the pre-extraction Sensor.Execute (cb4a1dbd with this scene patched in); the post-extraction code gives the same hash, 919B7EE7... - Cycling reach fits its guns 250, 120, 350, so the last active gun is not the longest (a "last weapon wins" reach survived the old order). - The weighted pick uses three positive weights, so a pick that falls through to the last kind is told apart (it survived with two). - The chunk query runs around a belt centred off the origin. - TargetRef hashes an entity target and None. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 77 +++++++++++++------ 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 90c7fa042..04536d829 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -78,6 +78,7 @@ public sealed class MiningCut3Tests : IDisposable public Zone Zone; public HullData Hull; public GearData Eye; + public GearData DimEye; public ZonePack Pack; public List Belts = new List(); public List Kinds = new List(); @@ -94,7 +95,10 @@ public sealed class MiningCut3Tests : IDisposable private static Asteroid Rock(float distance, float phase = 0f, float size = .5f) => new Asteroid { Distance = distance, Phase = phase, Size = size, RotationSpeed = .1f }; - private Scene BuildScene(FieldKindData[] kinds = null, params BeltSpec[] belts) + private Scene BuildScene(FieldKindData[] kinds = null, params BeltSpec[] belts) => BuildSceneAt(float2.zero, kinds, belts); + + // The same scene with its sun, and so every belt centre, at `sunAt`. + private Scene BuildSceneAt(float2 sunAt, FieldKindData[] kinds, params BeltSpec[] belts) { var dir = Path.Combine(_root, $"scene{_fixtureCount++}"); Directory.CreateDirectory(dir); @@ -116,10 +120,23 @@ public sealed class MiningCut3Tests : IDisposable PingCooldown = Constant(4f) }); cache.Upsert(eye); + // A far weaker sensor, for scenes of ships: a hull's own heat makes it hundreds of times brighter than a rock, + // and the ordinary sensor would saturate on it at once. + var dimEye = Gear("Dim eye", new SensorData + { + Sensitivity = Constant(.01f), + SensitivityCurve = Falloff(), + PingBoost = Constant(.02f), + PingEnergy = Constant(0f), + PingVisibility = Constant(5f), + PingRange = Constant(300f), + PingCooldown = Constant(4f) + }); + cache.Upsert(dimEye); foreach (var kind in kinds ?? new FieldKindData[0]) cache.Upsert(kind); var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); - var sunOrbit = cache.Upsert(new OrbitData()).Key; + var sunOrbit = cache.Upsert(new OrbitData { FixedPosition = sunAt }).Key; var sun = cache.Upsert(new SunData { Orbit = new CultRecordRef(sunOrbit) }).Key; var pack = new ZonePack { @@ -129,7 +146,7 @@ public sealed class MiningCut3Tests : IDisposable Mass = 10000f, Time = 0 }; - var scene = new Scene { Cache = cache, Items = items, Hull = hull, Eye = eye, Pack = pack }; + var scene = new Scene { Cache = cache, Items = items, Hull = hull, Eye = eye, DimEye = dimEye, Pack = pack }; scene.Kinds.AddRange(kinds ?? new FieldKindData[0]); foreach (var spec in belts) { @@ -177,11 +194,12 @@ public sealed class MiningCut3Tests : IDisposable // A ship held at a planar position, facing +z. `sensor` fits the scene's sensor; `crossSection` fits a // reflector of that cross-section (its own design, so ships in one scene can differ); each of `weaponRanges` // fits a gun of that range. - private Ship SpawnShip(Scene s, float2 at, bool sensor = false, float crossSection = 0f, bool secondSensor = false, params float[] weaponRanges) + private Ship SpawnShip(Scene s, float2 at, bool sensor = false, float crossSection = 0f, bool secondSensor = false, bool dimSensor = false, params float[] weaponRanges) { var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); if (sensor) Assert.True(ship.TryEquip(Mint(s, s.Eye))); if (secondSensor) Assert.True(ship.TryEquip(Mint(s, s.Eye))); + if (dimSensor) Assert.True(ship.TryEquip(Mint(s, s.DimEye))); if (crossSection > 0f) { var mirror = Gear($"Mirror {crossSection}", new ReflectorData { CrossSection = Constant(crossSection) }); @@ -221,11 +239,11 @@ public sealed class MiningCut3Tests : IDisposable private static uint Bits(float value) => BitConverter.ToUInt32(BitConverter.GetBytes(value), 0); - // The per-tick entity detection loop is unchanged by the extraction of its gain rule: a fixed scene (two - // reflecting ships at different ranges and bearings, a ping part way through) yields the same - // EntityInfoGathered trace, bit for bit, as it did before Sensor.Gain existed. The golden hash was recorded - // against the pre-extraction Sensor.Execute. - private const string Golden = "6C23DF2584646E976A11CB0B8013A18499F5DCCABD86C7A840F201899984D737"; + // The per-tick entity detection loop is unchanged by the extraction of its gain rule: a fixed scene (two ships + // at different ranges and bearings, seen by a weak sensor so neither saturates, a ping part way through) + // yields the same EntityInfoGathered trace, bit for bit, as it did before Sensor.Gain existed. The golden hash + // was recorded against the pre-extraction Sensor.Execute (cb4a1dbd with this scene patched in). + private const string Golden = "919B7EE7C5D1B40F774EE34283D62DE016862FE799B3EF16362D7FC75FD73C79"; [Fact] public void SensorGainExtractionChangesNothing() @@ -233,23 +251,27 @@ public sealed class MiningCut3Tests : IDisposable var s = BuildScene(); var eyeAt = float2(0, 50); var nearAt = float2(30, 120); - var farAt = float2(-200, 90); - var observer = SpawnShip(s, eyeAt, sensor: true); - var near = SpawnShip(s, nearAt, crossSection: 40f); - var far = SpawnShip(s, farAt, crossSection: 400f); + var farAt = float2(-150, 90); + var observer = SpawnShip(s, eyeAt, dimSensor: true); + var near = SpawnShip(s, nearAt, crossSection: .2f); + var far = SpawnShip(s, farAt, crossSection: 9f); var trace = new List(); + var nearTrace = new List(); for (var tick = 0; tick < 60; tick++) { if (tick == 5) observer.Sensor.Ping(); Tick((near, nearAt), (far, farAt), (observer, eyeAt)); trace.AddRange(BitConverter.GetBytes(Bits(observer.EntityInfoGathered[near]))); trace.AddRange(BitConverter.GetBytes(Bits(observer.EntityInfoGathered[far]))); + nearTrace.Add(observer.EntityInfoGathered[near]); } - // Not a degenerate scene: both targets gathered some info, neither saturated. - Assert.InRange(observer.EntityInfoGathered[near], .01f, .99f); - Assert.InRange(observer.EntityInfoGathered[far], .01f, .99f); + // Not a degenerate scene: both targets settle well short of saturation, and the ping visibly lifts the + // near one above where it settles, so the trace carries the passive rule, the bearing and the ping. + Assert.InRange(observer.EntityInfoGathered[near], .05f, .8f); + Assert.InRange(observer.EntityInfoGathered[far], .05f, .8f); + Assert.True(nearTrace.Max() > observer.EntityInfoGathered[near] + .1f, "the ping left no mark on the trace"); var hash = Convert.ToHexString(SHA256.HashData(trace.ToArray())); Assert.True(hash == Golden, $"the EntityInfoGathered trace changed: hash {hash}"); } @@ -379,7 +401,8 @@ public sealed class MiningCut3Tests : IDisposable } // Q13 A: the reach for offering a chunk is the longest range among the entity's ACTIVE weapons. Two visible - // chunks, 200 and about 300 away; guns of 120, 250 and 350, the last switched off. + // chunks, 200 and about 300 away; guns of 250, 120 and 350 fitted in that order, the last switched off -- so the + // last active gun is not the longest one. [Fact] public void CyclingReachIsTheLongestActiveWeaponRange() { @@ -389,7 +412,7 @@ public sealed class MiningCut3Tests : IDisposable var near = new ChunkId(s.Belts[0], 0); var farther = new ChunkId(s.Belts[0], 1); var eyeAt = float2(150, -200); - var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 120f, 250f, 350f }); + var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 250f, 120f, 350f }); Tick((observer, eyeAt)); // weapons read their ranges when they run Assert.Equal(200f, length(At(s, near) - eyeAt), 2); Assert.Equal(300f, length(At(s, farther) - eyeAt), 2); @@ -422,7 +445,8 @@ public sealed class MiningCut3Tests : IDisposable { var kind = Kind("Asteroid", 4f); var rocks = Enumerable.Range(0, 12).Select(i => Rock(200f + i * 15f, i / 12f)).ToArray(); - var s = BuildScene(new[] { kind }, Belt(kind, rocks)); + var centre = float2(400, -250); // off the origin, so a query that ignored the belt centre would disagree + var s = BuildSceneAt(centre, new[] { kind }, Belt(kind, rocks)); var belt = s.Belts[0]; Assert.True(s.Zone.Wear(new ChunkId(belt, 5), 1e6f)); // broken @@ -430,8 +454,8 @@ public sealed class MiningCut3Tests : IDisposable var sawSome = false; foreach (var (from, range) in new[] { - (float2(0, 0), 150f), (float2(0, 0), 230f), (float2(0, 0), 400f), - (float2(220, 0), 40f), (float2(-300, 50), 120f), (float2(600, 0), 360f), (float2(600, 0), 200f) + (centre + float2(0, 0), 150f), (centre + float2(0, 0), 230f), (centre + float2(0, 0), 400f), + (centre + float2(220, 0), 40f), (centre + float2(-300, 50), 120f), (centre + float2(600, 0), 360f), (centre + float2(600, 0), 200f) }) { s.Zone.ChunksNear(from, range, found); @@ -535,6 +559,8 @@ public sealed class MiningCut3Tests : IDisposable Assert.False(new TargetRef(default(ChunkId)).Equals(TargetRef.None)); Assert.False(new TargetRef(ship).IsNone); Assert.True(((object) new TargetRef(chunk)).Equals(new TargetRef(chunk))); + Assert.Equal(new TargetRef(ship).GetHashCode(), new TargetRef(ship).GetHashCode()); + Assert.Equal(TargetRef.None.GetHashCode(), new TargetRef((Entity) null).GetHashCode()); } // Q12 A: a launcher never locks a chunk. The same lock weapon, geometry and detection build lock on a @@ -577,9 +603,10 @@ public sealed class MiningCut3Tests : IDisposable { var light = Kind("Light", 1f, 1f); var never = Kind("Never", 1f, 0f); + var middle = Kind("Middle", 1f, 2f); var heavy = Kind("Heavy", 1f, 3f); - var s = BuildScene(new[] { light, never, heavy }); - var counts = new Dictionary { ["Light"] = 0, ["Never"] = 0, ["Heavy"] = 0 }; + var s = BuildScene(new[] { light, never, middle, heavy }); + var counts = new Dictionary { ["Light"] = 0, ["Never"] = 0, ["Middle"] = 0, ["Heavy"] = 0 }; for (var i = 0; i < 2000; i++) { var key = new CultRecordKey($"belt-{i}"); @@ -588,7 +615,9 @@ public sealed class MiningCut3Tests : IDisposable counts[s.Cache.Get(picked).Name]++; } Assert.Equal(0, counts["Never"]); - Assert.InRange(counts["Heavy"] / 2000f, .7f, .8f); + Assert.InRange(counts["Light"] / 2000f, 1f / 6 - .04f, 1f / 6 + .04f); + Assert.InRange(counts["Middle"] / 2000f, 2f / 6 - .04f, 2f / 6 + .04f); + Assert.InRange(counts["Heavy"] / 2000f, 3f / 6 - .04f, 3f / 6 + .04f); var empty = BuildScene(new[] { never }); Assert.False(FieldKinds.Assign(new CultRecordKey("belt-0"), empty.Cache).IsSet()); From f88d1fc1b3346fb1c31d596dee75116005ea198d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 13:02:26 +0200 Subject: [PATCH 30/45] Mining Cut 3 fix: launchers do not count toward rock reach (Q12 A) Weapon.CanMine is the one mining predicate; LockWeapon overrides it to false. Entity.VisibleChunksInReach counts only weapons that can mine. Tests: LaunchersDoNotCountTowardChunkReach (a launcher-only ship is offered no rock; a gun of 200 beside a launcher of 400 is offered the near rock only) and AnOfflineWeaponAddsNoReach (Soul F8: reach reads Active, not Enabled). Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/LockWeapon.cs | 3 + .../Scripts/ServerShared/Behaviors/Weapon.cs | 5 ++ Assets/Scripts/ServerShared/Entity.cs | 8 +-- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 72 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index a290d1481..6b88f4ba4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -65,6 +65,9 @@ public class LockWeapon : InstantWeapon // before a shot is ever attempted. public bool IsLocked => _lock > .99f; + // Q12 A: a launcher cannot mine. + public override bool CanMine => false; + public LockWeapon(LockWeaponData data, EquippedItem item) : base(data, item) { diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index b8f70699c..450490cfe 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -88,6 +88,11 @@ public abstract class Weapon : Behavior, IActivatedBehavior public float Spread { get; protected set; } public float Velocity { get; protected set; } + // Mining Cut 3 fix (Q12 A, "launchers cannot mine"): whether this weapon can mine a chunk. The one mining + // predicate: chunk reach (Entity.VisibleChunksInReach) reads it, and so does anything that decides a weapon + // may work a chunk. A launcher overrides it. + public virtual bool CanMine => true; + protected bool _firing; public bool Firing diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 48142c9ca..908741173 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -371,14 +371,14 @@ public abstract class Entity // enters VisibleEntities: its info exceeds TargetDetectionInfoThreshold. public bool ChunkVisible(ChunkId chunk) => ChunkInfo(chunk) > ItemManager.GameplaySettings.TargetDetectionInfoThreshold; - // Mining Cut 3 (Q13 A): the reach for picking a chunk -- the longest range among this entity's active weapons. - // Every visible chunk within it, written into `into` (cleared first). Cycling and any programmatic chunk pick - // read this, so there is one reach rule. + // Mining Cut 3 (Q13 A): the reach for picking a chunk -- the longest range among this entity's active weapons + // that can mine (Weapon.CanMine, Q12 A). Every visible chunk within it, written into `into` (cleared first). + // Cycling and any programmatic chunk pick read this, so there is one reach rule. public void VisibleChunksInReach(List into) { var reach = 0f; foreach (var weapon in _weapons) - if (weapon.Item == null || weapon.Item.Active.Value) + if (weapon.CanMine && (weapon.Item == null || weapon.Item.Active.Value)) reach = max(reach, weapon.Range); Zone.ChunksNear(Position.xz, reach, into); into.RemoveAll(chunk => !ChunkVisible(chunk)); diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 04536d829..1caee68d1 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -777,4 +777,76 @@ public sealed class MiningCut3Tests : IDisposable } Assert.True(belts > 0, "no seed generated a belt; the generation path went unexercised"); } + + // ==== Mining Cut 3 fix batch (Soul's findings) ==== + + // Q12 A ("launchers cannot mine") in the reach rule: reach counts only weapons that can mine (Weapon.CanMine). + // Two visible rocks, 150 and 250 away. A launcher of range 400 alone is offered neither; a gun of range 200 + // beside it is offered the near one only, so the launcher's range still counts for nothing. + [Fact] + public void LaunchersDoNotCountTowardChunkReach() + { + var kind = Kind("Asteroid", 40f); + var eyeAt = float2(150, -250); + var nearAt = float2(150, -100); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(length(nearAt), 1f + atan2(nearAt.y, nearAt.x) / (2 * PI)))); + var far = new ChunkId(s.Belts[0], 0); + var near = new ChunkId(s.Belts[0], 1); + Assert.Equal(250f, length(At(s, far) - eyeAt), 2); + Assert.Equal(150f, length(At(s, near) - eyeAt), 2); + var launcher = Gear("Launcher", new LockWeaponData { Range = Constant(400f), LockSpeed = Constant(.5f), LockAngle = Constant(30f), DirectionImpact = Constant(1f) }); + s.Cache.Upsert(launcher); + var gun = Gear("Gun 200", new InstantWeaponData { Range = Constant(200f) }); + s.Cache.Upsert(gun); + + Ship Fit(params GearData[] weapons) + { + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + Assert.True(ship.TryEquip(Mint(s, s.Eye))); + foreach (var weapon in weapons) Assert.True(ship.TryEquip(Mint(s, weapon))); + s.Zone.Entities.Add(ship); + Hold(ship, eyeAt); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + Tick((ship, eyeAt)); // weapons read their ranges when they run + return ship; + } + + var offered = new List(); + var launcherOnly = Fit(launcher); + Assert.Equal(400f, launcherOnly.Weapons.Single().Range); + Assert.True(launcherOnly.ChunkVisible(far)); + Assert.True(launcherOnly.ChunkVisible(near)); + launcherOnly.VisibleChunksInReach(offered); + Assert.Empty(offered); + + var withGun = Fit(launcher, gun); + withGun.VisibleChunksInReach(offered); + Assert.Equal(new[] { near }, offered); + } + + // Q13 A reads Active, not Enabled (Soul F8): a gun that is switched on but offline (worn out) adds no reach. + [Fact] + public void AnOfflineWeaponAddsNoReach() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, + Belt(kind, Rock(150f), Rock(length(float2(150, 100)), atan2(100f, 150f) / (2 * PI)))); + var near = new ChunkId(s.Belts[0], 0); + var farther = new ChunkId(s.Belts[0], 1); + var eyeAt = float2(150, -200); + var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 250f, 350f }); + Tick((observer, eyeAt)); + var offered = new List(); + observer.VisibleChunksInReach(offered); + Assert.Equal(new[] { near, farther }, offered); + + var longGun = observer.Equipment.Single(item => item.Data.Name == "Gun 350"); + longGun.EquippableItem.Durability = 0f; + Tick((observer, eyeAt)); + Assert.True(longGun.Enabled.Value); + Assert.False(longGun.Active.Value); + observer.VisibleChunksInReach(offered); + Assert.Equal(new[] { near }, offered); + } } From 83d8371e9c71d6b8782d123d119d46b4610b56fb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 13:02:27 +0200 Subject: [PATCH 31/45] Mining Cut 3 tests: commit Soul's probes and the shipped field kind check - AFirstLoadKindIsSavedFromACleanStore: FieldKinds.Ensure's kind is saved when the store was clean at load (ZoneData.cs:162). - SetTargetRefusesALitChunkBeyondSight: a lit rock too far to see is refused (Entity.cs:345). - TheChunkQueryMatchesBruteForce: 3000 random queries, off-origin, two belts, across the outer skip edge (Soul F7). - ThePingArmIsUnchangedByTheGainExtraction: Soul's ping sweep, golden recorded at cb4a1dbd; kills the Sensor.cs:168 and :189 mutants. - TheShippedCatalogHasAPickableFieldKind (Soul F2). Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 1caee68d1..f84c73efd 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -849,4 +849,163 @@ public sealed class MiningCut3Tests : IDisposable observer.VisibleChunksInReach(offered); Assert.Equal(new[] { near }, offered); } + + // Q16 B on the clean-store path (Soul P2): a belt saved kindless, reopened from disk with nothing dirty, and + // loaded by a Zone, has its first-load kind saved by the next flush. + [Fact] + public void AFirstLoadKindIsSavedFromACleanStore() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }); + var beltOrbit = s.Cache.Upsert(new OrbitData { Parent = s.Pack.Orbits[0], Distance = 150f }).Key; + var beltKey = s.Cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef(beltOrbit), Asteroids = new[] { Rock(150f) } }).Key; + s.Pack.Orbits.Add(new CultRecordRef(beltOrbit)); + s.Pack.Planets.Add(new CultRecordRef(beltKey)); + s.Cache.FlushAsync().Wait(); + s.Cache.Dispose(); + _caches.Remove(s.Cache); + var dir = Path.Combine(_root, $"scene{_fixtureCount - 1}"); + + var clean = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(clean); + Assert.False(clean.IsDirty); + Assert.False(((AsteroidBeltData) clean.Get(beltKey)).Kind.IsSet()); + var zone = new Zone(new ItemManager(clean, new ProvenanceLedger(), GameSettings(), _ => { }), PlanetSettings(), s.Pack, + new GalaxyZone { Name = "Clean", Owner = null }, null); + var assigned = ((AsteroidBeltData) zone.Planets[beltKey]).Kind; + Assert.True(assigned.IsSet()); + clean.FlushAsync().Wait(); + clean.Dispose(); + _caches.Remove(clean); + + var reopened = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: false); + _caches.Add(reopened); + Assert.True(((AsteroidBeltData) reopened.Get(beltKey)).Kind.Key.Equals(assigned.Key), "the first-load kind was not saved"); + } + + // SetTarget refuses a lit chunk the entity cannot see because it is too far away, not only a dark one (Soul P9). + [Fact] + public void SetTargetRefusesALitChunkBeyondSight() + { + var kind = Kind("Asteroid", 1f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var eyeAt = At(s, chunk) + float2(0, -900); + var ship = SpawnShip(s, eyeAt, sensor: true); + Tick((ship, eyeAt)); + Assert.True(s.Zone.ChunkVisibility(chunk) > 0f); + Assert.InRange(ship.ChunkInfo(chunk), 1e-3f, .1f); // seen a little, below the detection threshold + Assert.False(ship.ChunkVisible(chunk)); + Assert.False(ship.SetTarget(chunk)); + Assert.True(ship.Target.Value.IsNone); + } + + // The chunk query equals brute force (Soul P10): 3000 random queries off the origin, over two belts, many of + // them from beyond a belt's outer edge where the belt skip decides. + [Fact] + public void TheChunkQueryMatchesBruteForce() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(11); + var rocks = Enumerable.Range(0, 400).Select(i => Rock(200f + (float) rng.NextDouble() * 150f, (float) rng.NextDouble())).ToArray(); + var centre = float2(-700, 333); + var s = BuildSceneAt(centre, new[] { kind }, Belt(kind, rocks), Belt(kind, Rock(600f), Rock(640f, .3f))); + var found = new List(); + var mismatches = 0; + var nonEmpty = 0; + var acrossOuterEdge = 0; + for (var q = 0; q < 3000; q++) + { + var angle = (float) (rng.NextDouble() * 2 * PI); + var fromCentre = (float) rng.NextDouble() * 900f; + var from = centre + fromCentre * float2(cos(angle), sin(angle)); + var range = (float) rng.NextDouble() * 300f; + s.Zone.ChunksNear(from, range, found); + var expected = s.Belts + .SelectMany(b => Enumerable.Range(0, ((AsteroidBeltData) s.Zone.Planets[b]).Asteroids.Length).Select(i => new ChunkId(b, i))) + .Where(c => length(At(s, c) - from) <= range).ToArray(); + if (!expected.SequenceEqual(found)) mismatches++; + if (expected.Length > 0) nonEmpty++; + if (fromCentre > 350f && fromCentre - range < 350f) acrossOuterEdge++; + } + Assert.Equal(0, mismatches); + Assert.True(nonEmpty > 300 && acrossOuterEdge > 300, $"degenerate queries: {nonEmpty} non-empty, {acrossOuterEdge} across the outer edge"); + } + + // The detection loop's ping arm is unchanged by the gain extraction (Soul's ping sweep): the EntityInfoGathered + // trace of 18 sensor configurations, some with a ping that lands unsaturated, matches the trace recorded against + // the pre-extraction Sensor.Execute (cb4a1dbd). SensorGainExtractionChangesNothing does not see a ping's size; + // this sweep does. + [Fact] + public void ThePingArmIsUnchangedByTheGainExtraction() + { + var bytes = new List(); + var unsaturatedPings = 0; + foreach (var sensitivity in new[] { 1e-5f, 3e-5f, 1e-4f, 3e-4f, 1e-3f, 1e-2f }) + foreach (var boost in new[] { 1e-4f, 1e-3f, 2e-2f }) + { + var (near, far) = PingTrace(sensitivity, boost, true); + var (nearQuiet, farQuiet) = PingTrace(sensitivity, boost, false); + foreach (var x in near.Concat(far)) bytes.AddRange(BitConverter.GetBytes(x)); + if (near.Max() < .999f && near.Zip(nearQuiet, (a, b) => abs(a - b)).Max() > .01f) unsaturatedPings++; + if (far.Max() < .999f && far.Zip(farQuiet, (a, b) => abs(a - b)).Max() > .01f) unsaturatedPings++; + } + Assert.True(unsaturatedPings > 0, "no configuration saw a ping land unsaturated"); + Assert.Equal("35DE0E26ACC5BD51F6B6E170AB890C3FE5D8BB2BE3A9DC3F3EB66CDA0FA23728", Convert.ToHexString(SHA256.HashData(bytes.ToArray()))); + } + + // One observer and two reflecting ships in an empty zone, 80 ticks, a ping at tick 20 when `ping`. + private (float[] near, float[] far) PingTrace(float sensitivity, float boost, bool ping) + { + var dir = Path.Combine(_root, $"ping{_fixtureCount++}"); + Directory.CreateDirectory(dir); + var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hull = new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Durability = 10, Mass = 1000 }; + cache.Upsert(hull); + var eye = Gear("Eye", new SensorData + { + Sensitivity = Constant(sensitivity), SensitivityCurve = Falloff(), PingBoost = Constant(boost), + PingEnergy = Constant(0f), PingVisibility = Constant(5f), PingRange = Constant(300f), PingCooldown = Constant(4f) + }); + cache.Upsert(eye); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var s = new Scene + { + Cache = cache, Items = items, Hull = hull, Eye = eye, + Zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Ping", Owner = null }, null) + }; + var eyeAt = float2(0, 50); + var nearAt = float2(30, 120); + var farAt = float2(-150, 90); + var observer = SpawnShip(s, eyeAt, sensor: true); + var near = SpawnShip(s, nearAt, crossSection: .2f); + var far = SpawnShip(s, farAt, crossSection: 9f); + var nearTrace = new float[80]; + var farTrace = new float[80]; + for (var tick = 0; tick < 80; tick++) + { + if (tick == 20 && ping) observer.Sensor.Ping(); + Tick((near, nearAt), (far, farAt), (observer, eyeAt)); + nearTrace[tick] = observer.EntityInfoGathered[near]; + farTrace[tick] = observer.EntityInfoGathered[far]; + } + return (nearTrace, farTrace); + } + + // Soul F2: the shipped catalog authors at least one field kind that generation can pick, so a generated belt is + // never left kindless and dark. + [Fact] + public void TheShippedCatalogHasAPickableFieldKind() + { + var dir = Path.Combine(_root, "shipped"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(Path.Combine(RestoredHullsTests.FindRepoRoot(), "GameData", "Aetheria.cc"), catalog); + using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc")); + Assert.Contains(cache.GetAll(), kind => kind.GenerationWeight > 0f); + } } From f803151681379c8be5b1523c2c8d3c6b86f5f517 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:02:51 +0200 Subject: [PATCH 32/45] Mining index: a general targeting index fed by providers; the linear chunk scan goes Zone.Targets (TargetingIndex) answers Within and best-first Best over ITargetProvider regions. EntityTargets offers the observer's VisibleEntities; BeltTargets keys each belt's rocks into kinetic bands and bounds each segment's annular sector for distance, bearing and visibility. Entity.VisibleChunksInReach queries the index with DetectionReachPerVisibility, an upper bound from the sensors' hull. Zone.ChunksNear and AsteroidBelt.InnerRadius are deleted. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/Behaviors/Sensor.cs | 9 + Assets/Scripts/ServerShared/BezierCurve.cs | 26 ++ Assets/Scripts/ServerShared/Entity.cs | 37 ++- Assets/Scripts/ServerShared/Targeting.meta | 8 + .../ServerShared/Targeting/BeltTargets.cs | 302 ++++++++++++++++++ .../Targeting/BeltTargets.cs.meta | 11 + .../ServerShared/Targeting/EntityTargets.cs | 39 +++ .../Targeting/EntityTargets.cs.meta | 11 + .../ServerShared/Targeting/TargetingIndex.cs | 155 +++++++++ .../Targeting/TargetingIndex.cs.meta | 11 + Assets/Scripts/ServerShared/Zone.cs | 63 ++-- 11 files changed, 631 insertions(+), 41 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Targeting.meta create mode 100644 Assets/Scripts/ServerShared/Targeting/BeltTargets.cs create mode 100644 Assets/Scripts/ServerShared/Targeting/BeltTargets.cs.meta create mode 100644 Assets/Scripts/ServerShared/Targeting/EntityTargets.cs create mode 100644 Assets/Scripts/ServerShared/Targeting/EntityTargets.cs.meta create mode 100644 Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs create mode 100644 Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs.meta diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 7619e90fd..8eeadbb7d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -197,4 +197,13 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer var angle = acos(dot(Direction.xz, normalize(diff))); return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false); } + + // An upper bound on sensitivity x response over every bearing, so PassiveRate(v, p) <= v x this / distance + // (Entity.DetectionReachPerVisibility). The curve lies within its control points' hull. + public float PassiveGainBound() + { + var sensitivity = Evaluate(_data.Sensitivity); + var response = _data.SensitivityCurve.HullRange(); + return max(sensitivity * response.x, sensitivity * response.y); + } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/BezierCurve.cs b/Assets/Scripts/ServerShared/BezierCurve.cs index 7574de8e7..656f6e448 100644 --- a/Assets/Scripts/ServerShared/BezierCurve.cs +++ b/Assets/Scripts/ServerShared/BezierCurve.cs @@ -4,6 +4,7 @@ using MessagePack; using Newtonsoft.Json; using CultMath; using static CultMath.math; +using float2 = CultMath.float2; [MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class BezierCurve @@ -39,6 +40,31 @@ public class BezierCurve } } + // The least and greatest value Evaluate can return, as (min, max): each segment is a cubic Bezier whose control + // points are p0, p0 + m0/3, p1 - m1/3 and p1, and a Bezier curve lies within their hull. A stepped segment holds + // its left key; outside the keys Evaluate clamps to the end keys. A bound, not the curve's extremes. + public float2 HullRange() + { + var low = float.PositiveInfinity; + var high = float.NegativeInfinity; + void Include(float value) + { + low = min(low, value); + high = max(high, value); + } + foreach (var key in Keys) Include(key.y); + for (var i = 0; i + 1 < Keys.Length; i++) + { + var left = Keys[i]; + var right = Keys[i + 1]; + if (isinf(left.w) || isinf(right.z)) continue; + var dx = right.x - left.x; + Include(left.y + left.w * dx / 3); + Include(right.y - right.z * dx / 3); + } + return float2(low, high); + } + // Integrate area under curve between start and end time public float IntegrateCurve(float startTime, float endTime, int steps) { diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 908741173..cc5d17094 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -372,16 +372,45 @@ public abstract class Entity public bool ChunkVisible(ChunkId chunk) => ChunkInfo(chunk) > ItemManager.GameplaySettings.TargetDetectionInfoThreshold; // Mining Cut 3 (Q13 A): the reach for picking a chunk -- the longest range among this entity's active weapons - // that can mine (Weapon.CanMine, Q12 A). Every visible chunk within it, written into `into` (cleared first). - // Cycling and any programmatic chunk pick read this, so there is one reach rule. + // that can mine (Weapon.CanMine, Q12 A). Every visible chunk within it, written into `into` (cleared first), by + // index then field. Cycling and any programmatic chunk pick read this, so there is one reach rule. The zone's + // targeting index proposes the chunks that could pass (no observer, so no entities); ChunkVisible decides. public void VisibleChunksInReach(List into) { var reach = 0f; foreach (var weapon in _weapons) if (weapon.CanMine && (weapon.Item == null || weapon.Item.Active.Value)) reach = max(reach, weapon.Range); - Zone.ChunksNear(Position.xz, reach, into); - into.RemoveAll(chunk => !ChunkVisible(chunk)); + into.Clear(); + Zone.Targets.Within(new TargetSearch(Position.xz, reach, reachPerVisibility: DetectionReachPerVisibility()), _targetCandidates); + foreach (var candidate in _targetCandidates) + if (candidate.Target.Chunk is ChunkId chunk && ChunkVisible(chunk)) + into.Add(chunk); + into.Sort(ByIndexThenField); + } + + private readonly List _targetCandidates = new List(); + private static readonly Comparison ByIndexThenField = (a, b) => + a.Index != b.Index ? a.Index.CompareTo(b.Index) : a.Field.GetHashCode().CompareTo(b.Field.GetHashCode()); + + // The planar distance per unit of visibility beyond which no target can pass ChunkVisible's threshold: an upper + // bound, never a decision. ChunkInfo is saturate(sum_i v s_i c_i(angle) / d / (n k)), linear in v and falling as + // 1/d, and a Bezier curve lies within the hull of its control points, so each sensor's s_i c_i is bounded by + // Sensor.PassiveGainBound. Zero with no active sensor (nothing is visible); infinity when the threshold is not + // positive. Slack covers float rounding and only ever widens it. + public float DetectionReachPerVisibility() + { + var gain = 0f; + var sensors = 0; + foreach (var sensor in ActiveSensors()) + { + gain += sensor.PassiveGainBound(); + sensors++; + } + if (sensors == 0) return 0f; + var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold; + if (!(threshold > 0f)) return float.PositiveInfinity; + return max(gain, 0f) / (sensors * ItemManager.GameplaySettings.TargetInfoDecay * threshold) * 1.0001f; } // Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables. diff --git a/Assets/Scripts/ServerShared/Targeting.meta b/Assets/Scripts/ServerShared/Targeting.meta new file mode 100644 index 000000000..39f43376e --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7c083d5830b0418cbf8e34158465d764 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs new file mode 100644 index 000000000..6bde2c790 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs @@ -0,0 +1,302 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using GameCult.Caching; +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; + +// Mining index: one belt's rocks as targets. Derived from the belt's data, the zone's settings and zone time when the +// zone builds the belt, and never saved. +// +// Rocks are grouped into bands of neighbouring orbit distance. Within a band they are sorted by where they stood on +// their orbits (in turns, as AsteroidBelt.Pose measures them) at the band's key time. A rock's turn at a later time is +// its key plus elapsed / period, and a band's periods lie close together, so the rocks near a point lie in one arc of +// keys, widened by elapsed time times the band's rate spread. A band sheared past ShearLimit is re-sorted at the +// current time, at most RekeyBudget bands per search, so no single search pays for the whole belt; one that is not +// re-sorted is still searched correctly through its wider arc. Each band is split into segments of consecutive keys, +// and each segment's part of the arc is a region, bounded by the annular sector it can occupy. +public sealed class BeltTargets : ITargetProvider +{ + private const int RocksPerBand = 256; + private const int RocksPerSegment = 32; + private const double ShearLimit = 1.0 / 64; + private const int RekeyBudget = 8; + + private readonly Zone _zone; + private readonly CultRecordKey _field; + private readonly AsteroidBeltData _data; + private readonly double[] _periods; + private readonly Band[] _bands; + // No rock of the belt, whole or worn, is wider than this (AsteroidSize's bound over every argument). + private readonly float _largestRadius; + + private sealed class Band + { + public float Inner, Outer; // the least and greatest orbit distance among the band's rocks + public double SlowestRate, FastestRate; // turns per second: 1 / period + public double KeyTime; + public int[] Rocks; + public double[] Keys; // each rock's turn at KeyTime, ascending, in [0, 1) + } + + public BeltTargets(Zone zone, CultRecordKey field, AsteroidBeltData data, PlanetSettings settings, double time) + { + _zone = zone; + _field = field; + _data = data; + var size = settings.AsteroidSize; + _largestRadius = size.Exponent > 0 ? max(abs(size.Minimum), abs(size.Maximum)) : float.PositiveInfinity; + + var count = data.Asteroids.Length; + _periods = new double[count]; + var order = new int[count]; + var distances = new float[count]; + for (var i = 0; i < count; i++) + { + order[i] = i; + distances[i] = data.Asteroids[i].Distance; + // Pose divides by the float period the curve returns; the keys must use the same one. + _periods[i] = settings.OrbitPeriod.Evaluate(distances[i]); + } + Array.Sort(distances, order); + _bands = new Band[(count + RocksPerBand - 1) / RocksPerBand]; + for (var b = 0; b < _bands.Length; b++) + { + var start = b * RocksPerBand; + var length = Math.Min(RocksPerBand, count - start); + var band = new Band + { + Inner = distances[start], Outer = distances[start + length - 1], + SlowestRate = double.PositiveInfinity, FastestRate = double.NegativeInfinity, + Rocks = new int[length], Keys = new double[length] + }; + Array.Copy(order, start, band.Rocks, 0, length); + foreach (var rock in band.Rocks) + { + var rate = 1.0 / _periods[rock]; + band.SlowestRate = Math.Min(band.SlowestRate, rate); + band.FastestRate = Math.Max(band.FastestRate, rate); + } + Rekey(band, time); + _bands[b] = band; + } + } + + // Diagnostic only: how many bands have been re-sorted since the belt was built, so a test can pin the budget. + public long Rekeys { get; private set; } + + private void Rekey(Band band, double time) + { + Rekeys++; + band.KeyTime = time; + for (var j = 0; j < band.Rocks.Length; j++) + band.Keys[j] = Turn(time / _periods[band.Rocks[j]] + _data.Asteroids[band.Rocks[j]].Phase); + Array.Sort(band.Keys, band.Rocks); + } + + public void Regions(in TargetSearch search, List into) + { + if (_bands.Length == 0) return; + var time = _zone.ExactTime; + var centre = _zone.GetOrbitPosition(_zone.Orbits[_data.Orbit.Key].Data.Parent.Key); + var offset = search.Position - centre; + var distance = length(offset); + var turn = Turn(atan2(offset.y, offset.x) / (2 * PI_DBL)); + // Slack covers float rounding in Pose and in these bounds; it only ever widens a region. + var slack = 1e-4f * (_bands[_bands.Length - 1].Outer + distance) + 1e-3f; + var reach = search.Reach + slack; + var half = distance <= reach ? 1.0 : Math.Asin(reach / distance) / (2 * PI_DBL) + 1e-6; + var budget = RekeyBudget; + + // The first band whose outer edge reaches the search. + int lo = 0, hi = _bands.Length; + while (lo < hi) + { + var mid = (lo + hi) / 2; + if (_bands[mid].Outer < distance - reach) lo = mid + 1; + else hi = mid; + } + for (var b = lo; b < _bands.Length && _bands[b].Inner <= distance + reach; b++) + { + var band = _bands[b]; + if (Math.Abs(time - band.KeyTime) * (band.FastestRate - band.SlowestRate) > ShearLimit && budget > 0) + { + Rekey(band, time); + budget--; + } + Drift(band, time, out var leastDrift, out var mostDrift); + var width = 2 * half + (mostDrift - leastDrift); + if (!(width < 1)) + { + AddSegments(search, b, 0, band.Keys.Length, centre, distance, turn, slack, into); + continue; + } + // Keys whose rock can now lie within half a window of the search's own turn. + var from = Turn(turn - half - mostDrift); + var to = from + width; + AddSegments(search, b, LowerBound(band.Keys, from), UpperBound(band.Keys, Math.Min(to, 1.0)), centre, distance, turn, slack, into); + if (to > 1) AddSegments(search, b, 0, UpperBound(band.Keys, to - 1), centre, distance, turn, slack, into); + } + } + + public void Candidates(in TargetSearch search, in TargetRegion region, List into) + { + var band = _bands[region.Group]; + for (var j = region.Start; j < region.End; j++) + { + var chunk = new ChunkId(_field, band.Rocks[j]); + if (_zone.ChunkExists(chunk)) into.Add(new TargetCandidate(chunk, _zone.ChunkPose(_field, chunk.Index).xy)); + } + } + + // The keys [start, end) of one band, one region per segment they cross, each bounded by the sector its rocks can + // occupy now: the band's distances, and the turns of its first and last key widened by the band's drift. + private void AddSegments(in TargetSearch search, int b, int start, int end, float2 centre, float distance, double turn, float slack, List into) + { + var band = _bands[b]; + Drift(band, _zone.ExactTime, out var leastDrift, out var mostDrift); + var crossSection = _zone.FieldCrossSection(_data); + for (var s = start; s < end; s = (s / RocksPerSegment + 1) * RocksPerSegment) + { + var e = Math.Min(end, (s / RocksPerSegment + 1) * RocksPerSegment); + var sector = new Sector + { + Inner = band.Inner, Outer = band.Outer, + From = band.Keys[s] + leastDrift - 1e-6, + Width = band.Keys[e - 1] - band.Keys[s] + (mostDrift - leastDrift) + 2e-6 + }; + sector.Range(distance, turn, out var nearest, out var farthest); + nearest = max(0f, nearest - slack); + if (nearest > search.Reach) continue; + sector.Bearing(search.Position - centre, out var bearing, out var bearingHalf); + var light = 0f; + foreach (var sun in _zone.Suns()) + { + var fromSun = sun.Orbit.Position - centre; + sector.Range(length(fromSun), Turn(atan2(fromSun.y, fromSun.x) / (2 * PI_DBL)), out var sunNearest, out _); + light += Zone.SunLight(sun, max(0f, sunNearest - slack)); + } + var cells = _largestRadius / _zone.SchematicCellSize; + into.Add(new TargetRegion + { + Provider = this, + Group = b, Start = s, End = e, + Nearest = nearest, + Farthest = farthest + slack, + BearingCentre = bearing, + BearingHalfWidth = min((float) PI, bearingHalf + slack / max(distance, 1e-3f) + 1e-5f), + MaxVisibility = crossSection * PI * cells * cells * light * 1.0001f + }); + } + } + + private static void Drift(Band band, double time, out double least, out double most) + { + var elapsed = time - band.KeyTime; + least = Math.Min(elapsed * band.SlowestRate, elapsed * band.FastestRate); + most = Math.Max(elapsed * band.SlowestRate, elapsed * band.FastestRate); + } + + private static double Turn(double turns) => turns - Math.Floor(turns); + + private static int LowerBound(double[] keys, double value) + { + int lo = 0, hi = keys.Length; + while (lo < hi) + { + var mid = (lo + hi) / 2; + if (keys[mid] < value) lo = mid + 1; + else hi = mid; + } + return lo; + } + + private static int UpperBound(double[] keys, double value) + { + int lo = 0, hi = keys.Length; + while (lo < hi) + { + var mid = (lo + hi) / 2; + if (keys[mid] <= value) lo = mid + 1; + else hi = mid; + } + return lo; + } + + // An annular sector about the belt centre: distances [Inner, Outer], turns [From, From + Width] (a Width of 1 or + // more is the whole annulus). + private struct Sector + { + public float Inner, Outer; + public double From, Width; + + // The nearest and farthest a point `distance` from the centre at `turn` can be from any point of the sector. + public void Range(float distance, double turn, out float nearest, out float farthest) + { + double offAngle, farAngle; + if (Width >= 1) + { + offAngle = 0; + farAngle = PI_DBL; + } + else + { + var into = Turn(turn - From); + offAngle = into <= Width ? 0 : Math.Min(into - Width, 1 - into) * 2 * PI_DBL; + // The sector point farthest in angle: the antipode if the sector holds it, else the farther end. + farAngle = Turn(turn + .5 - From) <= Width + ? PI_DBL + : Math.Max(Separation(turn, From), Separation(turn, From + Width)) * 2 * PI_DBL; + } + if (offAngle == 0) + nearest = max(max(Inner - distance, distance - Outer), 0f); + else + { + // On the nearer edge ray: the closest point of the segment [Inner, Outer] along it. + var along = clamp(distance * (float) Math.Cos(offAngle), Inner, Outer); + nearest = Apart(distance, along, offAngle); + } + farthest = max(Apart(distance, Inner, farAngle), Apart(distance, Outer, farAngle)); + } + + // A circle that holds the sector, seen from `from` (relative to the belt centre): the bearing to its centre + // and the half-width of the bearings it covers (pi when `from` is inside it). + public void Bearing(float2 from, out float centre, out float halfWidth) + { + float2 middle; + float radius; + if (Width >= .5) + { + middle = float2.zero; + radius = Outer; + } + else + { + var middleTurn = (From + Width / 2) * 2 * PI_DBL; + var halfAngle = Width * PI_DBL; + var middleRadius = (Inner + Outer) / 2; + middle = middleRadius * float2((float) Math.Cos(middleTurn), (float) Math.Sin(middleTurn)); + radius = max(Apart(middleRadius, Inner, halfAngle), Apart(middleRadius, Outer, halfAngle)); + } + var toMiddle = middle - from; + var apart = length(toMiddle); + centre = atan2(toMiddle.y, toMiddle.x); + halfWidth = apart <= radius ? (float) PI : asin(radius / apart); + } + + // How far apart two points are at distances a and b from the centre, `angle` radians apart about it. + private static float Apart(float a, float b, double angle) => + (float) Math.Sqrt(Math.Max(0, (double) a * a + (double) b * b - 2.0 * a * b * Math.Cos(angle))); + + // The angle in turns between two turns, in [0, .5]. + private static double Separation(double a, double b) + { + var d = Turn(a - b); + return Math.Min(d, 1 - d); + } + } +} diff --git a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs.meta b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs.meta new file mode 100644 index 000000000..853bba9c2 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d33fc585810446f7b0a9319196c74633 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs b/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs new file mode 100644 index 000000000..f4103e6d0 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using static CultMath.math; + +// Mining index: entities as targets. Perception stays Entity's: the only entities a search can be offered are those +// already in its observer's VisibleEntities, so the one region carries no visibility bound (they are already seen). +public sealed class EntityTargets : ITargetProvider +{ + public void Regions(in TargetSearch search, List into) + { + var observer = search.Observer; + if (observer == null || observer.VisibleEntities.Count == 0) return; + var nearest = float.PositiveInfinity; + var farthest = 0f; + foreach (var entity in observer.VisibleEntities) + { + var distance = length(entity.Position.xz - search.Position); + nearest = min(nearest, distance); + farthest = max(farthest, distance); + } + into.Add(new TargetRegion + { + Provider = this, + Nearest = nearest, + Farthest = farthest, + BearingHalfWidth = PI, + MaxVisibility = float.PositiveInfinity + }); + } + + public void Candidates(in TargetSearch search, in TargetRegion region, List into) + { + foreach (var entity in search.Observer.VisibleEntities) + into.Add(new TargetCandidate(entity, entity.Position.xz)); + } +} diff --git a/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs.meta b/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs.meta new file mode 100644 index 000000000..49bb99318 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1db47cd187504dbb97994e4b8c436ef1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs b/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs new file mode 100644 index 000000000..62f5cf6da --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; + +// Mining index (docs/aetheria-release-map.md, L3-L5; operator 2026-10-01: "don't specialize the indexing too much +// towards asteroid belts"): one targeting index per zone (Zone.Targets), fed by providers. It owns which targetable +// things can lie in a region and an upper bound on how visible they can be. Each provider owns its own things' +// motion and bounds. Whether one target is actually visible stays with Entity (ChunkVisible, VisibleEntities): every +// answer here is a superset the caller filters exactly. Nothing in the index names a belt or a rock. + +// One thing that can be targeted, with where it stands on the plane now. +public readonly struct TargetCandidate +{ + public readonly TargetRef Target; + public readonly float2 Position; + + public TargetCandidate(TargetRef target, float2 position) + { + Target = target; + Position = position; + } +} + +// Who is searching, from where, how far, and how well they can see. A null Observer perceives no entities. +// ReachPerVisibility is Entity.DetectionReachPerVisibility: no target of visibility v farther than v times it can be +// detected. Infinity applies no visibility bound. +public readonly struct TargetSearch +{ + public readonly float2 Position; + public readonly float Reach; + public readonly Entity Observer; + public readonly float ReachPerVisibility; + + public TargetSearch(float2 position, float reach, Entity observer = null, float reachPerVisibility = float.PositiveInfinity) + { + Position = position; + Reach = reach; + Observer = observer; + ReachPerVisibility = reachPerVisibility; + } +} + +// A group of a provider's things, with bounds that hold for every one of them at the time it was made: planar +// distance from the searcher in [Nearest, Farthest], bearing from the searcher (radians, as atan2 measures it) +// within BearingHalfWidth of BearingCentre (pi means any bearing), and visibility at most MaxVisibility. +// Group, Start and End are the provider's own: the index only hands the region back to it. +public struct TargetRegion +{ + public ITargetProvider Provider; + public int Group, Start, End; + public float Nearest, Farthest; + public float BearingCentre, BearingHalfWidth; + public float MaxVisibility; + + // Whether anything in this region can both lie within reach and be detected by the searcher. + public bool CanHold(in TargetSearch search) => + !(Nearest > search.Reach) && !(Nearest > MaxVisibility * search.ReachPerVisibility); +} + +public interface ITargetProvider +{ + // Regions that together hold every one of this provider's things within the search's reach, added to `into`. + void Regions(in TargetSearch search, List into); + + // The things of one region this provider made for this search, with their positions now, added to `into`. + void Candidates(in TargetSearch search, in TargetRegion region, List into); +} + +// A best-first question: the candidate with the least key wins. Bound must never exceed the key of any candidate the +// region holds (positive infinity skips the region). Key returns false for a candidate that is not eligible. +// Precedes breaks a tie between two equal keys. +public interface ITargetKey +{ + float Bound(in TargetRegion region); + bool Key(in TargetCandidate candidate, out float key); + bool Precedes(in TargetCandidate a, in TargetCandidate b); +} + +public sealed class TargetingIndex +{ + private readonly List _providers = new List(); + private readonly List _regions = new List(); + private readonly List _candidates = new List(); + private readonly List<(float bound, int region)> _order = new List<(float, int)>(); + + // Diagnostic only: how many candidates the index has posed and tested, ever. Tests pin a query's cost with it + // rather than with timing. + public long Examined { get; private set; } + + public void Add(ITargetProvider provider) => _providers.Add(provider); + + // Every candidate within the search's reach whose region can be detected, written into `into` (cleared first), + // in no particular order. Reach is exact; detection is only bounded, so the caller still tests it. + public void Within(in TargetSearch search, List into) + { + into.Clear(); + _regions.Clear(); + foreach (var provider in _providers) provider.Regions(search, _regions); + foreach (var region in _regions) + { + if (!region.CanHold(search)) continue; + _candidates.Clear(); + region.Provider.Candidates(search, region, _candidates); + foreach (var candidate in _candidates) + { + Examined++; + if (length(candidate.Position - search.Position) <= search.Reach) into.Add(candidate); + } + } + } + + // The eligible candidate within reach with the least key, or false if there is none. Regions are opened in order + // of their bound, and the search stops at the first region whose bound is greater than the best key found: + // Hjaltason and Samet's best-first browsing over the providers' flat region sets. + public bool Best(in TargetSearch search, TKey query, out TargetCandidate best) where TKey : ITargetKey + { + best = default; + var found = false; + var bestKey = float.PositiveInfinity; + _regions.Clear(); + _order.Clear(); + foreach (var provider in _providers) provider.Regions(search, _regions); + for (var i = 0; i < _regions.Count; i++) + { + if (!_regions[i].CanHold(search)) continue; + var bound = query.Bound(_regions[i]); + if (!float.IsPositiveInfinity(bound)) _order.Add((bound, i)); + } + _order.Sort((a, b) => a.bound.CompareTo(b.bound)); + foreach (var (bound, index) in _order) + { + if (found && bound > bestKey) break; + _candidates.Clear(); + var region = _regions[index]; + region.Provider.Candidates(search, region, _candidates); + foreach (var candidate in _candidates) + { + Examined++; + if (!(length(candidate.Position - search.Position) <= search.Reach) || !query.Key(candidate, out var key)) continue; + if (!found || key < bestKey || key == bestKey && query.Precedes(candidate, best)) + { + best = candidate; + bestKey = key; + found = true; + } + } + } + return found; + } +} diff --git a/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs.meta b/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs.meta new file mode 100644 index 000000000..4b40c10b0 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9fef7cc648f2498ebd24e1d0ca61b5c3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 9b9e409e7..863061eec 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -23,6 +23,12 @@ public class Zone public Dictionary AsteroidBelts = new Dictionary(); public PlanetSettings Settings; + // Mining index (docs/aetheria-release-map.md, L3-L5): every targetable thing in the zone, fed by providers -- + // the entities, and one provider per belt. The reticle, cycling and any range query over rocks go through it; + // nothing else scans a belt. + public readonly TargetingIndex Targets = new TargetingIndex(); + private readonly List _suns = new List(); + private HashSet _updatedOrbits = new HashSet(); private ItemManager _itemManager; @@ -62,6 +68,9 @@ public class Zone { get => (float) _time; } + + // Zone time at full precision, for the index's orbital keys, which must agree with ChunkPose's own. + internal double ExactTime => _time; public ZonePack Pack { get; } public GalaxyZone GalaxyZone { get; } public Galaxy Galaxy { get; } @@ -89,6 +98,8 @@ public class Zone // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); })); + Targets.Add(new EntityTargets()); + foreach (var orbit in pack.Orbits) { Orbits.Add(orbit.Key, new Orbit(Settings, cache.Get(orbit))); @@ -105,9 +116,12 @@ public class Zone // the rule generation uses, and keeps it. FieldKinds.Ensure(body.Key, belt, cache); AsteroidBelts[body.Key] = new AsteroidBelt(belt); + Targets.Add(new BeltTargets(this, body.Key, belt, settings, _time)); break; case SunData sun: - PlanetInstances.Add(body.Key, new Sun(settings, sun, Orbits[planet.Orbit.Key])); + var sunInstance = new Sun(settings, sun, Orbits[planet.Orbit.Key]); + PlanetInstances.Add(body.Key, sunInstance); + _suns.Add(sunInstance); break; case GasGiantData gas: PlanetInstances.Add(body.Key, new GasGiant(settings, gas, Orbits[planet.Orbit.Key])); @@ -320,25 +334,6 @@ public class Zone return kind.CrossSection * PI * cells * cells * GetLight(ChunkPose(chunk.Field, chunk.Index).xy); } - // Mining Cut 3: the one chunk query -- every chunk that exists and whose centre lies within `range` of - // `position`, written into `into` (cleared first). Belts whose annulus cannot reach are skipped whole. It knows - // nothing about detection; callers filter by what an observer can see. - public void ChunksNear(float2 position, float range, List into) - { - into.Clear(); - foreach (var belt in AsteroidBelts) - { - var centre = GetOrbitPosition(Orbits[belt.Value.Data.Orbit.Key].Data.Parent.Key); - var fromCentre = length(position - centre); - if (fromCentre - range > belt.Value.Radius || fromCentre + range < belt.Value.InnerRadius) continue; - for (var i = 0; i < belt.Value.Data.Asteroids.Length; i++) - { - var chunk = new ChunkId(belt.Key, i); - if (ChunkExists(chunk) && length(ChunkPose(belt.Key, i).xy - position) <= range) into.Add(chunk); - } - } - } - public void EvaluateBelt(CultRecordKey belt, Span into) { var beltData = Planets[belt] as AsteroidBeltData; @@ -418,23 +413,20 @@ public class Zone public float GetLight(float2 position) { var light = 0f; - foreach (var body in PlanetInstances.Values) - { - if (body is Sun sun) - { - var p = position - body.Orbit.Position; - var distSqr = lengthsq(p); - var lightRadius = sun.LightRadius; - if (distSqr < lightRadius * lightRadius) - { - light += PowerPulse(sqrt(distSqr) / lightRadius, 8); - } - } - } - + foreach (var sun in _suns) + light += SunLight(sun, length(position - sun.Orbit.Position)); return light; } + // One sun's light at `distance` from it. It never grows with distance, so the light at the nearest point of a + // region bounds the light anywhere in it (BeltTargets' visibility bound). + public static float SunLight(Sun sun, float distance) => + distance < sun.LightRadius ? PowerPulse(distance / sun.LightRadius, 8) : 0f; + + internal IReadOnlyList Suns() => _suns; + internal float SchematicCellSize => _itemManager.GameplaySettings.SchematicCellSize; + internal float FieldCrossSection(AsteroidBeltData belt) => _itemManager.ItemData.Get(belt.Kind)?.CrossSection ?? 0f; + public float2 GetForce(float2 position) { var normal = GetNormal(position); @@ -537,14 +529,11 @@ public class AsteroidBelt { public AsteroidBeltData Data; public float Radius { get; } - // Mining Cut 3: the belt's inner edge, so the chunk query can skip a belt whose annulus it cannot reach. - public float InnerRadius { get; } public AsteroidBelt(AsteroidBeltData data) { Data = data; Radius = data.Asteroids.Max(a => a.Distance); - InnerRadius = data.Asteroids.Min(a => a.Distance); } // Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used. From 6a79f87a64dd4710f828f8b9d0112a825ece3b73 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:02:52 +0200 Subject: [PATCH 33/45] Mining index tests: brute-force equality, bounds, pruning and cost The two chunk-query tests compare sets through Zone.Targets.Within. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 25 +- .../Aetheria.Shared.Tests/MiningIndexTests.cs | 327 ++++++++++++++++++ 2 files changed, 342 insertions(+), 10 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/MiningIndexTests.cs diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index f84c73efd..989961f77 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -17,7 +17,7 @@ using float3 = CultMath.float3; // Cut 3 (docs/mining-cut-refresh.md): a chunk is a target, detected by reflected light. One detection gain rule // (Sensor.Gain) serves both the per-tick entity loop and the on-demand chunk query, and the chunk query returns // the value the per-tick rule settles at (Q14 A). A belt's field kind is chosen once and saved (Q16 B). -public sealed class MiningCut3Tests : IDisposable +public sealed partial class MiningCut3Tests : IDisposable { private const float Dt = .05f; private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut3-" + Guid.NewGuid().ToString("N")); @@ -49,7 +49,7 @@ public sealed class MiningCut3Tests : IDisposable // Flat terrain (no zone depth, no gravity wells), one sun whose light reaches 1000 units, and the chunk // curves MiningCut2Tests uses, so sizes and wear vary over the fixture's range. - private static PlanetSettings PlanetSettings() => new PlanetSettings + private static PlanetSettings PlanetSettings(float lightRadius = 1000f) => new PlanetSettings { OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, @@ -62,7 +62,7 @@ public sealed class MiningCut3Tests : IDisposable WaveRadius = new ExponentialCurve(), WaveSpeed = new ExponentialCurve(), WaveFrequency = new ExponentialCurve(), - LightRadius = new ExponentialCurve { Constant = 1000f } + LightRadius = new ExponentialCurve { Constant = lightRadius } }; // A sensitivity that falls off away from the sensor's facing, so the angle term is exercised, not a constant. @@ -98,7 +98,11 @@ public sealed class MiningCut3Tests : IDisposable private Scene BuildScene(FieldKindData[] kinds = null, params BeltSpec[] belts) => BuildSceneAt(float2.zero, kinds, belts); // The same scene with its sun, and so every belt centre, at `sunAt`. - private Scene BuildSceneAt(float2 sunAt, FieldKindData[] kinds, params BeltSpec[] belts) + private Scene BuildSceneAt(float2 sunAt, FieldKindData[] kinds, params BeltSpec[] belts) => + BuildSceneAt(sunAt, 1000f, 0, kinds, belts); + + // The same, with the sun's light radius and the zone's starting time chosen. + private Scene BuildSceneAt(float2 sunAt, float lightRadius, double time, FieldKindData[] kinds, params BeltSpec[] belts) { var dir = Path.Combine(_root, $"scene{_fixtureCount++}"); Directory.CreateDirectory(dir); @@ -144,7 +148,7 @@ public sealed class MiningCut3Tests : IDisposable Planets = { new CultRecordRef(sun) }, Radius = 5000f, Mass = 10000f, - Time = 0 + Time = time }; var scene = new Scene { Cache = cache, Items = items, Hull = hull, Eye = eye, DimEye = dimEye, Pack = pack }; scene.Kinds.AddRange(kinds ?? new FieldKindData[0]); @@ -158,7 +162,7 @@ public sealed class MiningCut3Tests : IDisposable pack.Planets.Add(new CultRecordRef(key)); scene.Belts.Add(key); } - scene.Zone = new Zone(items, PlanetSettings(), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null); + scene.Zone = new Zone(items, PlanetSettings(lightRadius), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null); return scene; } @@ -458,11 +462,12 @@ public sealed class MiningCut3Tests : IDisposable (centre + float2(220, 0), 40f), (centre + float2(-300, 50), 120f), (centre + float2(600, 0), 360f), (centre + float2(600, 0), 200f) }) { - s.Zone.ChunksNear(from, range, found); + ChunksWithin(s, from, range, found); var expected = Enumerable.Range(0, rocks.Length) .Where(i => i != 5 && length(At(s, new ChunkId(belt, i)) - from) <= range) .Select(i => new ChunkId(belt, i)).ToArray(); - Assert.Equal(expected, found); + Assert.Equal(expected.ToHashSet(), found.ToHashSet()); + Assert.Equal(expected.Length, found.Count); sawSome |= expected.Length > 0; } Assert.True(sawSome); @@ -920,11 +925,11 @@ public sealed class MiningCut3Tests : IDisposable var fromCentre = (float) rng.NextDouble() * 900f; var from = centre + fromCentre * float2(cos(angle), sin(angle)); var range = (float) rng.NextDouble() * 300f; - s.Zone.ChunksNear(from, range, found); + ChunksWithin(s, from, range, found); var expected = s.Belts .SelectMany(b => Enumerable.Range(0, ((AsteroidBeltData) s.Zone.Planets[b]).Asteroids.Length).Select(i => new ChunkId(b, i))) .Where(c => length(At(s, c) - from) <= range).ToArray(); - if (!expected.SequenceEqual(found)) mismatches++; + if (expected.Length != found.Count || !expected.ToHashSet().SetEquals(found)) mismatches++; if (expected.Length > 0) nonEmpty++; if (fromCentre > 350f && fromCentre - range < 350f) acrossOuterEdge++; } diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs new file mode 100644 index 000000000..4215ed062 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -0,0 +1,327 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float4 = CultMath.float4; + +// Mining index (docs/aetheria-release-map.md, L3-L5; Soul F1, operator 2026-10-01: "Fix the belt freeze"): the zone's +// targeting index answers range and best-first queries from providers, and a query near a few rocks examines few +// rocks however large the belt. Every answer is compared with a test-local scan of every rock. +public sealed partial class MiningCut3Tests +{ + private static void ChunksWithin(Scene s, float2 from, float range, List into) + { + var candidates = new List(); + s.Zone.Targets.Within(new TargetSearch(from, range), candidates); + into.Clear(); + into.AddRange(candidates.Where(c => c.Target.Chunk.HasValue).Select(c => c.Target.Chunk.Value)); + } + + private static IEnumerable AllChunks(Scene s) => + s.Belts.SelectMany(b => Enumerable.Range(0, ((AsteroidBeltData) s.Zone.Planets[b]).Asteroids.Length).Select(i => new ChunkId(b, i))); + + // Rocks spread evenly over the annulus [inner, outer]: `count` of them at the same density per area as any other + // call with the same density. + private static Asteroid[] Annulus(System.Random rng, int count, float inner, float outer) => + Enumerable.Range(0, count).Select(_ => + Rock(sqrt(inner * inner + (float) rng.NextDouble() * (outer * outer - inner * inner)), (float) rng.NextDouble(), + (float) rng.NextDouble())).ToArray(); + + // A ship at `at` with one sensor of the given sensitivity and curve, and one gun of `reach`. + private Ship Observer(Scene s, float2 at, float reach, float sensitivity, BezierCurve curve) + { + var eye = Gear($"Eye {Guid.NewGuid():N}", new SensorData + { + Sensitivity = Constant(sensitivity), + SensitivityCurve = curve, + PingBoost = Constant(.02f), + PingEnergy = Constant(0f), + PingVisibility = Constant(5f), + PingRange = Constant(300f), + PingCooldown = Constant(4f) + }); + s.Cache.Upsert(eye); + var ship = SpawnShip(s, at, weaponRanges: reach); + Assert.True(ship.TryEquip(Mint(s, eye))); + Tick((ship, at)); // equipment comes online and weapons read their ranges on the first tick + return ship; + } + + // Within's chunks, at random positions and times -- including after time jumps that shear every band past its + // re-key threshold many times over -- equal the scan of every rock, broken rocks left out. + [Fact] + public void WithinIsExactlyTheBruteForceSet() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(5); + var centre = float2(-300, 820); + var s = BuildSceneAt(centre, 5000f, 1234.5, new[] { kind }, + Belt(kind, Annulus(rng, 3000, 200f, 900f)), Belt(kind, Annulus(rng, 700, 1000f, 1060f))); + for (var i = 0; i < 40; i++) Assert.True(s.Zone.Wear(new ChunkId(s.Belts[0], rng.Next(3000)), 1e6f)); + + var found = new List(); + var nonEmpty = 0; + foreach (var step in new[] { 0f, 3f, 250f, 4e4f, 9e5f, 1.7f, 3e6f }) + { + if (step > 0f) s.Zone.Update(step); + for (var q = 0; q < 60; q++) + { + var angle = (float) (rng.NextDouble() * 2 * PI); + var from = centre + (float) rng.NextDouble() * 1200f * float2(cos(angle), sin(angle)); + var range = (float) rng.NextDouble() * 250f; + ChunksWithin(s, from, range, found); + var expected = AllChunks(s).Where(c => s.Zone.ChunkExists(c) && length(At(s, c) - from) <= range).ToList(); + Assert.Equal(expected.Count, found.Count); + Assert.True(expected.ToHashSet().SetEquals(found), $"step {step}, query {q}: {expected.Count} expected, {found.Count} found"); + if (expected.Count > 0) nonEmpty++; + } + } + Assert.True(nonEmpty > 200, $"degenerate queries: {nonEmpty} non-empty"); + } + + // Every bound a belt region states holds for every rock it hands back: distance, bearing and visibility. + [Fact] + public void BeltRegionBoundsHoldForEveryRock() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(8); + var centre = float2(150, -60); + var s = BuildSceneAt(centre, 1500f, 0, new[] { kind }, Belt(kind, Annulus(rng, 4000, 300f, 1300f))); + var data = (AsteroidBeltData) s.Zone.Planets[s.Belts[0]]; + var belt = new BeltTargets(s.Zone, s.Belts[0], data, s.Zone.Settings, 0); + var regions = new List(); + var candidates = new List(); + var checkedRocks = 0; + foreach (var step in new[] { 0f, 50f, 2e5f }) + { + if (step > 0f) s.Zone.Update(step); + for (var q = 0; q < 30; q++) + { + var angle = (float) (rng.NextDouble() * 2 * PI); + var from = centre + (float) rng.NextDouble() * 1500f * float2(cos(angle), sin(angle)); + var search = new TargetSearch(from, 50f + (float) rng.NextDouble() * 400f); + regions.Clear(); + belt.Regions(search, regions); + foreach (var region in regions) + { + candidates.Clear(); + belt.Candidates(search, region, candidates); + foreach (var c in candidates) + { + var to = c.Position - from; + var d = length(to); + Assert.InRange(d, region.Nearest, region.Farthest); + var off = abs(atan2(to.y, to.x) - region.BearingCentre); + off = min(off, 2 * PI - off); + Assert.True(off <= region.BearingHalfWidth, $"bearing {off} outside {region.BearingHalfWidth}"); + Assert.True(s.Zone.ChunkVisibility(c.Target.Chunk.Value) <= region.MaxVisibility); + checkedRocks++; + } + } + } + } + Assert.True(checkedRocks > 5000, $"only {checkedRocks} rocks checked"); + } + + // The visibility bound never drops a visible rock: over a belt the sun lights unevenly, at two sensor + // sensitivities and two curve shapes (one whose tangents overshoot its keys), VisibleChunksInReach equals the scan + // of every rock in reach that ChunkVisible accepts -- and the bound did prune some. + [Fact] + public void VisibilityPruningNeverDropsAVisibleChunk() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(13); + // The sun at the belt centre, its light gone by 750: the belt runs from bright to dark. + var s = BuildSceneAt(float2.zero, 1500f, 0, new[] { kind }, Belt(kind, Annulus(rng, 4000, 200f, 1200f))); + var overshoot = new BezierCurve { Keys = new[] { new float4(0, .4f, 0, 3f), new float4(1, .1f, -2f, 0) } }; + var offered = new List(); + var visibleSeen = 0; + var pruned = 0; + foreach (var sensitivity in new[] { 3f, .05f }) + foreach (var curve in new[] { Falloff(), overshoot }) + for (var q = 0; q < 8; q++) + { + var angle = (float) (rng.NextDouble() * 2 * PI); + var at = (200f + (float) rng.NextDouble() * 1000f) * float2(cos(angle), sin(angle)); + var observer = Observer(s, at, 350f, sensitivity, curve); + var before = s.Zone.Targets.Examined; + observer.VisibleChunksInReach(offered); + var examined = s.Zone.Targets.Examined - before; + var inReach = AllChunks(s).Where(c => s.Zone.ChunkExists(c) && length(At(s, c) - at) <= 350f).ToList(); + var expected = inReach.Where(observer.ChunkVisible).ToList(); + Assert.True(expected.ToHashSet().SetEquals(offered), $"sensitivity {sensitivity}: {expected.Count} visible, {offered.Count} offered"); + visibleSeen += expected.Count; + if (examined < inReach.Count) pruned++; + s.Zone.Entities.Remove(observer); + } + Assert.True(visibleSeen > 50 && pruned > 4, $"degenerate: {visibleSeen} visible, {pruned} queries pruned"); + } + + // The operator's rule for F1: the cost of a key press must not grow with belt size. Belts of 30k and 300k rocks at + // one density, the observer in the same neighbourhood: one VisibleChunksInReach examines about as many rocks in + // both, and under 2% of the larger belt. + [Fact] + public void AQueryNearAFewRocksExaminesFewRocks() + { + var kind = Kind("Asteroid", 4f); + // 100 square units per rock: 30k rocks fill 2000..2226, 300k fill 2000..3681. + long Examine(int count) + { + var outer = sqrt(2000f * 2000f + count * 100f / PI); + var s = BuildSceneAt(float2.zero, 20000f, 0, new[] { kind }, Belt(kind, Annulus(new System.Random(21), count, 2000f, outer))); + var observer = Observer(s, float2(2100, 0), 150f, 3f, Falloff()); + var offered = new List(); + var before = s.Zone.Targets.Examined; + observer.VisibleChunksInReach(offered); + Assert.NotEmpty(offered); + return s.Zone.Targets.Examined - before; + } + var small = Examine(30_000); + var large = Examine(300_000); + Assert.True(large < 2 * small && small < 2 * large, $"examined {small} at 30k, {large} at 300k"); + Assert.True(large < 300_000 / 50, $"examined {large} of 300k"); + } + + // A belt outside every sun's light: VisibleChunksInReach examines none of its rocks, while Within with no + // visibility bound still finds them. + [Fact] + public void DarkBandsAreSkippedWhole() + { + var kind = Kind("Asteroid", 4f); + var s = BuildSceneAt(float2.zero, 1000f, 0, new[] { kind }, Belt(kind, Annulus(new System.Random(3), 2000, 1500f, 1600f))); + var at = float2(1550f, 0f); + var observer = Observer(s, at, 300f, 3f, Falloff()); + var offered = new List(); + var before = s.Zone.Targets.Examined; + observer.VisibleChunksInReach(offered); + Assert.Equal(0, s.Zone.Targets.Examined - before); + Assert.Empty(offered); + var found = new List(); + ChunksWithin(s, at, 300f, found); + Assert.NotEmpty(found); + } + + // After a time jump that shears every band, one query re-sorts at most the budget of bands and still returns the + // scan's answer: the bands it did not re-sort are searched through their widened arcs. + [Fact] + public void ReKeyingIsBoundedPerQuery() + { + var kind = Kind("Asteroid", 4f); + var s = BuildSceneAt(float2.zero, 5000f, 0, new[] { kind }, Belt(kind, Annulus(new System.Random(17), 256 * 40, 200f, 1200f))); + var data = (AsteroidBeltData) s.Zone.Planets[s.Belts[0]]; + var belt = new BeltTargets(s.Zone, s.Belts[0], data, s.Zone.Settings, 0); + s.Zone.Update(5e6f); + var whole = new TargetSearch(float2(700, 0), 2000f); + var regions = new List(); + var before = belt.Rekeys; + belt.Regions(whole, regions); + Assert.Equal(8, belt.Rekeys - before); + regions.Clear(); + belt.Regions(whole, regions); + Assert.Equal(16, belt.Rekeys - before); + + var found = new List(); + foreach (var (from, range) in new[] { (float2(700, 0), 120f), (float2(-300, 500), 300f), (float2(0, -1100), 90f) }) + { + ChunksWithin(s, from, range, found); + var expected = AllChunks(s).Where(c => length(At(s, c) - from) <= range).ToList(); + Assert.NotEmpty(expected); + Assert.True(expected.ToHashSet().SetEquals(found) && expected.Count == found.Count); + } + } + + // Perception stays Entity's: an entity the observer has not detected is never offered, even inside reach; one it + // has detected is; and a search with no observer offers no entities at all. + [Fact] + public void EntitiesComeFromPerception() + { + var s = BuildScene(); + var observer = SpawnShip(s, float2(0, 0)); + var seen = SpawnShip(s, float2(40, 0)); + var unseen = SpawnShip(s, float2(0, 40)); + var candidates = new List(); + s.Zone.Targets.Within(new TargetSearch(float2(0, 0), 500f, observer), candidates); + Assert.Empty(candidates); + + observer.VisibleEntities.Add(seen); + s.Zone.Targets.Within(new TargetSearch(float2(0, 0), 500f, observer), candidates); + Assert.Equal(new[] { new TargetRef(seen) }, candidates.Select(c => c.Target)); + Assert.Equal(float2(40, 0), candidates[0].Position); + + s.Zone.Targets.Within(new TargetSearch(float2(0, 0), 20f, observer), candidates); + Assert.Empty(candidates); + s.Zone.Targets.Within(new TargetSearch(float2(0, 0), 500f), candidates); + Assert.Empty(candidates); + Assert.DoesNotContain(unseen, observer.VisibleEntities); + } + + private readonly struct NearestChunk : ITargetKey + { + private readonly float2 _from; + public NearestChunk(float2 from) => _from = from; + public float Bound(in TargetRegion region) => region.Nearest; + public bool Key(in TargetCandidate candidate, out float key) + { + key = length(candidate.Position - _from); + return candidate.Target.Chunk.HasValue; + } + public bool Precedes(in TargetCandidate a, in TargetCandidate b) => a.Target.Chunk.Value.Index < b.Target.Chunk.Value.Index; + } + + // Best by distance is the scan's nearest rock in reach, and examines fewer rocks than the range query. + [Fact] + public void BestIsTheBruteForceNearest() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(29); + var s = BuildSceneAt(float2.zero, 5000f, 0, new[] { kind }, Belt(kind, Annulus(rng, 5000, 300f, 1100f))); + s.Zone.Update(777f); + long bestCost = 0, withinCost = 0; + var candidates = new List(); + for (var q = 0; q < 100; q++) + { + var angle = (float) (rng.NextDouble() * 2 * PI); + var from = (float) rng.NextDouble() * 1300f * float2(cos(angle), sin(angle)); + var search = new TargetSearch(from, 40f + (float) rng.NextDouble() * 300f); + var expected = AllChunks(s).Where(c => length(At(s, c) - from) <= search.Reach) + .OrderBy(c => length(At(s, c) - from)).ThenBy(c => c.Index).ToList(); + var before = s.Zone.Targets.Examined; + var found = s.Zone.Targets.Best(search, new NearestChunk(from), out var best); + bestCost += s.Zone.Targets.Examined - before; + Assert.Equal(expected.Count > 0, found); + if (found) Assert.Equal(expected[0], best.Target.Chunk.Value); + before = s.Zone.Targets.Examined; + s.Zone.Targets.Within(search, candidates); + withinCost += s.Zone.Targets.Examined - before; + } + Assert.True(bestCost * 2 < withinCost, $"best examined {bestCost}, within {withinCost}"); + } + + // The hull bound holds: no sample of a curve with overshooting and stepped segments leaves HullRange. + [Fact] + public void ACurveStaysWithinItsHullRange() + { + var curve = new BezierCurve + { + Keys = new[] + { + new float4(0, .4f, 0, 6f), new float4(.5f, .1f, -2f, float.PositiveInfinity), new float4(.7f, .9f, 0, -4f), + new float4(1, .2f, 1f, 0) + } + }; + var range = curve.HullRange(); + for (var i = -10; i <= 1010; i++) + { + var value = curve.Evaluate(i / 1000f); + Assert.InRange(value, range.x, range.y); + } + Assert.True(range.y > 1f, $"hull max {range.y} does not reach past the keys"); + } +} From 1482a359224b7de665efcc6eaa64a5316ad4cac0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:04:33 +0200 Subject: [PATCH 34/45] Mining index: BeltTargets uses Math.PI (CultMath has no PI_DBL) Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Targeting/BeltTargets.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs index 6bde2c790..04c24ac59 100644 --- a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs +++ b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs @@ -105,11 +105,11 @@ public sealed class BeltTargets : ITargetProvider var centre = _zone.GetOrbitPosition(_zone.Orbits[_data.Orbit.Key].Data.Parent.Key); var offset = search.Position - centre; var distance = length(offset); - var turn = Turn(atan2(offset.y, offset.x) / (2 * PI_DBL)); + var turn = Turn(atan2(offset.y, offset.x) / (2 * Math.PI)); // Slack covers float rounding in Pose and in these bounds; it only ever widens a region. var slack = 1e-4f * (_bands[_bands.Length - 1].Outer + distance) + 1e-3f; var reach = search.Reach + slack; - var half = distance <= reach ? 1.0 : Math.Asin(reach / distance) / (2 * PI_DBL) + 1e-6; + var half = distance <= reach ? 1.0 : Math.Asin(reach / distance) / (2 * Math.PI) + 1e-6; var budget = RekeyBudget; // The first band whose outer edge reaches the search. @@ -177,7 +177,7 @@ public sealed class BeltTargets : ITargetProvider foreach (var sun in _zone.Suns()) { var fromSun = sun.Orbit.Position - centre; - sector.Range(length(fromSun), Turn(atan2(fromSun.y, fromSun.x) / (2 * PI_DBL)), out var sunNearest, out _); + sector.Range(length(fromSun), Turn(atan2(fromSun.y, fromSun.x) / (2 * Math.PI)), out var sunNearest, out _); light += Zone.SunLight(sun, max(0f, sunNearest - slack)); } var cells = _largestRadius / _zone.SchematicCellSize; @@ -241,16 +241,16 @@ public sealed class BeltTargets : ITargetProvider if (Width >= 1) { offAngle = 0; - farAngle = PI_DBL; + farAngle = Math.PI; } else { var into = Turn(turn - From); - offAngle = into <= Width ? 0 : Math.Min(into - Width, 1 - into) * 2 * PI_DBL; + offAngle = into <= Width ? 0 : Math.Min(into - Width, 1 - into) * 2 * Math.PI; // The sector point farthest in angle: the antipode if the sector holds it, else the farther end. farAngle = Turn(turn + .5 - From) <= Width - ? PI_DBL - : Math.Max(Separation(turn, From), Separation(turn, From + Width)) * 2 * PI_DBL; + ? Math.PI + : Math.Max(Separation(turn, From), Separation(turn, From + Width)) * 2 * Math.PI; } if (offAngle == 0) nearest = max(max(Inner - distance, distance - Outer), 0f); @@ -276,8 +276,8 @@ public sealed class BeltTargets : ITargetProvider } else { - var middleTurn = (From + Width / 2) * 2 * PI_DBL; - var halfAngle = Width * PI_DBL; + var middleTurn = (From + Width / 2) * 2 * Math.PI; + var halfAngle = Width * Math.PI; var middleRadius = (Inner + Outer) / 2; middle = middleRadius * float2((float) Math.Cos(middleTurn), (float) Math.Sin(middleTurn)); radius = max(Apart(middleRadius, Inner, halfAngle), Apart(middleRadius, Outer, halfAngle)); From 944baa02a7d2e783d7a7b0f8faeaaccf67f66e42 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:08:48 +0200 Subject: [PATCH 35/45] Mining index tests: fit the observer before it activates; break distinct rocks Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/MiningIndexTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs index 4215ed062..57073a91f 100644 --- a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -9,6 +9,7 @@ using CultMath; using Xunit; using static CultMath.math; using float2 = CultMath.float2; +using float3 = CultMath.float3; using float4 = CultMath.float4; // Mining index (docs/aetheria-release-map.md, L3-L5; Soul F1, operator 2026-10-01: "Fix the belt freeze"): the zone's @@ -48,8 +49,15 @@ public sealed partial class MiningCut3Tests PingCooldown = Constant(4f) }); s.Cache.Upsert(eye); - var ship = SpawnShip(s, at, weaponRanges: reach); + var gun = Gear($"Gun {reach}", new InstantWeaponData { Range = Constant(reach) }); + s.Cache.Upsert(gun); + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); Assert.True(ship.TryEquip(Mint(s, eye))); + Assert.True(ship.TryEquip(Mint(s, gun))); + s.Zone.Entities.Add(ship); + Hold(ship, at); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); Tick((ship, at)); // equipment comes online and weapons read their ranges on the first tick return ship; } @@ -64,7 +72,7 @@ public sealed partial class MiningCut3Tests var centre = float2(-300, 820); var s = BuildSceneAt(centre, 5000f, 1234.5, new[] { kind }, Belt(kind, Annulus(rng, 3000, 200f, 900f)), Belt(kind, Annulus(rng, 700, 1000f, 1060f))); - for (var i = 0; i < 40; i++) Assert.True(s.Zone.Wear(new ChunkId(s.Belts[0], rng.Next(3000)), 1e6f)); + for (var i = 0; i < 40; i++) Assert.True(s.Zone.Wear(new ChunkId(s.Belts[0], i * 73), 1e6f)); var found = new List(); var nonEmpty = 0; From a4e8e1471839b7fd309835625948ee868d48e49a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 15:25:32 +0200 Subject: [PATCH 36/45] Mining index: the index alone decides reach; tests pin what Stryker found loose BeltTargets drops its own reach skip (TargetRegion.CanHold owns it) and an empty-belt guard AsteroidBelt already makes unreachable. Tests add an off-centre sun, whole-belt searches across the antipode, cost after time passes, a late short step that re-keys nothing, and an entity search off the origin. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Targeting/BeltTargets.cs | 2 - .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 12 +++- .../Aetheria.Shared.Tests/MiningIndexTests.cs | 63 ++++++++++++------- 3 files changed, 50 insertions(+), 27 deletions(-) diff --git a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs index 04c24ac59..cba7ea628 100644 --- a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs +++ b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs @@ -100,7 +100,6 @@ public sealed class BeltTargets : ITargetProvider public void Regions(in TargetSearch search, List into) { - if (_bands.Length == 0) return; var time = _zone.ExactTime; var centre = _zone.GetOrbitPosition(_zone.Orbits[_data.Orbit.Key].Data.Parent.Key); var offset = search.Position - centre; @@ -171,7 +170,6 @@ public sealed class BeltTargets : ITargetProvider }; sector.Range(distance, turn, out var nearest, out var farthest); nearest = max(0f, nearest - slack); - if (nearest > search.Reach) continue; sector.Bearing(search.Position - centre, out var bearing, out var bearingHalf); var light = 0f; foreach (var sun in _zone.Suns()) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 989961f77..1170ade7a 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -99,10 +99,10 @@ public sealed partial class MiningCut3Tests : IDisposable // The same scene with its sun, and so every belt centre, at `sunAt`. private Scene BuildSceneAt(float2 sunAt, FieldKindData[] kinds, params BeltSpec[] belts) => - BuildSceneAt(sunAt, 1000f, 0, kinds, belts); + BuildSceneAt(sunAt, 1000f, 0, null, kinds, belts); - // The same, with the sun's light radius and the zone's starting time chosen. - private Scene BuildSceneAt(float2 sunAt, float lightRadius, double time, FieldKindData[] kinds, params BeltSpec[] belts) + // The same, with the suns' light radius, the zone's starting time and more suns (fixed, belt-less) chosen. + private Scene BuildSceneAt(float2 sunAt, float lightRadius, double time, float2[] moreSuns, FieldKindData[] kinds, params BeltSpec[] belts) { var dir = Path.Combine(_root, $"scene{_fixtureCount++}"); Directory.CreateDirectory(dir); @@ -150,6 +150,12 @@ public sealed partial class MiningCut3Tests : IDisposable Mass = 10000f, Time = time }; + foreach (var at in moreSuns ?? new float2[0]) + { + var orbit = cache.Upsert(new OrbitData { FixedPosition = at }).Key; + pack.Orbits.Add(new CultRecordRef(orbit)); + pack.Planets.Add(new CultRecordRef(cache.Upsert(new SunData { Orbit = new CultRecordRef(orbit) }).Key)); + } var scene = new Scene { Cache = cache, Items = items, Hull = hull, Eye = eye, DimEye = dimEye, Pack = pack }; scene.Kinds.AddRange(kinds ?? new FieldKindData[0]); foreach (var spec in belts) diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs index 57073a91f..f8dac85cc 100644 --- a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -70,7 +70,7 @@ public sealed partial class MiningCut3Tests var kind = Kind("Asteroid", 4f); var rng = new System.Random(5); var centre = float2(-300, 820); - var s = BuildSceneAt(centre, 5000f, 1234.5, new[] { kind }, + var s = BuildSceneAt(centre, 5000f, 1234.5, null, new[] { kind }, Belt(kind, Annulus(rng, 3000, 200f, 900f)), Belt(kind, Annulus(rng, 700, 1000f, 1060f))); for (var i = 0; i < 40; i++) Assert.True(s.Zone.Wear(new ChunkId(s.Belts[0], i * 73), 1e6f)); @@ -101,7 +101,8 @@ public sealed partial class MiningCut3Tests var kind = Kind("Asteroid", 4f); var rng = new System.Random(8); var centre = float2(150, -60); - var s = BuildSceneAt(centre, 1500f, 0, new[] { kind }, Belt(kind, Annulus(rng, 4000, 300f, 1300f))); + // A second sun off the belt centre, so the light bound's bearing to a sun matters. + var s = BuildSceneAt(centre, 1500f, 0, new[] { centre + float2(900, 400) }, new[] { kind }, Belt(kind, Annulus(rng, 4000, 300f, 1300f))); var data = (AsteroidBeltData) s.Zone.Planets[s.Belts[0]]; var belt = new BeltTargets(s.Zone, s.Belts[0], data, s.Zone.Settings, 0); var regions = new List(); @@ -110,11 +111,14 @@ public sealed partial class MiningCut3Tests foreach (var step in new[] { 0f, 50f, 2e5f }) { if (step > 0f) s.Zone.Update(step); - for (var q = 0; q < 30; q++) + for (var q = 0; q < 40; q++) { + // Every fourth search starts near the centre and reaches the whole belt, so sectors across the + // searcher's antipode are bounded too. + var whole = q % 4 == 0; var angle = (float) (rng.NextDouble() * 2 * PI); - var from = centre + (float) rng.NextDouble() * 1500f * float2(cos(angle), sin(angle)); - var search = new TargetSearch(from, 50f + (float) rng.NextDouble() * 400f); + var from = centre + (float) rng.NextDouble() * (whole ? 120f : 1500f) * float2(cos(angle), sin(angle)); + var search = new TargetSearch(from, whole ? 1500f : 50f + (float) rng.NextDouble() * 400f); regions.Clear(); belt.Regions(search, regions); foreach (var region in regions) @@ -147,7 +151,7 @@ public sealed partial class MiningCut3Tests var kind = Kind("Asteroid", 4f); var rng = new System.Random(13); // The sun at the belt centre, its light gone by 750: the belt runs from bright to dark. - var s = BuildSceneAt(float2.zero, 1500f, 0, new[] { kind }, Belt(kind, Annulus(rng, 4000, 200f, 1200f))); + var s = BuildSceneAt(float2.zero, 1500f, 0, null, new[] { kind }, Belt(kind, Annulus(rng, 4000, 200f, 1200f))); var overshoot = new BezierCurve { Keys = new[] { new float4(0, .4f, 0, 3f), new float4(1, .1f, -2f, 0) } }; var offered = new List(); var visibleSeen = 0; @@ -183,13 +187,21 @@ public sealed partial class MiningCut3Tests long Examine(int count) { var outer = sqrt(2000f * 2000f + count * 100f / PI); - var s = BuildSceneAt(float2.zero, 20000f, 0, new[] { kind }, Belt(kind, Annulus(new System.Random(21), count, 2000f, outer))); - var observer = Observer(s, float2(2100, 0), 150f, 3f, Falloff()); + var s = BuildSceneAt(float2.zero, 20000f, 0, null, new[] { kind }, Belt(kind, Annulus(new System.Random(21), count, 2000f, outer))); + var at = 2100f * float2(cos(1f), sin(1f)); + var observer = Observer(s, at, 150f, 3f, Falloff()); var offered = new List(); var before = s.Zone.Targets.Examined; observer.VisibleChunksInReach(offered); Assert.NotEmpty(offered); - return s.Zone.Targets.Examined - before; + var cost = s.Zone.Targets.Examined - before; + // Time passes, too little to shear a band past re-keying: the arcs widen by their drift only. + s.Zone.Update(5f); + Tick((observer, at)); + before = s.Zone.Targets.Examined; + observer.VisibleChunksInReach(offered); + Assert.True(s.Zone.Targets.Examined - before < 2 * cost, $"after 5 s, examined {s.Zone.Targets.Examined - before} against {cost}"); + return cost; } var small = Examine(30_000); var large = Examine(300_000); @@ -203,7 +215,7 @@ public sealed partial class MiningCut3Tests public void DarkBandsAreSkippedWhole() { var kind = Kind("Asteroid", 4f); - var s = BuildSceneAt(float2.zero, 1000f, 0, new[] { kind }, Belt(kind, Annulus(new System.Random(3), 2000, 1500f, 1600f))); + var s = BuildSceneAt(float2.zero, 1000f, 0, null, new[] { kind }, Belt(kind, Annulus(new System.Random(3), 2000, 1500f, 1600f))); var at = float2(1550f, 0f); var observer = Observer(s, at, 300f, 3f, Falloff()); var offered = new List(); @@ -222,14 +234,20 @@ public sealed partial class MiningCut3Tests public void ReKeyingIsBoundedPerQuery() { var kind = Kind("Asteroid", 4f); - var s = BuildSceneAt(float2.zero, 5000f, 0, new[] { kind }, Belt(kind, Annulus(new System.Random(17), 256 * 40, 200f, 1200f))); + var s = BuildSceneAt(float2.zero, 5000f, 1e5, null, new[] { kind }, Belt(kind, Annulus(new System.Random(17), 256 * 40, 200f, 1200f))); var data = (AsteroidBeltData) s.Zone.Planets[s.Belts[0]]; - var belt = new BeltTargets(s.Zone, s.Belts[0], data, s.Zone.Settings, 0); - s.Zone.Update(5e6f); + var belt = new BeltTargets(s.Zone, s.Belts[0], data, s.Zone.Settings, 1e5); var whole = new TargetSearch(float2(700, 0), 2000f); var regions = new List(); + // Ten seconds shear no band past the limit, however late the zone's clock: nothing is re-keyed. + s.Zone.Update(10f); var before = belt.Rekeys; belt.Regions(whole, regions); + Assert.Equal(0, belt.Rekeys - before); + regions.Clear(); + s.Zone.Update(5e6f); + before = belt.Rekeys; + belt.Regions(whole, regions); Assert.Equal(8, belt.Rekeys - before); regions.Clear(); belt.Regions(whole, regions); @@ -251,21 +269,22 @@ public sealed partial class MiningCut3Tests public void EntitiesComeFromPerception() { var s = BuildScene(); - var observer = SpawnShip(s, float2(0, 0)); - var seen = SpawnShip(s, float2(40, 0)); - var unseen = SpawnShip(s, float2(0, 40)); + var here = float2(300, 200); + var observer = SpawnShip(s, here); + var seen = SpawnShip(s, here + float2(40, 0)); + var unseen = SpawnShip(s, here + float2(0, 40)); var candidates = new List(); - s.Zone.Targets.Within(new TargetSearch(float2(0, 0), 500f, observer), candidates); + s.Zone.Targets.Within(new TargetSearch(here, 500f, observer), candidates); Assert.Empty(candidates); observer.VisibleEntities.Add(seen); - s.Zone.Targets.Within(new TargetSearch(float2(0, 0), 500f, observer), candidates); + s.Zone.Targets.Within(new TargetSearch(here, 50f, observer), candidates); Assert.Equal(new[] { new TargetRef(seen) }, candidates.Select(c => c.Target)); - Assert.Equal(float2(40, 0), candidates[0].Position); + Assert.Equal(here + float2(40, 0), candidates[0].Position); - s.Zone.Targets.Within(new TargetSearch(float2(0, 0), 20f, observer), candidates); + s.Zone.Targets.Within(new TargetSearch(here, 20f, observer), candidates); Assert.Empty(candidates); - s.Zone.Targets.Within(new TargetSearch(float2(0, 0), 500f), candidates); + s.Zone.Targets.Within(new TargetSearch(here, 500f), candidates); Assert.Empty(candidates); Assert.DoesNotContain(unseen, observer.VisibleEntities); } @@ -289,7 +308,7 @@ public sealed partial class MiningCut3Tests { var kind = Kind("Asteroid", 4f); var rng = new System.Random(29); - var s = BuildSceneAt(float2.zero, 5000f, 0, new[] { kind }, Belt(kind, Annulus(rng, 5000, 300f, 1100f))); + var s = BuildSceneAt(float2.zero, 5000f, 0, null, new[] { kind }, Belt(kind, Annulus(rng, 5000, 300f, 1100f))); s.Zone.Update(777f); long bestCost = 0, withinCost = 0; var candidates = new List(); From 16f7bdbfabeb85e98d31395806baf6d00e8e07d6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 16:48:05 +0200 Subject: [PATCH 37/45] Mining index pins: a belt region's bounds stay tight and a rock exactly at reach is found Three tests in MiningIndexTests, no production change. RegionsStayNearTheSearchArc bounds the share of a small search's regions that lie past reach on both sides (window widened, nearest loosened); VisibilityBoundPrunesADimBelt bounds rocks examined against rocks in reach in a half-lit belt of largest rocks; ARockExactlyAtReachIsFound searches from 1-300 units with reach set to a rock's exact distance, a third of them radially outward from a belt's outermost rock. Each threshold's unmutated and mutant values are in its comment. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/MiningIndexTests.cs | 145 +++++++++++++++++- 1 file changed, 142 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs index f8dac85cc..d6880d32d 100644 --- a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -94,15 +94,21 @@ public sealed partial class MiningCut3Tests Assert.True(nonEmpty > 200, $"degenerate queries: {nonEmpty} non-empty"); } + // 4,000 rocks over 300..1300 about `centre`, and a second sun off the belt centre, so the light bound's bearing to + // a sun matters. + private Scene TwoSunBelt(System.Random rng, float2 centre) + { + var kind = Kind("Asteroid", 4f); + return BuildSceneAt(centre, 1500f, 0, new[] { centre + float2(900, 400) }, new[] { kind }, Belt(kind, Annulus(rng, 4000, 300f, 1300f))); + } + // Every bound a belt region states holds for every rock it hands back: distance, bearing and visibility. [Fact] public void BeltRegionBoundsHoldForEveryRock() { - var kind = Kind("Asteroid", 4f); var rng = new System.Random(8); var centre = float2(150, -60); - // A second sun off the belt centre, so the light bound's bearing to a sun matters. - var s = BuildSceneAt(centre, 1500f, 0, new[] { centre + float2(900, 400) }, new[] { kind }, Belt(kind, Annulus(rng, 4000, 300f, 1300f))); + var s = TwoSunBelt(rng, centre); var data = (AsteroidBeltData) s.Zone.Planets[s.Belts[0]]; var belt = new BeltTargets(s.Zone, s.Belts[0], data, s.Zone.Settings, 0); var regions = new List(); @@ -351,4 +357,137 @@ public sealed partial class MiningCut3Tests } Assert.True(range.y > 1f, $"hull max {range.y} does not reach past the keys"); } + + // Rocks of one Size spread evenly over the annulus [inner, outer]. + private static Asteroid[] AnnulusOfSize(System.Random rng, int count, float inner, float outer, float size) => + Enumerable.Range(0, count).Select(_ => + Rock(sqrt(inner * inner + (float) rng.NextDouble() * (outer * outer - inner * inner)), (float) rng.NextDouble(), size)).ToArray(); + + // A belt's regions stay near the search's arc: of the regions a small search gets, few lie wholly past its reach + // (the arc window is not widened), and some do (the sector's nearest distance is not loosened toward zero). + // Measured on a4e8e147 over these 120 searches, as the share of regions with Nearest > Reach: unmutated 3.97% + // (15 of 378). Window widened: BeltTargets.cs:111 asin * 2pi 82.96%, :111 asin(reach * distance) 85.89%, :131 + // 2 / half 85.89%, :140 Math.Max(to, 1) 75.29%. Nearest loosened: :247 / 2 * pi 0.00%. The ceiling, 20%, is 5.0x + // the unmutated share and 3.8x below the least widened mutant; the floor, 2%, is half the unmutated share. + [Fact] + public void RegionsStayNearTheSearchArc() + { + var rng = new System.Random(8); + var s = TwoSunBelt(rng, float2(150, -60)); + var data = (AsteroidBeltData) s.Zone.Planets[s.Belts[0]]; + var belt = new BeltTargets(s.Zone, s.Belts[0], data, s.Zone.Settings, 0); + var regions = new List(); + var searches = new System.Random(31); + int total = 0, past = 0; + // At the key time, and after 5 s, when the arcs have widened by their drift. + foreach (var step in new[] { 0f, 5f }) + { + if (step > 0f) s.Zone.Update(step); + for (var q = 0; q < 60; q++) + { + var rock = At(s, new ChunkId(s.Belts[0], searches.Next(data.Asteroids.Length))); + var angle = (float) (searches.NextDouble() * 2 * PI); + var from = rock + (float) searches.NextDouble() * 20f * float2(cos(angle), sin(angle)); + var search = new TargetSearch(from, 30f + (float) searches.NextDouble() * 50f); + regions.Clear(); + belt.Regions(search, regions); + total += regions.Count; + past += regions.Count(r => r.Nearest > search.Reach); + } + } + var share = (double) past / total; + Assert.True(share < .2, $"{past} of {total} regions lie past reach: the search window has widened"); + Assert.True(share > .02, $"{past} of {total} regions lie past reach: region distances have loosened"); + } + + // The visibility bound prunes: a belt of rocks all of the largest Size, so the belt-wide radius bound is exact per + // rock, lit by a sun at its centre whose light ends inside it. Summed over the spots, one VisibleChunksInReach + // each, the rocks examined are a small share of the rocks in reach. + // Measured on a4e8e147, examined over in reach: unmutated 0.162 (231 of 1,429); BeltTargets.cs:181 radius * cell + // size, the bound 16x too loose, 0.434 (620). The ceiling, 0.265, is 1.64x the unmutated share and 1.64x below + // the mutant's. (:247 / 2 * pi, which loosens region distances, also fails it at 0.318.) The spots are where + // light, not reach, decides which regions survive. In a measurement at 450 units, sector geometry decided + // (94 examined unmutated, 105 mutated), and past the light nothing is lit either way. + // The cut asked for Examined <= 2x the rocks in reach within their own detection distance. Unmutated it fails: 64 + // examined for 5 such rocks at 250 units. A region is 32 rocks of a band spanning an eighth of the orbit, so the + // segment size sets Examined, not the detectable count. + [Fact] + public void VisibilityBoundPrunesADimBelt() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(13); + var s = BuildSceneAt(float2.zero, 750f, 0, null, new[] { kind }, Belt(kind, AnnulusOfSize(rng, 3000, 200f, 1200f, 1f))); + var all = AllChunks(s).ToList(); + // The sensitivity at which the brightest rock is detectable 120 units away. + var brightest = all.Max(c => s.Zone.ChunkVisibility(c)); + var unit = Observer(s, float2(5000, 0), 350f, 1f, Falloff()); + var sensitivity = 120f / (brightest * unit.DetectionReachPerVisibility()); + s.Zone.Entities.Remove(unit); + + var offered = new List(); + long examined = 0, inReach = 0; + var spots = new System.Random(41); + foreach (var radius in new[] { 250f, 350f, 550f, 650f, 700f, 740f }) + { + var angle = (float) (spots.NextDouble() * 2 * PI); + var at = radius * float2(cos(angle), sin(angle)); + var observer = Observer(s, at, 350f, sensitivity, Falloff()); + Assert.InRange(brightest * observer.DetectionReachPerVisibility(), 80f, 150f); + var before = s.Zone.Targets.Examined; + observer.VisibleChunksInReach(offered); + examined += s.Zone.Targets.Examined - before; + inReach += all.Count(c => length(At(s, c) - at) <= 350f); + s.Zone.Entities.Remove(observer); + } + Assert.True(examined < .265 * inReach, $"examined {examined} of {inReach} rocks in reach: the visibility bound has loosened"); + } + + // A rock exactly at reach is found, and Within equals the scan of every rock in reach. Two belts, one spread over + // 50..6000 and one thin and dense over 999..1001, under the fixture's orbit periods and the game's (period = + // distance), across time steps up to 3.3e7 s. Every third search starts from a belt's outermost rock, radially + // outward and over 50 units past the belt's edge, so the rock lies on its band's outer bound as well as on the + // search's reach. + // Measured on a4e8e147: unmutated, none of the 240 searches miss. BeltTargets.cs:110 Reach - slack misses 94 + // (80 of them outer). :109 Outer - distance misses all 80 outer searches. That mutant shortens reach by 0.005 to + // 0.03 units only beyond a belt's edge, which drops a rock only when the rock lies on a region's bound. With + // random directions, the cut's draft for the outer third, it missed none of 80 here and none of Soul's 640. + [Fact] + public void ARockExactlyAtReachIsFound() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(101); + var centre = float2(40, -70); + var spread = Enumerable.Range(0, 700).Select(_ => Rock(50f + (float) rng.NextDouble() * 5950f, (float) rng.NextDouble(), (float) rng.NextDouble())).ToArray(); + var s = BuildSceneAt(centre, 20000f, 777.25, null, new[] { kind }, Belt(kind, spread), Belt(kind, Annulus(rng, 3000, 999f, 1001f))); + var game = PlanetSettings(20000f); + game.OrbitPeriod = new ExponentialCurve { Multiplier = 1f, Exponent = 1f, Constant = 0f }; + var searches = 0; + foreach (var zone in new[] { s.Zone, new Zone(s.Items, game, s.Pack, new GalaxyZone { Name = "Game periods", Owner = null }, null) }) + foreach (var step in new[] { 0f, 13f, 1e4f, 3.3e7f }) + { + if (step > 0f) zone.Update(step); + for (var q = 0; q < 30; q++) + { + var field = s.Belts[q % 2]; + var rocks = ((AsteroidBeltData) zone.Planets[field]).Asteroids; + var edge = q % 3 == 0; + var index = edge ? Enumerable.Range(0, rocks.Length).OrderBy(i => rocks[i].Distance).Last() : rng.Next(rocks.Length); + var rock = zone.ChunkPose(field, index).xy; + var angle = (float) (rng.NextDouble() * 2 * PI); + var direction = edge ? normalize(rock - centre) : float2(cos(angle), sin(angle)); + var from = rock + (edge ? 60f + (float) rng.NextDouble() * 240f : 1f + (float) rng.NextDouble() * 299f) * direction; + var reach = length(rock - from); + var candidates = new List(); + zone.Targets.Within(new TargetSearch(from, reach), candidates); + var found = candidates.Where(c => c.Target.Chunk.HasValue).Select(c => c.Target.Chunk.Value).ToList(); + Assert.Contains(new ChunkId(field, index), found); + var expected = s.Belts.SelectMany(b => Enumerable.Range(0, ((AsteroidBeltData) zone.Planets[b]).Asteroids.Length).Select(i => new ChunkId(b, i))) + .Where(c => zone.ChunkExists(c) && length(zone.ChunkPose(c.Field, c.Index).xy - from) <= reach).ToList(); + Assert.True(expected.ToHashSet().SetEquals(found) && expected.Count == found.Count, + $"step {step}, search {q}: {expected.Count} expected, {found.Count} found"); + searches++; + } + } + Assert.Equal(240, searches); + } } From ec2042b390294d47369174d8977df516300b544f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 5 Oct 2026 23:38:20 +0200 Subject: [PATCH 38/45] Mining target queries: reticle, next, previous and nearest are Entity's best-first index queries The four handlers in ActionGameManager call Entity.TargetUnderReticle, TargetNext, TargetPrevious and TargetNearestEnemy. TargetCandidates, which built, sorted and searched every visible ship and rock per press, is gone. Previous with no target takes the farthest. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 54 +----- Assets/Scripts/ServerShared/Entity.cs | 188 ++++++++++++++++++- 2 files changed, 189 insertions(+), 53 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index adc695371..b144f4019 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -369,42 +369,12 @@ public class ActionGameManager : MonoBehaviour #region Targeting - // Mining Cut 3 (docs/mining-cut-refresh.md): the reticle and cycling offer visible ships and the visible - // chunks within reach (Entity.VisibleChunksInReach, Q13), through the one writer, Entity.SetTarget. - Input.Player.TargetReticle.performed += context => - { - var candidates = TargetCandidates(); - if (candidates.Length == 0) return; - var underReticle = candidates - .MaxBy(x => dot(normalize(x.position - CurrentEntity.Position), CurrentEntity.LookDirection)).target; - CurrentEntity.SetTarget(CurrentEntity.Target.Value.Equals(underReticle) ? TargetRef.None : underReticle); - }; - - // Nearest is enemies-only (it drives weapon lock), and picks the closest target, not the farthest. - Input.Player.TargetNearest.performed += context => - { - if(CurrentEntity.VisibleEnemies.Any()) - { - CurrentEntity.SetTarget(CurrentEntity.VisibleEnemies.Where(x => x != CurrentEntity) - .MinBy(x => length(x.Position - CurrentEntity.Position))); - } - }; - - Input.Player.TargetNext.performed += context => - { - var targets = TargetCandidates().OrderBy(x => length(x.position - CurrentEntity.Position)).Select(x => x.target).ToArray(); - if (targets.Length == 0) return; - var currentTargetIndex = Array.IndexOf(targets, CurrentEntity.Target.Value); - CurrentEntity.SetTarget(targets[(currentTargetIndex + 1) % targets.Length]); - }; - - Input.Player.TargetPrevious.performed += context => - { - var targets = TargetCandidates().OrderBy(x => length(x.position - CurrentEntity.Position)).Select(x => x.target).ToArray(); - if (targets.Length == 0) return; - var currentTargetIndex = Array.IndexOf(targets, CurrentEntity.Target.Value); - CurrentEntity.SetTarget(targets[(currentTargetIndex + targets.Length - 1) % targets.Length]); - }; + // The reticle, nearest, next and previous presses each choose through Entity (mining target-queries cut): + // the rule lives with the entity, so the player, the test agent and any director pick alike. + Input.Player.TargetReticle.performed += context => CurrentEntity.TargetUnderReticle(); + Input.Player.TargetNearest.performed += context => CurrentEntity.TargetNearestEnemy(); + Input.Player.TargetNext.performed += context => CurrentEntity.TargetNext(); + Input.Player.TargetPrevious.performed += context => CurrentEntity.TargetPrevious(); // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's revealed // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), same @@ -1168,18 +1138,6 @@ public class ActionGameManager : MonoBehaviour } } - // Mining Cut 3: what the reticle and cycling can pick -- visible ships, and visible chunks within reach -- each - // with a world position to measure bearing and distance against. - private readonly List _chunksInReach = new List(); - private (TargetRef target, float3 position)[] TargetCandidates() - { - CurrentEntity.VisibleChunksInReach(_chunksInReach); - return CurrentEntity.VisibleEntities.Where(x => x != CurrentEntity) - .Select(x => (target: (TargetRef) x, position: x.Position)) - .Concat(_chunksInReach.Select(chunk => (target: (TargetRef) chunk, position: ChunkWorldPosition(chunk)))) - .ToArray(); - } - // A chunk's simulated position is planar (Zone.ChunkPose); the renderer draws it at the asteroid layer's height. private float3 ChunkWorldPosition(ChunkId chunk) { diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index cc5d17094..fba59dd8e 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -377,18 +377,196 @@ public abstract class Entity // targeting index proposes the chunks that could pass (no observer, so no entities); ChunkVisible decides. public void VisibleChunksInReach(List into) { - var reach = 0f; - foreach (var weapon in _weapons) - if (weapon.CanMine && (weapon.Item == null || weapon.Item.Active.Value)) - reach = max(reach, weapon.Range); into.Clear(); - Zone.Targets.Within(new TargetSearch(Position.xz, reach, reachPerVisibility: DetectionReachPerVisibility()), _targetCandidates); + Zone.Targets.Within(MiningSearch(), _targetCandidates); foreach (var candidate in _targetCandidates) if (candidate.Target.Chunk is ChunkId chunk && ChunkVisible(chunk)) into.Add(chunk); into.Sort(ByIndexThenField); } + // The one search every chunk question asks the zone's targeting index: from here, as far as the longest range + // among this entity's active weapons that can mine (Q13 A; Weapon.CanMine, Q12 A), bounded by what its sensors + // could detect. + private TargetSearch MiningSearch() + { + var reach = 0f; + foreach (var weapon in _weapons) + if (weapon.CanMine && (weapon.Item == null || weapon.Item.Active.Value)) + reach = max(reach, weapon.Range); + return new TargetSearch(Position.xz, reach, reachPerVisibility: DetectionReachPerVisibility()); + } + + // Mining target-queries cut: which target a press of reticle, next, previous or nearest picks. Each is one + // best-first question to the index (no candidate list is built, sorted or kept), answered through SetTarget, the + // one writer. Candidates are visible ships at any range and visible chunks within mining reach. + + // The candidate nearest the look direction by planar angle; picking the held target again clears it. + public bool TargetUnderReticle() + { + var look = float2(LookDirection.x, LookDirection.z); + if (!PickTarget(new AngleKey(this, Position.xz, look), out var picked)) return false; + return SetTarget(Target.Value.Equals(picked) ? TargetRef.None : picked); + } + + // Enemies only, as it drives weapon lock; the nearest by distance. + public bool TargetNearestEnemy() + { + Entity nearest = null; + var nearestDistance = float.PositiveInfinity; + foreach (var enemy in VisibleEnemies) + { + if (enemy == this) continue; + var distance = length(enemy.Position - Position); + if (distance < nearestDistance) + { + nearest = enemy; + nearestDistance = distance; + } + } + return nearest != null && SetTarget(nearest); + } + + // The next candidate after the current target by (planar distance, TargetRef), wrapping to the nearest; with + // nothing targeted, the nearest. + public bool TargetNext() => CycleTarget(false); + + // The reverse: the next one nearer, wrapping to the farthest; with nothing targeted, the farthest. + public bool TargetPrevious() => CycleTarget(true); + + private bool CycleTarget(bool reverse) + { + var origin = Position.xz; + var current = Target.Value; + TargetRef picked = default; + var found = !current.IsNone && + PickTarget(new DistanceKey(this, origin, reverse, true, DistanceFrom(origin, current), current), out picked); + if (!found && !PickTarget(new DistanceKey(this, origin, reverse, false, 0f, default), out picked)) return false; + return SetTarget(picked); + } + + private float DistanceFrom(float2 origin, TargetRef target) => + length((target.Chunk is ChunkId chunk ? Zone.ChunkPose(chunk.Field, chunk.Index).xy : target.Entity.Position.xz) - origin); + + // The least-key eligible candidate: the best visible ship, and the index's best chunk within mining reach. Ships + // are not bounded by reach, so they are read from VisibleEntities (the ships this entity sees); the belt's size + // never enters. + private bool PickTarget(TKey query, out TargetRef picked) where TKey : ITargetKey + { + var best = default(TargetCandidate); + var bestKey = float.PositiveInfinity; + var found = false; + foreach (var entity in VisibleEntities) + Consider(query, new TargetCandidate(entity, entity.Position.xz), ref found, ref best, ref bestKey); + if (Zone.Targets.Best(MiningSearch(), query, out var chunk)) + Consider(query, chunk, ref found, ref best, ref bestKey); + picked = found ? best.Target : TargetRef.None; + return found; + } + + private static void Consider(TKey query, in TargetCandidate candidate, ref bool found, ref TargetCandidate best, ref float bestKey) + where TKey : ITargetKey + { + if (!query.Key(candidate, out var key)) return; + if (found && !(key < bestKey || key == bestKey && query.Precedes(candidate, best))) return; + best = candidate; + bestKey = key; + found = true; + } + + // What a press may pick: another ship, or a chunk this entity can see. Reach is the index's to apply. + private bool Eligible(in TargetCandidate candidate) => + candidate.Target.Chunk is ChunkId chunk ? ChunkVisible(chunk) : candidate.Target.Entity != this; + + // Entities come before chunks; entities by their place in the zone, chunks by field key then index. + private static int Order(TargetRef a, TargetRef b) + { + if (a.Chunk is ChunkId ca) + { + if (!(b.Chunk is ChunkId cb)) return 1; + var byField = string.CompareOrdinal(ca.Field.Value, cb.Field.Value); + return byField != 0 ? byField : ca.Index.CompareTo(cb.Index); + } + if (b.Chunk.HasValue) return -1; + var entities = a.Entity.Zone.Entities; + return entities.IndexOf(a.Entity).CompareTo(entities.IndexOf(b.Entity)); + } + + // The planar angle from the look direction to the candidate. A region's least angle is its bearing interval's + // distance from the look direction. + private readonly struct AngleKey : ITargetKey + { + private readonly Entity _self; + private readonly float2 _origin, _look; + + public AngleKey(Entity self, float2 origin, float2 look) + { + _self = self; + _origin = origin; + _look = look; + } + + public float Bound(in TargetRegion region) + { + var off = abs(atan2(_look.y, _look.x) - region.BearingCentre); + off = min(off, 2 * PI - off); + // Slack covers float rounding in the bearings; it only ever lowers the bound. + return max(0f, off - region.BearingHalfWidth - 1e-4f); + } + + public bool Key(in TargetCandidate candidate, out float key) + { + var to = candidate.Position - _origin; + key = abs(atan2(_look.x * to.y - _look.y * to.x, dot(_look, to))); + return _self.Eligible(candidate); + } + + public bool Precedes(in TargetCandidate a, in TargetCandidate b) => Order(a.Target, b.Target) < 0; + } + + // Planar distance from the ship, nearest first, or farthest first when reversed. With a pivot only candidates + // strictly after it by (distance, TargetRef) are eligible, so cycling is a total, stable order. + private readonly struct DistanceKey : ITargetKey + { + private readonly Entity _self; + private readonly float2 _origin; + private readonly bool _reverse, _hasPivot; + private readonly float _pivotDistance; + private readonly TargetRef _pivot; + + public DistanceKey(Entity self, float2 origin, bool reverse, bool hasPivot, float pivotDistance, TargetRef pivot) + { + _self = self; + _origin = origin; + _reverse = reverse; + _hasPivot = hasPivot; + _pivotDistance = pivotDistance; + _pivot = pivot; + } + + public float Bound(in TargetRegion region) + { + if (_hasPivot && (_reverse ? region.Nearest > _pivotDistance : region.Farthest < _pivotDistance)) + return float.PositiveInfinity; + return _reverse ? -region.Farthest : region.Nearest; + } + + public bool Key(in TargetCandidate candidate, out float key) + { + var distance = length(candidate.Position - _origin); + key = _reverse ? -distance : distance; + if (!_self.Eligible(candidate)) return false; + if (!_hasPivot) return true; + var order = Order(candidate.Target, _pivot); + return _reverse + ? distance < _pivotDistance || distance == _pivotDistance && order < 0 + : distance > _pivotDistance || distance == _pivotDistance && order > 0; + } + + public bool Precedes(in TargetCandidate a, in TargetCandidate b) => + _reverse ? Order(a.Target, b.Target) > 0 : Order(a.Target, b.Target) < 0; + } + private readonly List _targetCandidates = new List(); private static readonly Comparison ByIndexThenField = (a, b) => a.Index != b.Index ? a.Index.CompareTo(b.Index) : a.Field.GetHashCode().CompareTo(b.Field.GetHashCode()); From 60383cf00e7cb4c50b100acefbf73595ce81b60d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 5 Oct 2026 23:38:21 +0200 Subject: [PATCH 39/45] Mining target queries tests: brute-force order, farthest, smallest angle, cost, reach, enemies Co-Authored-By: Claude Opus 5.5 --- .../MiningTargetQueriesTests.cs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs new file mode 100644 index 000000000..c03c6db63 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -0,0 +1,269 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Mining target-queries cut (docs/aetheria-release-map.md; operator 2026-10-03: "Fix the belt freeze", "Fix the +// off-by-one"; reticle-exact: "Keep reticles exact"): the reticle, next, previous and nearest presses are Entity's +// best-first questions to the zone's targeting index. Every answer is compared with a test-local sort or argmin of +// every visible candidate. +public sealed partial class MiningCut3Tests +{ + // One key press at 30k or 300k rocks of one density examines under this many rocks (measured: see the test). + private const long KeyPressExaminedCeiling = 3000; + + // A belt, a dense knot of rocks around `at` for the observer to see, and the observer: reach 120, sensor on. + private (Scene scene, Ship observer) TargetScene(int seed, float2 at, float reach = 120f, float sensitivity = 3f, int rocks = 3000) + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(seed); + var s = BuildSceneAt(float2.zero, 1500f, 0, null, new[] { kind }, Belt(kind, Annulus(rng, rocks, 300f, 700f))); + return (s, Observer(s, at, reach, sensitivity, Falloff())); + } + + // Visible ships at every range, in the observer's VisibleEntities. + private static Ship Sight(Scene s, Ship observer, float2 at, bool enemy = false) + { + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + s.Zone.Entities.Add(ship); + Hold(ship, at); + observer.VisibleEntities.Add(ship); + if (enemy) observer.VisibleEnemies.Add(ship); + return ship; + } + + // Every target a press may pick, as a plain scan: other visible ships, and visible chunks within reach, by + // planar distance then entity before chunk, entities by zone order, chunks by field key then index. + private static List ByDistance(Scene s, Ship observer, float reach) + { + var at = observer.Position.xz; + var all = new List<(TargetRef target, float distance)>(); + foreach (var e in observer.VisibleEntities) + if (e != observer) all.Add((e, length(e.Position.xz - at))); + foreach (var c in AllChunks(s)) + if (s.Zone.ChunkExists(c) && length(At(s, c) - at) <= reach && observer.ChunkVisible(c)) + all.Add((c, length(At(s, c) - at))); + return all.OrderBy(x => x.distance) + .ThenBy(x => x.target.Chunk.HasValue ? 1 : 0) + .ThenBy(x => x.target.Chunk.HasValue ? 0 : s.Zone.Entities.IndexOf(x.target.Entity)) + .ThenBy(x => x.target.Chunk.HasValue ? x.target.Chunk.Value.Field.Value : "", StringComparer.Ordinal) + .ThenBy(x => x.target.Chunk.HasValue ? x.target.Chunk.Value.Index : 0) + .Select(x => x.target).ToList(); + } + + private static void SightShips(Scene s, Ship observer, float2 at) + { + // Three ships at one distance (a tie), one past mining reach, one far beyond it. + Sight(s, observer, at + float2(40, 0)); + Sight(s, observer, at + float2(0, 40)); + Sight(s, observer, at + float2(-40, 0)); + Sight(s, observer, at + float2(250, 0)); + Sight(s, observer, at + float2(0, -900)); + } + + // Repeated Next from nothing visits every target in the scan's order and wraps; Previous is the reverse. Ties + // between ships at one distance, ships past reach, and rocks both near and at reach are all in the order. + [Fact] + public void NextAndPreviousWalkTheBruteForceOrder() + { + var walked = 0; + foreach (var (seed, at) in new[] { (7, float2(500, 0)), (8, float2(-350, 400)), (9, float2(0, -650)) }) + { + var (s, observer) = TargetScene(seed, at); + SightShips(s, observer, at); + var order = ByDistance(s, observer, 120f); + Assert.True(order.Count(t => t.Chunk.HasValue) >= 8 && order.Count(t => t.Entity != null) == 5, $"degenerate: {order.Count} candidates"); + + observer.SetTarget(TargetRef.None); + for (var i = 0; i <= order.Count; i++) + { + Assert.True(observer.TargetNext()); + Assert.Equal(order[i % order.Count], observer.Target.Value); + walked++; + } + observer.SetTarget(TargetRef.None); + for (var i = 0; i <= order.Count; i++) + { + Assert.True(observer.TargetPrevious()); + Assert.Equal(order[(order.Count - 1 - i % order.Count)], observer.Target.Value); + walked++; + } + // From every target, Next is its successor and Previous its predecessor. + for (var i = 0; i < order.Count; i++) + { + observer.SetTarget(order[i]); + observer.TargetNext(); + Assert.Equal(order[(i + 1) % order.Count], observer.Target.Value); + observer.SetTarget(order[i]); + observer.TargetPrevious(); + Assert.Equal(order[(i + order.Count - 1) % order.Count], observer.Target.Value); + } + } + Assert.True(walked > 60, $"degenerate: {walked} steps"); + } + + // Operator, "Fix the off-by-one": with nothing targeted, Previous takes the farthest candidate, not the one + // before it. + [Fact] + public void PreviousWithNoTargetPicksTheFarthest() + { + var at = float2(500, 0); + var (s, observer) = TargetScene(7, at); + Sight(s, observer, at + float2(60, 0)); + var order = ByDistance(s, observer, 120f); + Assert.True(order.Count > 3); + Assert.True(order[^1].Chunk.HasValue, "the farthest should be a rock here"); + Assert.True(observer.TargetPrevious()); + Assert.Equal(order[^1], observer.Target.Value); + Assert.NotEqual(order[^2], observer.Target.Value); + + // With a far ship as well, the farthest is the ship. + var far = Sight(s, observer, at + float2(0, -900)); + observer.SetTarget(TargetRef.None); + Assert.True(observer.TargetPrevious()); + Assert.Equal(new TargetRef(far), observer.Target.Value); + } + + // The reticle's pick is the scan's least planar angle, over ships and rocks alike, in many look directions; the + // same press again clears the target. + [Fact] + public void TheReticlePicksTheSmallestAngle() + { + var at = float2(-350, 400); + var (s, observer) = TargetScene(8, at); + SightShips(s, observer, at); + var order = ByDistance(s, observer, 120f); + Assert.True(order.Count > 15); + float2 Where(TargetRef t) => t.Chunk is ChunkId c ? At(s, c) : t.Entity.Position.xz; + var rng = new System.Random(3); + var picked = new HashSet(); + for (var i = 0; i < 60; i++) + { + var heading = rng.NextDouble() * 2 * Math.PI; + observer.LookDirection = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading)); + double Angle(TargetRef t) + { + var to = Where(t) - at; + return Math.Abs(Math.Atan2((double) Math.Cos(heading) * to.y - Math.Sin(heading) * to.x, + Math.Cos(heading) * to.x + Math.Sin(heading) * to.y)); + } + var expected = order.OrderBy(Angle).First(); + observer.SetTarget(TargetRef.None); + Assert.True(observer.TargetUnderReticle()); + Assert.Equal(expected, observer.Target.Value); + picked.Add(expected); + Assert.True(observer.TargetUnderReticle()); + Assert.True(observer.Target.Value.IsNone, "the second press on the same target clears it"); + } + Assert.True(picked.Count > 10, $"degenerate: {picked.Count} distinct picks"); + } + + // The operator's rule for F1: a key press does not grow with the belt. TargetNext, from nothing and from a held + // rock, at 30k and 300k rocks of one density, examines about as many in both, under a fixed ceiling. + // Measured at this commit: see ExaminedAtPress. + [Fact] + public void AKeyPressDoesNotGrowWithTheBelt() + { + var kind = Kind("Asteroid", 4f); + long Press(int count, bool held, bool reticle) + { + var outer = sqrt(2000f * 2000f + count * 100f / PI); + var s = BuildSceneAt(float2.zero, 20000f, 0, null, new[] { kind }, Belt(kind, Annulus(new System.Random(21), count, 2000f, outer))); + var at = 2100f * float2(cos(1f), sin(1f)); + var observer = Observer(s, at, 150f, 3f, Falloff()); + observer.LookDirection = float3(1, 0, 0); + var order = ByDistance(s, observer, 150f); + Assert.True(order.Count > 5); + if (held) observer.SetTarget(order[order.Count / 2]); + var before = s.Zone.Targets.Examined; + Assert.True(reticle ? observer.TargetUnderReticle() : observer.TargetNext()); + var cost = s.Zone.Targets.Examined - before; + if (!reticle) Assert.Equal(order[held ? order.Count / 2 + 1 : 0], observer.Target.Value); + return cost; + } + foreach (var (held, reticle) in new[] { (false, false), (true, false), (false, true) }) + { + var small = Press(30_000, held, reticle); + var large = Press(300_000, held, reticle); + Assert.True(large < 2 * small && small < 2 * large, $"held {held} reticle {reticle}: examined {small} at 30k, {large} at 300k"); + Assert.True(large < KeyPressExaminedCeiling, $"held {held} reticle {reticle}: examined {large}, ceiling {KeyPressExaminedCeiling}"); + } + } + + // No press ever picks a rock this ship cannot see, or one beyond mining reach; launchers add no reach (Q12 A). + [Fact] + public void DarkAndOutOfReachRocksAreNeverPicked() + { + var at = float2(500, 0); + // A dim sensor: only some rocks in reach are visible. + var (s, observer) = TargetScene(7, at, reach: 150f, sensitivity: .35f); + var inReach = AllChunks(s).Where(c => length(At(s, c) - at) <= 150f).ToList(); + var visible = inReach.Where(observer.ChunkVisible).ToList(); + Assert.True(visible.Count > 3 && visible.Count < inReach.Count, $"degenerate: {visible.Count} of {inReach.Count} visible"); + var picks = new HashSet(); + var rng = new System.Random(5); + for (var i = 0; i < 200; i++) + { + var heading = rng.NextDouble() * 2 * Math.PI; + observer.LookDirection = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading)); + observer.SetTarget(TargetRef.None); + switch (i % 3) + { + case 0: observer.TargetUnderReticle(); break; + case 1: observer.TargetNext(); observer.TargetNext(); break; + default: observer.TargetPrevious(); observer.TargetPrevious(); break; + } + if (observer.Target.Value.Chunk is ChunkId chunk) + { + Assert.Contains(chunk, visible); + picks.Add(chunk); + } + } + Assert.True(picks.Count > 3, $"degenerate: {picks.Count} distinct rocks picked"); + + // A launcher's longer range is no reach: a ship fitted with one alone picks no rock. + var launcher = Gear("Launcher", new LockWeaponData { Range = Constant(400f), LockSpeed = Constant(.5f), LockAngle = Constant(30f), DirectionImpact = Constant(1f) }); + s.Cache.Upsert(launcher); + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + Assert.True(ship.TryEquip(Mint(s, s.Eye))); + Assert.True(ship.TryEquip(Mint(s, launcher))); + s.Zone.Entities.Add(ship); + Hold(ship, at); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + Tick((ship, at)); + Assert.Contains(ship.Weapons, w => w.Range == 400f); + Assert.True(AllChunks(s).Any(c => length(At(s, c) - at) <= 400f && ship.ChunkVisible(c)), "there are rocks within the launcher's range"); + Assert.False(ship.TargetUnderReticle()); + Assert.False(ship.TargetNext()); + Assert.False(ship.TargetPrevious()); + Assert.True(ship.Target.Value.IsNone); + } + + // Nearest stays enemies only: a nearer rock and a nearer friendly ship are ignored. + [Fact] + public void NearestStaysEnemiesOnly() + { + var at = float2(500, 0); + var (s, observer) = TargetScene(7, at); + var friend = Sight(s, observer, at + float2(10, 0)); + var farEnemy = Sight(s, observer, at + float2(0, 90), enemy: true); + var nearEnemy = Sight(s, observer, at + float2(60, 0), enemy: true); + Assert.True(observer.TargetNearestEnemy()); + Assert.Equal(new TargetRef(nearEnemy), observer.Target.Value); + + observer.VisibleEnemies.Clear(); + observer.SetTarget(friend); + Assert.False(observer.TargetNearestEnemy()); + Assert.Equal(new TargetRef(friend), observer.Target.Value); + Assert.NotEqual(new TargetRef(farEnemy), observer.Target.Value); + } +} From 793fdc87893331de53dff1b59a7acedaa225221c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 5 Oct 2026 23:44:38 +0200 Subject: [PATCH 40/45] Mining target queries tests: pick the sensor that sees a share of the rocks in reach; report every press cost together Co-Authored-By: Claude Opus 5.5 --- .../MiningTargetQueriesTests.cs | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs index c03c6db63..bd485a6b2 100644 --- a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -20,13 +20,22 @@ public sealed partial class MiningCut3Tests // One key press at 30k or 300k rocks of one density examines under this many rocks (measured: see the test). private const long KeyPressExaminedCeiling = 3000; - // A belt, a dense knot of rocks around `at` for the observer to see, and the observer: reach 120, sensor on. - private (Scene scene, Ship observer) TargetScene(int seed, float2 at, float reach = 120f, float sensitivity = 3f, int rocks = 3000) + // A belt and the observer: reach `reach`, with the dimmest sensor (found by doubling) that sees at least `share` + // of the rocks in its reach, so some are lit and some dark whatever the fixture's light. + private (Scene scene, Ship observer) TargetScene(int seed, float2 at, float reach = 120f, float share = .4f, int rocks = 3000) { var kind = Kind("Asteroid", 4f); var rng = new System.Random(seed); var s = BuildSceneAt(float2.zero, 1500f, 0, null, new[] { kind }, Belt(kind, Annulus(rng, rocks, 300f, 700f))); - return (s, Observer(s, at, reach, sensitivity, Falloff())); + var inReach = AllChunks(s).Where(c => length(At(s, c) - at) <= reach).ToList(); + Assert.True(inReach.Count > 20, $"degenerate: {inReach.Count} rocks in reach"); + for (var sensitivity = .5f; sensitivity < 1e9f; sensitivity *= 2f) + { + var observer = Observer(s, at, reach, sensitivity, Falloff()); + if (inReach.Count(observer.ChunkVisible) >= share * inReach.Count) return (s, observer); + s.Zone.Entities.Remove(observer); + } + throw new InvalidOperationException("no sensitivity sees the rocks"); } // Visible ships at every range, in the observer's VisibleEntities. @@ -189,13 +198,18 @@ public sealed partial class MiningCut3Tests if (!reticle) Assert.Equal(order[held ? order.Count / 2 + 1 : 0], observer.Target.Value); return cost; } + var report = new List(); + var grew = false; + var over = false; foreach (var (held, reticle) in new[] { (false, false), (true, false), (false, true) }) { var small = Press(30_000, held, reticle); var large = Press(300_000, held, reticle); - Assert.True(large < 2 * small && small < 2 * large, $"held {held} reticle {reticle}: examined {small} at 30k, {large} at 300k"); - Assert.True(large < KeyPressExaminedCeiling, $"held {held} reticle {reticle}: examined {large}, ceiling {KeyPressExaminedCeiling}"); + report.Add($"held {held} reticle {reticle}: {small} at 30k, {large} at 300k"); + grew |= !(large < 2 * small && small < 2 * large); + over |= large >= KeyPressExaminedCeiling; } + Assert.False(grew || over, $"examined rocks per press, ceiling {KeyPressExaminedCeiling}: {string.Join("; ", report)}"); } // No press ever picks a rock this ship cannot see, or one beyond mining reach; launchers add no reach (Q12 A). @@ -204,7 +218,7 @@ public sealed partial class MiningCut3Tests { var at = float2(500, 0); // A dim sensor: only some rocks in reach are visible. - var (s, observer) = TargetScene(7, at, reach: 150f, sensitivity: .35f); + var (s, observer) = TargetScene(7, at, reach: 150f); var inReach = AllChunks(s).Where(c => length(At(s, c) - at) <= 150f).ToList(); var visible = inReach.Where(observer.ChunkVisible).ToList(); Assert.True(visible.Count > 3 && visible.Count < inReach.Count, $"degenerate: {visible.Count} of {inReach.Count} visible"); From 7a01e608ba58a4b94ffb2d19de807a69d2673c70 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 5 Oct 2026 23:49:55 +0200 Subject: [PATCH 41/45] Mining target queries tests: a launcher-only ship may see a ship but picks no rock; name the press ceiling Co-Authored-By: Claude Opus 5.5 --- .../MiningTargetQueriesTests.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs index bd485a6b2..1db1ca377 100644 --- a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -17,8 +17,8 @@ using float3 = CultMath.float3; // every visible candidate. public sealed partial class MiningCut3Tests { - // One key press at 30k or 300k rocks of one density examines under this many rocks (measured: see the test). - private const long KeyPressExaminedCeiling = 3000; + // One key press at 30k or 300k rocks of one density examines under this many rocks (measured at most 620 across the presses below, at both sizes). + private const long KeyPressExaminedCeiling = 1500; // A belt and the observer: reach `reach`, with the dimmest sensor (found by doubling) that sees at least `share` // of the rocks in its reach, so some are lit and some dark whatever the fixture's light. @@ -256,10 +256,13 @@ public sealed partial class MiningCut3Tests Tick((ship, at)); Assert.Contains(ship.Weapons, w => w.Range == 400f); Assert.True(AllChunks(s).Any(c => length(At(s, c) - at) <= 400f && ship.ChunkVisible(c)), "there are rocks within the launcher's range"); - Assert.False(ship.TargetUnderReticle()); - Assert.False(ship.TargetNext()); - Assert.False(ship.TargetPrevious()); - Assert.True(ship.Target.Value.IsNone); + // It may see the other ship; it must not pick a rock. + foreach (var press in new Func[] { ship.TargetUnderReticle, ship.TargetNext, ship.TargetPrevious }) + { + ship.SetTarget(TargetRef.None); + press(); + Assert.False(ship.Target.Value.Chunk.HasValue, "a launcher-only ship picked a rock"); + } } // Nearest stays enemies only: a nearer rock and a nearer friendly ship are ignored. From c57466a398e8ef1cd08cbc1f2c2a13a1e55bfbf8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 00:06:30 +0200 Subject: [PATCH 42/45] Mining target queries tests: tie-break across belts by field key, and nearest-first pruning from no target Pins two Stryker survivors: Entity.Order's field comparison and DistanceKey.Bound's forward bound. Co-Authored-By: Claude Opus 5.5 --- .../MiningTargetQueriesTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs index 1db1ca377..33221824e 100644 --- a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -195,6 +195,12 @@ public sealed partial class MiningCut3Tests var before = s.Zone.Targets.Examined; Assert.True(reticle ? observer.TargetUnderReticle() : observer.TargetNext()); var cost = s.Zone.Targets.Examined - before; + // From nothing, nearest first stops at the first region no nearer rock can lie in: a fraction of the rocks in reach. + if (!held && !reticle) + { + var inReach = AllChunks(s).Count(c => length(At(s, c) - at) <= 150f); + Assert.True(cost * 4 < inReach, $"Next from nothing examined {cost} of {inReach} rocks in reach"); + } if (!reticle) Assert.Equal(order[held ? order.Count / 2 + 1 : 0], observer.Target.Value); return cost; } @@ -212,6 +218,34 @@ public sealed partial class MiningCut3Tests Assert.False(grew || over, $"examined rocks per press, ceiling {KeyPressExaminedCeiling}: {string.Join("; ", report)}"); } + // Rocks at one distance are ordered by field key, then index, across belts: four rocks on one spot, two in each + // of two belts, are walked in exactly that order by Next and, reversed, by Previous. + [Fact] + public void RocksAtOneDistanceAreOrderedByFieldThenIndex() + { + var kind = Kind("Asteroid", 40f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(150f)), Belt(kind, Rock(150f), Rock(150f))); + var eyeAt = float2(150, -100); + var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 300f }); + Tick((observer, eyeAt)); + var expected = s.Belts.OrderBy(b => b.Value, StringComparer.Ordinal) + .SelectMany(b => new[] { new TargetRef(new ChunkId(b, 0)), new TargetRef(new ChunkId(b, 1)) }).ToList(); + Assert.All(expected, t => Assert.True(observer.ChunkVisible(t.Chunk.Value))); + Assert.Equal(expected, ByDistance(s, observer, 300f)); + for (var i = 0; i < 4; i++) + { + Assert.True(observer.TargetNext()); + Assert.Equal(expected[i], observer.Target.Value); + } + observer.SetTarget(TargetRef.None); + for (var i = 3; i >= 0; i--) + { + Assert.True(observer.TargetPrevious()); + Assert.Equal(expected[i], observer.Target.Value); + } + } + + // No press ever picks a rock this ship cannot see, or one beyond mining reach; launchers add no reach (Q12 A). [Fact] public void DarkAndOutOfReachRocksAreNeverPicked() From 9e10166c7651387dcab9c76b95d6ad736631974b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 01:19:23 +0200 Subject: [PATCH 43/45] Mining target queries tests: press pruning pinned by per-press examined ceilings near the measured costs A second density-equal belt size and reach, held-target presses and the reticle at three headings, each ceiling within about 10% of the measured count; the shared ceiling drops from 1500 to 700. Co-Authored-By: Claude Opus 5.5 --- .../MiningTargetQueriesTests.cs | 235 +++++++++++++++++- 1 file changed, 233 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs index 33221824e..5ad61d205 100644 --- a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -17,8 +17,8 @@ using float3 = CultMath.float3; // every visible candidate. public sealed partial class MiningCut3Tests { - // One key press at 30k or 300k rocks of one density examines under this many rocks (measured at most 620 across the presses below, at both sizes). - private const long KeyPressExaminedCeiling = 1500; + // One key press at 30k or 300k rocks of one density examines under this many rocks (measured at most 635 across the presses below, at both sizes). + private const long KeyPressExaminedCeiling = 700; // A belt and the observer: reach `reach`, with the dimmest sensor (found by doubling) that sees at least `share` // of the rocks in its reach, so some are lit and some dark whatever the fixture's light. @@ -218,6 +218,59 @@ public sealed partial class MiningCut3Tests Assert.False(grew || over, $"examined rocks per press, ceiling {KeyPressExaminedCeiling}: {string.Join("; ", report)}"); } + // The pruning of every press, pinned by what a press examines. Each ceiling sits within about 10% of the count + // measured at this commit (the same fixture, fixed seed, one density of 100 square units per rock); a press that + // loses its angular bound (the reticle), its held-target prune (Next and Previous from a rock at the median + // distance) or its angle wrap examines 15% to 55% more than that, and fails. The belt's size and the reach vary: + // 30k rocks at reach 150, 300k at reach 150, and 300k at reach 400 with some 5000 rocks in reach. + [Fact] + public void PressPruningKeepsEachPressNearItsMeasuredCost() + { + var kind = Kind("Asteroid", 4f); + var failures = new List(); + foreach (var (count, ring, reaches) in new[] { (30_000, 2100f, new[] { 150f }), (300_000, 2800f, new[] { 150f, 400f }) }) + { + var outer = sqrt(2000f * 2000f + count * 100f / PI); + var s = BuildSceneAt(float2.zero, 20000f, 0, null, new[] { kind }, Belt(kind, Annulus(new System.Random(21), count, 2000f, outer))); + var at = ring * float2(cos(1f), sin(1f)); + foreach (var reach in reaches) + { + var observer = Observer(s, at, reach, 3f, Falloff()); + var order = ByDistance(s, observer, reach); + Assert.True(order.Count > 500, $"degenerate: {order.Count} candidates at reach {reach}"); + var median = order[order.Count / 2]; + long Cost(Func press, TargetRef held) + { + observer.SetTarget(held); + var before = s.Zone.Targets.Examined; + Assert.True(press()); + return s.Zone.Targets.Examined - before; + } + void Check(string press, long examined, long ceiling) + { + if (examined > ceiling) failures.Add($"{count} rocks, reach {reach}, {press}: examined {examined}, ceiling {ceiling}"); + } + var (nextNone, nextHeld, previousNone, previousHeld, reticle) = (count, reach) switch + { + (30_000, _) => (100L, 575L, 290L, 590L, 620L), + (_, 150f) => (40L, 560L, 460L, 560L, 680L), + _ => (120L, 4700L, 4600L, 4700L, 5900L) + }; + Check("Next from nothing", Cost(observer.TargetNext, TargetRef.None), nextNone); + Check("Next from the median rock", Cost(observer.TargetNext, median), nextHeld); + Check("Previous from nothing", Cost(observer.TargetPrevious, TargetRef.None), previousNone); + Check("Previous from the median rock", Cost(observer.TargetPrevious, median), previousHeld); + foreach (var heading in new[] { 0f, 2f, 4f }) + { + observer.LookDirection = float3(cos(heading), 0, sin(heading)); + Check($"the reticle at {heading}", Cost(observer.TargetUnderReticle, TargetRef.None), reticle); + } + s.Zone.Entities.Remove(observer); + } + } + Assert.True(failures.Count == 0, string.Join("; ", failures)); + } + // Rocks at one distance are ordered by field key, then index, across belts: four rocks on one spot, two in each // of two belts, are walked in exactly that order by Next and, reversed, by Previous. [Fact] @@ -317,4 +370,182 @@ public sealed partial class MiningCut3Tests Assert.Equal(new TargetRef(friend), observer.Target.Value); Assert.NotEqual(new TargetRef(farEnemy), observer.Target.Value); } + + // A ship in the zone that the observer has not detected: it is in Zone.Entities and nowhere in VisibleEntities. + private static Ship Unseen(Scene s, float2 at) + { + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + s.Zone.Entities.Add(ship); + Hold(ship, at); + return ship; + } + + // The pilot's instruments bound the pick: a ship the observer has not detected is never offered, whichever end of + // the order or look direction it would win. Unseen ships sit nearest, farthest, and dead ahead. + [Fact] + public void ShipsTheObserverHasNotDetectedAreNeverPicked() + { + var at = float2(500, 0); + var (s, observer) = TargetScene(7, at); + Sight(s, observer, at + float2(60, 0)); + Sight(s, observer, at + float2(0, -900)); + var hidden = new TargetRef[] { Unseen(s, at + float2(0, 5)), Unseen(s, at + float2(0, 3000)), Unseen(s, at + float2(0, 40)) }; + var order = ByDistance(s, observer, 120f); + Assert.True(order.Count > 5 && order.Count(t => t.Entity != null) == 2, $"degenerate: {order.Count} candidates"); + Assert.All(hidden, h => Assert.DoesNotContain(h, order)); + + observer.SetTarget(TargetRef.None); + for (var i = 0; i <= order.Count; i++) + { + Assert.True(observer.TargetNext()); + Assert.Equal(order[i % order.Count], observer.Target.Value); + } + observer.SetTarget(TargetRef.None); + for (var i = 0; i <= order.Count; i++) + { + Assert.True(observer.TargetPrevious()); + Assert.Equal(order[order.Count - 1 - i % order.Count], observer.Target.Value); + } + // Looking straight at a hidden ship (heading 0 is dead ahead of the nearest) and in every other direction: a + // visible target, never a hidden one. + for (var heading = 0f; heading < 2 * PI; heading += .7f) + { + observer.LookDirection = float3(sin(heading), 0, cos(heading)); + observer.SetTarget(TargetRef.None); + Assert.True(observer.TargetUnderReticle()); + Assert.DoesNotContain(observer.Target.Value, hidden); + Assert.Contains(observer.Target.Value, order); + } + } + + // Candidates at one distance are ordered entity before chunk, then entities by zone order and chunks by field key + // then index; candidates at one bearing tie the same way for the reticle. A ship stands on the rocks' spot, so + // all five tie exactly in distance and in angle. + [Fact] + public void AShipOnTheRocksSpotOrdersBeforeThem() + { + var kind = Kind("Asteroid", 40f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(150f)), Belt(kind, Rock(150f), Rock(150f))); + var eyeAt = float2(150, -100); + var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 300f }); + Tick((observer, eyeAt)); + var chunks = s.Belts.OrderBy(b => b.Value, StringComparer.Ordinal) + .SelectMany(b => new[] { new TargetRef(new ChunkId(b, 0)), new TargetRef(new ChunkId(b, 1)) }).ToList(); + var spot = At(s, chunks[0].Chunk.Value); + var ship = Sight(s, observer, spot); + var expected = new List { new TargetRef(ship) }; + expected.AddRange(chunks); + Assert.All(chunks, t => Assert.Equal(spot, At(s, t.Chunk.Value))); + Assert.All(chunks, t => Assert.True(observer.ChunkVisible(t.Chunk.Value))); + Assert.Equal(expected, ByDistance(s, observer, 300f)); + + observer.SetTarget(TargetRef.None); + for (var i = 0; i < 5; i++) + { + Assert.True(observer.TargetNext()); + Assert.Equal(expected[i], observer.Target.Value); + } + observer.SetTarget(TargetRef.None); + for (var i = 4; i >= 0; i--) + { + Assert.True(observer.TargetPrevious()); + Assert.Equal(expected[i], observer.Target.Value); + } + for (var i = 0; i < 5; i++) + { + observer.SetTarget(expected[i]); + Assert.True(observer.TargetNext()); + Assert.Equal(expected[(i + 1) % 5], observer.Target.Value); + observer.SetTarget(expected[i]); + Assert.True(observer.TargetPrevious()); + Assert.Equal(expected[(i + 4) % 5], observer.Target.Value); + } + + // The reticle, looking at the spot: the ship; then with the ship gone from sight, the first chunk. + observer.LookDirection = float3(0, 0, 1); + observer.SetTarget(TargetRef.None); + Assert.True(observer.TargetUnderReticle()); + Assert.Equal(expected[0], observer.Target.Value); + observer.VisibleEntities.Remove(ship); + observer.SetTarget(TargetRef.None); + Assert.True(observer.TargetUnderReticle()); + Assert.Equal(expected[1], observer.Target.Value); + } + + // A press with nothing to offer returns false and leaves the target as it was: a ship with no sensor sees no rock + // and has no mining reach, and sees no ship, whether it holds a target or not. + [Fact] + public void APressWithNoCandidateReturnsFalseAndKeepsTheTarget() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(160f, .3f))); + var eyeAt = float2(150, -100); + var blind = SpawnShip(s, eyeAt); + Tick((blind, eyeAt)); + var held = Unseen(s, float2(100, 0)); + foreach (var target in new[] { TargetRef.None, new TargetRef(held) }) + { + blind.SetTarget(target); + Assert.False(blind.TargetUnderReticle()); + Assert.False(blind.TargetNext()); + Assert.False(blind.TargetPrevious()); + Assert.False(blind.TargetNearestEnemy()); + Assert.Equal(target, blind.Target.Value); + } + } + + // Enemies at one distance: the first seen wins, whichever order they were seen in; the observer is never its own + // enemy, though it sits at distance zero. + [Fact] + public void EnemiesAtOneDistanceTieToTheFirstSeenAndNeverTheObserver() + { + var at = float2(500, 0); + var (s, observer) = TargetScene(7, at); + var a = Sight(s, observer, at + float2(0, 40), enemy: true); + var b = Sight(s, observer, at + float2(40, 0), enemy: true); + Assert.Equal(length(a.Position - observer.Position), length(b.Position - observer.Position)); + Assert.True(observer.TargetNearestEnemy()); + Assert.Equal(new TargetRef(a), observer.Target.Value); + + observer.VisibleEnemies.Clear(); + observer.VisibleEnemies.Add(b); + observer.VisibleEnemies.Add(a); + Assert.True(observer.TargetNearestEnemy()); + Assert.Equal(new TargetRef(b), observer.Target.Value); + + observer.VisibleEnemies.Clear(); + observer.VisibleEnemies.Add(observer); + observer.VisibleEnemies.Add(a); + Assert.True(observer.TargetNearestEnemy()); + Assert.Equal(new TargetRef(a), observer.Target.Value); + observer.VisibleEnemies.Remove(a); + observer.SetTarget(TargetRef.None); + Assert.False(observer.TargetNearestEnemy()); + Assert.True(observer.Target.Value.IsNone); + } + + // Ships between the mining reach and 250 keep their distance order for Previous: its key is the distance itself + // at every range, not a function that agrees with it only near and far. + [Fact] + public void ShipsBetweenReachAndTwoFiftyAreOrderedByDistanceBothWays() + { + var at = float2(500, 0); + var (s, observer) = TargetScene(7, at); + foreach (var offset in new[] { float2(135, 0), float2(0, 160), float2(-185, 0), float2(0, -215) }) Sight(s, observer, at + offset); + var order = ByDistance(s, observer, 120f); + Assert.True(order.Count(t => t.Chunk.HasValue) >= 8 && order.Count(t => t.Entity != null) == 4, $"degenerate: {order.Count} candidates"); + Assert.All(order.Take(order.Count - 4), t => Assert.True(t.Chunk.HasValue)); + observer.SetTarget(TargetRef.None); + for (var i = 0; i <= order.Count; i++) + { + Assert.True(observer.TargetPrevious()); + Assert.Equal(order[order.Count - 1 - i % order.Count], observer.Target.Value); + } + observer.SetTarget(TargetRef.None); + for (var i = 0; i <= order.Count; i++) + { + Assert.True(observer.TargetNext()); + Assert.Equal(order[i % order.Count], observer.Target.Value); + } + } } From 561a21f3b209ea8d5b17fa35715658e3337ce208 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 01:20:37 +0200 Subject: [PATCH 44/45] Mining target queries tests: the blind ship needs no tick; it has no equipment to bring online Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs index 5ad61d205..5cfd6d72b 100644 --- a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -481,7 +481,6 @@ public sealed partial class MiningCut3Tests var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(160f, .3f))); var eyeAt = float2(150, -100); var blind = SpawnShip(s, eyeAt); - Tick((blind, eyeAt)); var held = Unseen(s, float2(100, 0)); foreach (var target in new[] { TargetRef.None, new TargetRef(held) }) { From a83aa54e0933a772851e3aa472c5da8996416d46 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 15:41:49 +0200 Subject: [PATCH 45/45] Catalog: author the Asteroid field kind (AetherDb field-kinds apply on master's catalog) GameData/Aetheria.cc 70a7b0a9 -> 78fe2afe (12703072 bytes): one FieldKindData record, Asteroid, cross-section 5, weight 1. Run on Yggdrasil from the merge commit's tree, output pulled back byte-identical. Fixes TheShippedCatalogHasAPickableFieldKind and makes master's strict Exact weapon assertion hold on the rewritten catalog. Builds: both commits build; the previous commit fails one test by design, this one passes 837/837. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 20377e0db..d10c6d732 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:70a7b0a9f5a59bf64a0bff80f5d266083910e88cc141745b5082424e3ae30b79 -size 12701773 +oid sha256:78fe2afebd5caf98f7e01cf3c1cb3c7b11aed7d8971907f645c4929925e68f65 +size 12703072