Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
b1a6fbf880 Merge pull request 'element-ids doc: retired slots held placeholders, why nakovalnya shrank' (#15) from nibu/element-ids-doc-fix into master 2026-11-11 13:44:40 +00:00
Nibu
7f148f08f1 element-ids doc: retired slots held placeholders, and why nakovalnya shrank
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 13:44:38 +00:00
73d36698b0 Merge pull request 'Pin CultLib abd1a92e: element ids on every list element type' (#12) from nibu/cultlib-pin-abd1a92e into master 2026-11-11 13:44:28 +00:00
Nibu
344ef3265c Regenerate the shipped mod ship.cc files off the retired HullData slots; blind element ids only where they are irrelevant
AetherDb ship-authoring regenerate rewrites a package under the current schema and requires it to read back identical. ElementIdUpgradeTests gains content preservation and a shipped-packages-writable check; ShipModCatalogTests compares mod records unblinded where the same packages are composed twice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 13:10:31 +00:00
Nibu
7675296ec4 Pin CultLib cultlib-unity-v3.0.0 (7225835d) and CultMath cultmath-unity-v0.5.1 (f1edfa45) in the manifest and the headless build
The v3.0.0 plugin GameCult.Caching.dll exports CultElementIdAttribute; caching-unity stays at v2.0.0 (editor tooling, unchanged).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 13:10:31 +00:00
Nibu
6cb7b94551 element-ids doc: the old pin refuses v3, shipped mod packages are not rewritable, Unity cannot compile the attribute yet
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 13:10:31 +00:00
Nibu
9512e35d62 EmissionTests: launcher row is 38 slots with the element id
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 13:10:31 +00:00
Nibu
ff54fe7b46 Pin CultLib abd1a92e: element ids on every list element type, tests follow the new wire and write rules
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 13:10:31 +00:00
2c087bf2b0 Merge pull request 'Scenarios batch-4 minors: gun moves within its own hardpoint; station reactors regain quality spread' (#14) from nibu/scenarios-batch4-minors into master 2026-11-11 13:10:14 +00:00
Nibu
156d7c5a19 Determinism ledger: record why the station-reactor spread moves the two-AI digest
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 13:10:13 +00:00
Nibu
4c6dcfea73 AetherDb station-reactor-spread [apply]: station reactor products regain their sources' quality spread
f458206c7 authored the five per-manufacturer station reactor products with bare
ProductRole entries (the .5 / .15 default). The command copies each source product's
Mean and StandardDeviation per role to a role still at the default, and was applied
to the catalog. TwoAiDigest repinned for the catalog move.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:43:37 +00:00
Nibu
b1dae6218e Entity.ItemFits: an equipped item is judged without its own cells, so a gun moves within its hardpoint
The public ItemFits(item, coord) the inventory drag asks counted the dragged equipped
item's own cells as occupied, so a nudge overlapping its current cells was refused.
The private rule now takes the item being moved as an excluded occupant. TryEquip
and TryFindSpace pass none and stay strict, so equipping an equipped item twice is
still refused; the UI keeps TryUnequip then TryEquip (cargo bay emptiness rule).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:43:37 +00:00
5e443569c3 Merge pull request 'Shaders use cultmath_snoise from CultMath.hlsl' (#13) from nibu/shaders-use-cultmath-hlsl into master 2026-11-11 12:10:41 +00:00
Nibu
3c78dbcbd1 Fauna map B29: Aetheria pins cultmath-unity-v0.5.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:10:37 +00:00
Nibu
b6c428d682 Shaders: GalaxyMap, Clouds, SimplexBrush and Lightning.compute call cultmath_snoise
The previous commit deleted the GPU Noise includes; this one carries the call-site
edits it described. Release map notes Background's remaining 2D gradient copy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:08:15 +00:00
Nibu
feb7cf8606 Shaders: GalaxyMap, Clouds, Lightning, SimplexBrush use cultmath_snoise from CultMath.hlsl
CPU snoise (CultMath C#) and the GPU shaders now share one implementation, so
fBm agrees. Deletes the GPU Noise copies nothing includes any more (Classic 2D/3D,
Simplex 3D, SimplexGrad 3D). Background.shader keeps SimplexNoise2D/SimplexNoiseGrad2D:
CultMath has no 2D gradient (cultmath_snoise_grad is float3 -> float4 only).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:53:16 +00:00
a5536cf150 Merge pull request 'Opens of suite-honest: honest station descriptions, Arcs bay, measured crates, drag test at play ThrustScale, HUD readout decision in Shared, gated Console capture' (#11) from nibu/opens-econ into master 2026-11-11 07:16:44 +00:00
Nibu
f856021997 Ammunition run: say the real window (heat death ~260 s, not ten minutes) and drop a dead debug line
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 07:16:40 +00:00
Nibu
782aee4ae5 Merge nibu/opens-tests into nibu/opens-econ 2026-11-11 06:35:35 +00:00
Nibu
465ab50666 Arcs player gets a bay; Arcs and Pursuit crates sized from a measured run
The Arcs fighter had no hold, so its Autocannon and Cell Blasters went silent after one magazine: it now fits the one free 2x2 bay. The estimated crate counts are replaced by measured draws (ten minutes of play or death by heat) plus margin, authored for every ammunition-drawing shooter; ScenarioAmmunitionRunTests drives both scenarios and fails if any shooter is stocked below its measured draw.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 06:34:46 +00:00
Nibu
dcd27cb3b0 Station-kind descriptions name only goods a recipe makes or takes
Uranium, Hydrogen (and Bacteria, Fungi, Animal Products) were named by mining, volatiles, agriculture, habitat-support and power descriptions with no recipe behind them. Dropped the claims (smaller than authoring a power chain); batch StationKindDescriptions restates the five kinds in the catalog; test pins the rule.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 05:59:44 +00:00
Nibu
58c4fa10c1 ShipProductTests and ShipModCatalogTests capture Console through ConsoleProbe.Capture so restores cannot interleave
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 05:59:30 +00:00
Nibu
eee5f213bf Ammo label visibility is Weapon.ShowsAmmoReadout: the HUD's three AmmoType.IsSet() decisions move into Shared and are tested
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 05:59:30 +00:00
Nibu
036f75a0d8 CoastingTests: a ship launched below top speed keeps it at the play ThrustScale 0.1 and at 1
Reads only the ship's velocity after the zone steps; reintroducing drag below top speed fails both cases (50 -> 18.39 in 10 s).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 05:58:27 +00:00
6cc6414425 Merge pull request 'Make the suite honest: digest repin, Crossing/MiningCut3 causes, authored cargo for Arcs and Pursuit, ConstantWeapon HUD, station descriptions' (#10) from nibu/suite-honest into master 2026-11-11 05:55:07 +00:00
Nibu
3458f0d7b3 TwoAiDigestIsPinned: repin linux-x64 to 52fbb10d and name the flight-model commits that moved it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 05:20:46 +00:00
Nibu
663d054b02 Merge nibu/ammo-tail into nibu/suite-honest 2026-11-11 05:15:55 +00:00
Nibu
601ce3cc18 Catalog: mining names Diamond, volatiles names Ammonia (economy author ExtractionKindDescriptions)
Applied through AetherDb: 0 added, 2 changed (station-kind:mining, station-kind:volatiles), 0 removed. economy report fault count unchanged at 126.
GameData/Aetheria.cc sha256 956a1573fa2aa69d7f72861e10dd17d7d3255d114e878da81802869efcfe31e9 (before: 0f81d35d4cec8f57178220f20bca98ffaa6709783349efe3cc28aa64ea1f02b1).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 05:14:40 +00:00
Nibu
ee49f95843 AetherDb economy: Author takes the scratch root; TheTrialCopyIsRemoved audits its own, not the shared temp folder
The test counted aetherdb-economy-* directories in the machine's temp folder, which every parallel test running the command also writes to.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 05:07:47 +00:00
Nibu
85f1a933d4 GetAllChildClasses: the type memo is concurrent
A plain Dictionary written by parallel catalog loads corrupted itself ("Operations that change non-concurrent collections must have exclusive
access") and failed unrelated tests at random, through ValidateStatModifiers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 05:07:47 +00:00
Nibu
b2a2da9d27 MiningCut3Tests: ChunkInfo's pinned bits are per host class; the Linux bits are the base's own
ChunkInfoIsBitIdenticalToItsValueBeforeTheExtraction never passed on Linux: at c86f28e93 (the commit that added it) it already measures 1039274088 for the second observer where the recording says 1039274086, and base 12dfcef5, the code the recording claims to be taken from, measures the same ...088 on Linux (CultMath 0.4.1 and 0.5.0 alike). The sensor's bearing term (acos, curve) is libm arithmetic, which rounds differently on Windows, where the recording was made. It is a host-class recording read as universal, not a regression: no commit moved it.

The pin is now per host class like the two-AI digest (ruling replays-per-host-class): windows-x64 keeps the original recording, linux-x64 is base 12dfcef5 run on Linux, and the current code equals it. A host with no recording fails and prints its bits. RunStartTests.HostClass is now internal, the one host-class name.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 05:06:09 +00:00
Nibu
0e9b10c3ae Crossing: the crosser is launched at the speed it should cross at, since a hull no longer decays below top speed
845f6c3aa (aetheria-gravity:ruling:no-drag-below-top-speed) removed hull drag below a hull's top speed. The crosser was staged at the Djinni's 50 m/s cap on the premise that drag slowed it to ~25 m/s at the bow line; it now holds 50 m/s and crosses at 2.57 s, 50 m/s relative, 1.1 rad/s, outside the tuned encounter (3.6 s, 25 m/s, 0.56 rad/s) that the Brief, the pass window and CrossingHoldsAcrossTheStartWindow all describe. CrossingConditions and CrossingHoldsAcrossTheStartWindow failed on that stale tuning, not on a defect.

The encounter is restaged to reproduce its tuning under the ruled physics: launched at 25 m/s from 90 m out (probed over speed x start; constant-speed passes are dx / v seconds, 45 m ahead). The pass lands at 3.63 s, 44.7 m, 24.9 m/s relative, 0.56 rad/s. The test's launch-speed assertions now pin the scenario's 25 m/s instead of "most of the cap"; the time, distance, relative-speed and angular-rate windows are unchanged. If drag below top speed returns, the crosser decays and the pass leaves the window.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 05:03:57 +00:00
Nibu
bd3563dee3 Scenarios: the Arcs turret and the Pursuit player carry the crates their guns reload from
Arcs' hostile turret fits two 100-round Light Machine Guns and had no hold, so it fell silent after one magazine; it now fits a bay and carries 30
crates of Ammo (about 8 minutes of both guns firing without pause). Pursuit's authored cargo had no Mines, capping the player at one magazine of six;
it now carries 10 crates (sixty-six mines). ScenarioStage.Cargo admits a commodity name as one unit, so authored cargo can state crates.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 04:44:31 +00:00
Nibu
908712b2ee HUD: a weapon says what its ammunition label shows; a beam reads its reserve alone
ConstantWeapon names an AmmoType but has no magazine (a crate per AmmoInterval straight from the cargo), so the label printed its unfilled
magazine ("1/N"). Weapon.AmmoReadout owns the label's text, HasMagazine says whether there is a magazine to show, and SchematicDisplay reads it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 04:36:56 +00:00
Nibu
ac45a5c836 AetherDb: mining and volatiles descriptions name Diamond and Ammonia; the ExtractionKindDescriptions batch restates them
Every commodity a kind extracts (a recipe with no inputs) is named in that kind's description: checked over the shipped catalog, only these
two kinds broke it. The batch applies in the catalog commit that follows; until then the catalog tests for it fail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 04:35:18 +00:00
Nibu
1013e2a2e4 Test: the Economy console-never-written probes keep only the calling thread's writes
The two tests capture Console.Out to prove the commands write to the writer they are given. Console is process-global, so in a full-suite run
other tests' diagnostic prints landed in the capture and failed the author dry-run test. ConsoleProbe keeps only writes made on the thread
running the command, and serialises probes so two cannot restore each other's writer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 04:35:18 +00:00
16bb2e4c8f Merge pull request 'Authored ships carry crates; ballistic guns and mines draw rounds; the munitions chain is sourced' (#9) from nibu/ammo-data into master 2026-11-11 04:26:31 +00:00
Nibu
36a74478b6 Test: a Loadout-built ship is stocked by Materialize's default
Pins the default path the boss and the console fly use: a fed gun leaves the
ship with AmmunitionCrates, a gun that draws nothing leaves none. Removing the
default StockAmmunition call fails the fed case.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 04:26:16 +00:00
Nibu
afa048641f Merge remote-tracking branch 'origin/nibu/ammo-stock-code' into nibu/ammo-data 2026-11-11 03:54:40 +00:00
Nibu
ded74e8b10 Scenario fits are stocked unless the scenario authors their cargo
Materialize takes stockAmmunition (default true: boss, console ship). ScenarioStage
passes false and stocks at the end of staging only the fitted entities whose cargo
the scenario did not author with Cargo, so a scenario that names what a hold
carries gets exactly that. A packed (saved) fit is never stocked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 03:40:33 +00:00
Nibu
c21fad28f5 Ammunition stock has one owner; a named AmmoType makes a one-round magazine
Loadouts.StockAmmunition is the single owner of crate stocking. The generator
calls it for generated ships and turrets, and Loadouts.Materialize calls it at the
end, which covers every Loadout-built entity: scenario stage.Fit/Preset, the boss
in ZoneGenerator, and the console-built ship. Paths left alone: Unpack of a pack
or save (an entity's cargo is its own and a saved game's cargo is never touched on
load), scenario stage.Cargo (adds on top of the stock), the LoadoutCommit
validation build (discarded). The new-run player ship is stage.Generated and so
already stocked by the generator. A hull with no bay gets no crates and a log line.

InstantWeapon: a weapon that names an AmmoType is magazine-fed with an effective
magazine of max(1, MagazineSize): every round leaves the magazine and the reload
takes a crate. A weapon with no AmmoType and a magazine of 0 or 1 stays free.
HUD shows rounds/reserve for every AmmoType weapon. ConstantWeapon keeps its own
per-interval crate draw.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 03:36:58 +00:00
Nibu
8948b99e59 GameData/Aetheria.cc: Munitions + MunitionsFeed apply (chain sources, Mines, gun ammo)
sha256 0f81d35d4cec8f57178220f20bca98ffaa6709783349efe3cc28aa64ea1f02b1
economy author Munitions MunitionsFeed apply: 14 extraction/processing recipes (Rock, New Metals, Gold, Potassium, Diamond, Water,
Methane, Ammonia, Algae, Oxygen, Steel, Cellulose Pulp, Circuit Board, Actuator), Mines commodity and recipe, Zhestokost depot
stand-ins for mining, volatiles and agriculture. AmmoType=Ammo on Slug Rifle, Long Slug Rifle, Light Machine Gun; AmmoType=Mines on the
Mine Launcher. Stat change: Light Machine Gun MagazineSize 0 -> 100, ReloadTime 1 -> 3 s.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 03:32:54 +00:00
Nibu
54632d4eb9 Tests: pin Mines in the commodity count; the stand-in fixture uses salvage, since Zhestokost now stands in for mining
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 03:32:45 +00:00
Nibu
1f93e22239 AetherDb: MunitionsFeed gives the munitions chain real sources; Mines; every round-firing gun names its round
MunitionsFeed (run after Munitions) authors the chain's leaf recipes: extraction at mining (Rock, New Metals, Gold, Potassium, Diamond)
and volatiles (Water, Methane, Ammonia), Algae at agriculture, Oxygen/Steel/Cellulose Pulp at processing, Circuit Board/Actuator at
fabrication. Munitions adds the Mines commodity and recipe, AmmoType=Ammo on Slug Rifle, Long Slug Rifle and Light Machine Gun, AmmoType=Mines
on the Mine Launcher, a real Light Machine Gun magazine (0 -> 100 rounds, reload 3 s), and Zhestokost depot stand-ins for mining,
volatiles and agriculture so the new recipes' station kinds are placed. Tests read the shipped catalog (pending the blob commit).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 03:19:46 +00:00
1a8ced9ab6 Merge pull request 'Launchers draw real missile rounds; reloads cost a crate' (#8) from nibu/launcher-ammo into master 2026-11-11 03:12:50 +00:00
Nibu
35fa50f256 Generated turrets are stocked with the crates their weapons reload from
GenerateTurretLoadout calls the same StockAmmunition as ships; an ammo-fed turret otherwise went quiet after one magazine.
Test: generated turrets hold exactly the crates for each ammunition type they draw.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 03:12:36 +00:00
Nibu
5ec368c11e GameData/Aetheria.cc: Munitions re-apply (Missiles shape and stack, recipe lore)
economy author Munitions apply; rerun reports 0 changed. sha256 b737d1c74f48c3c0a2e911efd2824345c8491cd8a0c0d3754d78b85d3ea29c40

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 02:55:39 +00:00
Nibu
386f29bf0d Generated ships carry the crates their weapons reload from; Missiles gets a cargo shape; comment and lore fixes
LoadoutGenerator.GenerateShipLoadout, the one owner of NPC and generated-scenario fitting, now stocks AmmunitionCrates (3) of every
distinct AmmoType the ship's weapons draw, after the consumable roll and without touching the random stream; it logs and goes on if
no hold takes them. Missiles is authored with Ammo's 1x1 shape and a stack of 5 (a shapeless commodity cannot be stored at all).
Tests: generated ships hold exactly the crates they draw and none of an ammunition they do not; a magazine weapon with no AmmoType
still reloads free. weapon-ammo moves so RangeFalloff keeps its doc comment; the Munitions comment and lore now match the recipe
(warhead and motor, magazines 2 to 288).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 02:55:39 +00:00
Nibu
fdbf0b6851 GameData/Aetheria.cc: Munitions apply (Missiles commodity and recipe, AmmoType on five launchers)
economy author Munitions apply on the latest origin/master catalog; rerun reports 0 added, 0 changed. sha256 d1c96c6ed8530a4e17246604580eca3a45666b3846d56d557f8869ce7c2cc2b3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 01:58:59 +00:00
Nibu
8f77000844 Launchers draw real rounds: Missiles commodity and recipe, a reload that pays its crate, a reserve readout
Rulings ballistics-real-ammo and ballistics-cycle-power, follow-up launcher-ammunition. The Munitions batch adds the Missiles
commodity (a high-explosive warhead and a rocket motor at the munitions works; guidance waits on the electronics chain) and sets
AmmoType=Missiles on the five launcher designs; reactor energy per shot stays as authored.

InstantWeapon: the reload used to refill the magazine on its timer and never reach the cargo branch in UseAmmo, so no ammunition-fed
weapon ever paid a round. The refill now takes one unit of AmmoType from cargo (TakeReloadRound) and, with none, the magazine stays
empty; UseAmmo only starts a reload when a unit is there. Weapon.Reserve exposes the cargo count and the HUD shows rounds/reserve for
magazine weapons.

Tests: shipped launchers all name Missiles and Missiles has a recipe; a fed weapon fires its magazine and no more without crates,
reloads once per crate; Reserve equals the cargo count and falls per reload. The commodity count pin goes 22 to 23.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 01:58:59 +00:00
Nibu
9135360beb AetherDb: weapon-ammo lists every weapon's energy, magazine, reload and ammunition type
Read-only audit command, so ammunition rulings are checked against the catalog and not against names.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 01:58:59 +00:00
d5890fd259 Merge pull request 'docs: record the Continue hull-layout gate in the release map' (#7) from nibu/changelog-continue-layout into master 2026-11-10 22:11:59 +00:00
Nibu
b3f3c9a0c8 docs: record the Continue hull-layout gate in the release map
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 22:11:56 +00:00
16e34049e0 Merge pull request 'Continue refuses a run whose equipment no longer fits its hull's edited layout' (#6) from nibu/continue-hull-layout into master 2026-11-10 22:11:46 +00:00
Nibu
c6a3bee879 Continue refuses a run whose equipment no longer fits its hull's edited layout
A mod package edited in place keeps its hull key, so RequireDesigns passed while
saved gear could sit outside the new hardpoints or interior cells and be dropped
on load. RequireLayouts applies the equip rules (HardpointData.TakesAt, interior
cells for tools) to each saved entity and names the changed hulls.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 22:10:49 +00:00
db6bda1655 docs: ship concept batch brief (#5) 2026-11-10 20:54:42 +00:00
c33feebde2 Merge pull request 'Moddable ships: starter command, top-speed fix, Wren stand-in package, thrust-scale test pins' (#1) from nibu/moddable-ships-wiring into master 2026-11-10 18:02:25 +00:00
Nibu
07cfa26d6c Merge master into moddable-ships-wiring
#3 landed the same --top-speed fix this branch already carried; both
conflicting files keep this branch's version (same behavior, stronger test).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 17:51:00 +00:00
9f8452818d Merge pull request 'Retire the Wwise catalog fields (SoundBank, AudioStats, faction music)' (#4) from nibu/remove-wwise-catalog-fields into master 2026-11-10 17:50:48 +00:00
25594f480f Merge pull request 'Save standings by faction, not by list position' (#2) from nibu/saved-relationships-order into master 2026-11-10 17:45:16 +00:00
c965962281 Merge pull request 'ship-authoring stats --top-speed adds the VelocityLimit a bare hull lacks' (#3) from nibu/ship-authoring-top-speed-adds into master 2026-11-10 17:39:09 +00:00
Nibu
d97e2714cd Retire the Wwise catalog fields: SoundBank, AudioStats (AudioStat) and Faction music
Nothing reads or writes them (audio is native Unity plus AquaSynth). Keys 21, 22 and 13 to 15 stay
reserved as never-reuse comments, as with the earlier retirements. The shipped catalog still holds old
values; the reader skips unknown keys.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 05:50:28 +00:00
Nibu
b571555e2a ship-authoring stats --top-speed adds the VelocityLimit a bare hull lacks
create without --like makes a hull with no behaviors, Validate refuses a ship hull without a VelocityLimit, and stats --top-speed refused any hull without exactly one: no command could give such a hull a top speed. The first --top-speed now adds the one limit; two or more are still refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 15:00:22 +00:00
Nibu
65a6297aee Save standings by faction, not by list position
RunSave.Capture wrote Factions in HomeZones key order and Relationships in
galaxy.Factions order; Galaxy's save constructor paired them by index, so a
galaxy whose two orders differed resumed with standings on the wrong factions.
SavedGame.Standings (Key 16) now carries each standing with its faction ref.
A save without it still reads Relationships by index, the only record it holds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:55:50 +00:00
Nibu
2b0fb3c22d fly <hull> reaches unoffered hulls through their Loadout; authoring doc no longer says console-only
A hull no maker offers is drawn by no generator, so fly now falls back to a catalog
Loadout of the named ship hull (Loadouts.Materialize with the game's chooser). The
authoring doc claimed a product-less package is console-only; it is unreachable
unless a Loadout fits it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 00:15:56 +00:00
Nibu
a0c760f5cf Wren: product renamed Wren Courier; 'Wren' collided with a shipped product and compose excluded the package
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 11:39:59 +00:00
Nibu
8c5a904cb7 Tests that pin a thruster's authored push read it at ThrustScale 1
e0c1daa4e made thrust a tenth of authored by default (GameplaySettings.ThrustScale = .1), which broke twelve tests that pin what a thruster, a hull or a brain does (Brownout, ConditionRatio, Longinus top speed, the evasion, approach and envelope tests, SimRateEquivalence). Their fixtures now state ThrustScale 1: they pin mechanics, and a retune of the global knob does not move them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 11:39:11 +00:00
Nibu
a892b5463b Wren: the starter's first package, a stand-in light courier in GameData/Mods
Written by ship-authoring starter --like Djinni, priced, with an unoffered Aeronautics Unlimited product (so the census counts it sold and no faction fields a box). Tests: it composes into the catalog, its mounts take catalog gear, and the fitted ship moves under thrust. Drop the directory to remove it; replace the model through Blender and Package to make it a real hull.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 11:39:11 +00:00
Nibu
5b4665dfbb ship-authoring starter: a complete flyable package for authors with no Blender
Writes <mods>/<id>/ship.cc and ship.glb: a shipped hull's stats, a 4x6 grid with two drives, a radiator, two energy guns and the internal modules, and a boxed model carrying every anchor. The package is read back through ShipModCatalog.ReadPackage, so the GLB's node ids and mesh roles are judged by the reader the game boots with. Refuses to overwrite a package.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 11:39:11 +00:00
Nibu
17c19521f5 ship-authoring stats --top-speed adds the VelocityLimit a hull made without --like lacks
validate refuses a ship hull with no top speed, and stats --top-speed refused a hull with none, so a package made without --like could not be made valid by any command (follow-up package-hull-top-speed). The flag now adds exactly one VelocityLimit and edits it on a later run; two stay refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 11:39:11 +00:00
soul
a4be037a42 LFS lives on the Eureka forge: .lfsconfig points every clone at it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:12:31 +01:00
soul
152b9251a9 Research: release pipeline prior art (Unity CI, Personal licensing, Forgejo runners and releases, versioning tools)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:00:49 +01:00
soul
01bed8d447 AetheriaBuild: -aetheriaTarget win64|linux64, one entry point for both players
The target names the Unity player, its executable and the runtime AetherDb is published for.
Without the argument it builds win64 as before. Built 0.2.0 for both targets on Starfire
(Linux cross-built with Mono); both stop at the aetherdb step, because Aetheria compiles
CultMath from two CultLib pins and dotnet publish refuses the duplicated shader files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:51:42 +01:00
soul
3f3c59e27d Dithered Particles: a Deferred pass, so particles reach the depth texture the nebula fogs by
The shader dithered and wrote depth, but its one pass had no LightMode, so under the deferred
camera it drew in the forward pass, after the depth texture is taken from the G-buffer. The
nebula march read no depth there and fogged straight over every dithered particle (missile
trails, explosions, lasers, guns). Same cause and cure as Stardust (2e9d97661): black,
unreflective and emissive in the G-buffer. The colour pass is now ForwardBase only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:18:37 +01:00
soul
36d9d94e81 fly: unbind the ship you leave, so its death is no longer yours
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:29:13 +01:00
soul
26daac398a Console command "fly <hull>": a fitted ship beside the current one, and you in it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:20:44 +01:00
soul
f3927e24db Pin a docked ship to its parent before the inactive return; no flight while docked
Docking deactivates the ship, and Entity.Update returned before the parent pin, so a docked ship
kept integrating its own velocity and gravity (Ship.Update) for as long as it sat in the bay, and
undocked wherever it had fallen to at whatever speed it had reached. Drag used to bleed that
off; with no drag below top speed it flung the ship into the void.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:18:20 +01:00
soul
7a3c0c8fb8 Aim at infinity along the view's pitch; delete the terrain intersection
The horizon fault was the level XZ projection, not the distance (operator, 2026-10-29). The untargeted look point is 10000 along the aim again, so the camera no longer jumps where the view ray crosses a terrain discontinuity; group aim points and crosshairs lift each planar firing bearing by the aim's pitch. Reverts the surface march of aba02182b and the field-height crosshair of a9bbea509.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:45:37 +01:00
soul
ffdba39ac2 ThrustScale scales a thruster's push only; turning keeps authored strength
The 10x nerf reached yaw through Torque * thrust and made turning painfully slow (operator, 2026-10-29). Column and Execute apply the scale to the push; Thrust and NominalThrust read the authored stat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:39:18 +01:00
soul
845f6c3aad No drag below top speed: ships coast and orbits no longer decay; drag acts only on the excess over the hull's top speed
Carries out aetheria-gravity:ruling:no-drag-below-top-speed (2026-10-14), which the code never implemented (operator, 2026-10-29). The VelocityLimit clamp still caps speed; the soft cap is the ruling's remaining half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:33:26 +01:00
soul
e0c1daa4ec Thrust is a tenth of authored, the ratio gravity fell by; ThrustScale and DragScale are global flight tuning in GameplaySettings
Most ships cannot climb straight out of a well and use orbits; an overspecced hull like the Longinus still can (operator direction, 2026-10-29). DragScale defaults to 1, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:30:48 +01:00
soul
c6f7e0c8a4 Player turn input is banded by thruster: each band of equal yaw-to-translation ratio fills an equal share of the turn axis
The allocator already serves a turn by the best-ratio thrusters first; the axis was a fraction of total yaw, so a hull whose best-ratio pair holds little of it (the Longinus's front pair) saturated them in the first tenth of input and lurched on its drives. Ship.BandedTurn maps the player's axis so each band joins over an equal share of travel; full input is still full yaw. AI and the allocator are unchanged (operator request, 2026-10-29).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:22:04 +01:00
soul
633ae3353a Keyboard turning ramps down on release at the same rate it ramps up, so tapping at a rate holds a gentle turn; reversal still cuts to zero
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:09:29 +01:00
soul
5e2a9efc8a Keyboard turning ramps to full over KeyboardTurnRampTime (0.4 s) so a tap feathers the turn; analog turn passes straight through
Release or reversal starts the ramp again from zero (operator request, 2026-10-29: the Longinus lurches).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:07:30 +01:00
soul
562ba5c8eb Default weapon-group binding runs once per ship, on its first use; after that the action bar is the player's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:01:29 +01:00
soul
c4911a4c96 Action bar binds weapon groups by default: mouse left/right/middle fire groups 1-3, number key N fires group N
Only slots the input settings define, only groups with weapons, and never over gear or consumables the player placed. Runs on binding to a ship and after a save's bindings restore. Replaces a lazy Zip that never ran (operator request, 2026-10-29).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:57:42 +01:00
soul
2e9d97661c Stardust draws into the G-buffer so the nebula fog attenuates it by depth
At BeforeImageEffectsOpaque the dust was drawn after the depth texture the nebula march reads, so the fog treated dust pixels as far background and wiped them out (operator report, 2026-10-29). A Deferred pass writes black, unreflective, emissive G-buffer texels and depth at AfterGBuffer. FlowFieldSceneVerify still expects the old event and needs updating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:47:20 +01:00
soul
aba02182b7 Untargeted aim lands where the view ray meets the field, not 10000 out at the horizon; weapon-group aim points ride the field
With no target the look point sat 10000 along the camera direction, so crosshairs and shot visuals aimed at the horizon (operator report, 2026-10-29).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:44:56 +01:00
soul
a9bbea5091 Weapon crosshairs ride the gravity field: the aim point takes its own field height, not the ship's level plane
On a slope the level projection floated over downhill aim and sank into uphill aim (operator report, 2026-10-29).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:33:28 +01:00
soul
293e3a33fd Research: hyperbolic in-between space (H2 rendering, motion and precision, curved Kepler, HyperRogue scaffolding, getting lost, Aetheria's Euclidean gravity body)
Serves the operator's musing on hyperbolic inter-zone travel, 2026-10-28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:22:25 +01:00
soul
257a34939e Delete Vault/: a stale 2025 copy of the lore; the live lore is GameCult/AetheriaLore
The copy had drifted far behind (no Megiddo or Wavecrafters pages) and an agent
edited it as if it were canon on 2026-10-28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:36:54 +01:00
soul
6e5be44613 Revert lore merge 28c076b1e: it edited the stale Vault copy in this repo; the live lore is GameCult/AetheriaLore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:35:25 +01:00
soul
28c076b1e6 Merge lore: cognition-anchored aether, ARI spectrum, Megiddo doctrine (operator direction 2026-10-28)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:29:16 +01:00
soul
c4a2ce36be Lore: the aether is anchored by its residents' cognition; ARIs are a spectrum; Megiddo's doctrine
Aether gains what the field answers to (consensus, load-bearing belief, a self not
compute, unopposed small effects, density, attention). Necrotech gains the ARI
spectrum from factory stamps to bound espers. Megiddo gets a page: right about the
mechanism, wrong that there is a way back. Pseudospace ties flatness to consensus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:29:08 +01:00
soul
8fa1fc21b2 Merge one exit per region: after boss placement each boss zone keeps only its exit-path link into the far side; distances recomputed before faction homes; saves load the borders they stored (aetheria-living-world). Soul held s1 and s2; Unity batch compile clean on Starfire at 2822a583e.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:33:40 +01:00
soul
2822a583ea Pin CrossingLinks far-zone ordering and a boss zone at the start of the exit path in hand-built graphs (cut one-exit, finding pure-helper-survivors)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:24:02 +01:00
soul
97aa371872 Provinces.ExtraExits throws when a boss zone has no exit-path link to keep instead of silently keeping every link (cut one-exit, finding silent-keep-all-fallback)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:23:47 +01:00
soul
9fd0fe96e7 One-exit tests require every sector seed to build; a generator exception fails instead of skipping the seed (cut one-exit, finding trybuild-masks-generator-crash)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:23:32 +01:00
soul
0182570200 DistancesAreOfTheFinalGraph builds its own galaxies so the pruning's distance recompute is under its coverage (cut one-exit)
Stryker's per-test coverage credited the shared galaxy cache's first builder
alone with Galaxy.OneExitPerRegion's CalculateDistanceMatrix call, so deleting
the call survived. Fixture fix, no rule change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:50:45 +01:00
soul
84acc9e563 Galaxy.OneExitPerRegion: sector generation prunes each boss zone's extra exits, then recomputes distances (cut one-exit)
Ruling one-exit-per-region: one exit to defend per region. Called once in
PlaceFactionsMain after the boss loop and before home placement; BuildProvinces
and the pruning share ZoneGraph. Saves and the prelude never prune. Tests pin the
rule on sector seeds 1-60, the pure helpers on hand-built graphs, and save/load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:28:09 +01:00
soul
5df9960343 Provinces.ExtraExits: the crossing links to prune so a boss zone keeps the one on the exit path (cut one-exit)
Pure. Reads CrossingLinks; nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:28:08 +01:00
soul
4f93c0de68 Provinces.CrossingLinks: one definition of a boss zone's crossing link, Regions reads it (cut one-exit)
Moves Regions' inline exit-side loop into a public CrossingLinks so the
pruning that follows reads the same definition. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:25:19 +01:00
soul
bef6a90f58 Merge ore geography: provinces inside boss-bounded regions, region decks deal province types, field kinds and yield read the province (aetheria-living-world). Unity batch compile clean on Starfire at the merge; Soul held.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:11:49 +01:00
soul
674baa95ae Merge eureka/aetheria-living-world-economy-model: living-world Body map, economy section
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:16:36 +01:00
soul
3e98238cf0 Living-world map: the economy's catalog records, owners and checks after recipe role inputs
Owner, inputs, outputs, derived state, forbidden writers, shared paths and
verification layer for RecipeData, Recipes.RoleInputsOf/Fillers and
EconomyChecks, read at 30030af6e. Notes the two stores of a recipe's role
slots behind one reader, filler choice at production time as unowned, and
stale docs: the target prose is not on master and target r2's L-recipe
wording predates intermediate role inputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:16:07 +01:00
soul
30030af6e4 Merge eureka/aetheria-living-world-recipe-role-inputs: recipes take role inputs any filler satisfies; offered designs demand their roles
Soul s1 (aetheria-living-world:verdict:cut-recipe-role-inputs.s1) holds all five promises; its two Medium findings are pre-existing test races, deferred to their follow-ups. Suite 2867/0 at e93f53166; shipped report 146 faults (consumer demand: Ballistic Powder, Nitric Acid, Nitrocellulose, Potassium Nitrate). Unity compile not yet run; it rides with the ore-geography compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:11:20 +01:00
soul
e93f531669 Economy tests: a null list beside RoleInputs does not drop them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:44:30 +01:00
soul
1efd8343da Economy tests: pin the survivors of the diff (null RoleInputs keeps the rest, key-ordered recipe-less designs, unoffered recipe designs name roles, mixed takes line)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:30:14 +01:00
soul
ccd9792d2f Economy tests: role inputs persist, are read by every rule as any filler, offered recipe-less designs demand their roles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:56:21 +01:00
soul
3fb5e0e0e6 Economy: RecipeData.RoleInputs, Recipes.RoleInputsOf as the one reader of role slots, checks read a role input as any filler and offered recipe-less designs as demanding their roles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:56:01 +01:00
soul
1ed3579025 Merge eureka/aetheria-living-world-economy-checks: consumer demand from shared uses, a weapon's draw is a sink, upkeep walked, economy command tail
Soul s1 (aetheria-living-world:verdict:cut-economy-checks.s1) holds; s2 holds the fixes except one Low (dry-run test cannot see apply), filed as a follow-up. Suite 2848/0 at dc4838bd9; shipped report 144 faults, consumer demand nitric-acid only. Unity compile not run (no Unity-side change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:29:05 +01:00
soul
dc4838bd9e Economy checks: upkeep walk test gets a control case and a baseline that is actually shared
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:07:40 +01:00
soul
3c3ecd40da AetherDb economy: pin Run's report and author dry-run branches on the shipped catalog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:06:17 +01:00
soul
da067a34a2 Economy checks: the consumer walk follows upkeep edges; pin upkeep into a consumer good and a role filler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:06:15 +01:00
soul
6ba77aafe7 OreGeographyTests: restore the file the previous commit replaced with a stale copy; the three sector-galaxy tests and their helpers on top of the guard test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:00:42 +01:00
soul
154b71160d OreGeographyTests: a multi-region sector galaxy's provinces survive save and continue and re-derive from an older save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:59:24 +01:00
soul
4b7e5476c0 FieldKinds.Assign: a kind needs a positive kind weight and a positive province factor; two negatives no longer draw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:59:15 +01:00
soul
8e6fd43d23 Economy checks: ConsumerDemand tests recipes, not keys; pin upkeep reached through a chain
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:32:13 +01:00
soul
8d23a378d6 Economy checks: gearRoles reads roles of non-commodity recipes; pin use order and batch list
Mutation testing found IsRoleRecipe redundant in the gear-role set (RolesOf is empty for a
design without roles) and the message's use order, join and the unknown-batch count unpinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:19:00 +01:00
soul
1db250fb10 OreGeographyTests: the duplicate-listing fixture's yield-only kind is never drawn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:55:07 +01:00
soul
8774bd8a97 OreGeographyTests: pin seed order, tie-breaks, duplicate listings, deal key order, kept and saved first-load kind
Kills the survivors of the first Stryker run on FieldKinds and Provinces that were not equivalent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:50:01 +01:00
soul
c81a83ef2c OreGeographyTests: regions, partition, deal, yields, belts, save
Not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 06:43:00 +01:00
soul
4848c5332f Ore geography: yields, provinces cut inside boss-zone regions, types dealt per region
FieldYield and FieldKindData.Yields (key 3), ProvinceData, FieldKinds.Pick/Assign/Ensure/Yield
with a province type, Provinces.Regions/Partition/Deal, Galaxy.Provinces built once by the
generation and save constructors, SavedGame key 15 and SavedZone key 6, ProvinceZones setting.
Not yet built or run; tests follow in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 06:42:58 +01:00
soul
d92d77483f Retire BodyData.Resources key 4 and the commented ResourceValue
No reader or writer remains; FieldKinds.Yield (this cut) is the owner of a belt's yield.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 06:34:12 +01:00
soul
b375cfc8ec Research: ore geology and regional resources prior art (Dwarf Fortress, EVE, Elite/X4/Starsector, real asteroid taxonomy), for ruling ores-are-regional
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 05:11:18 +01:00
soul
26bea2cb14 AetherDb economy: unknown batch names are not echoed; held-cycle rise is relative
Run takes the output writer like Report and Author do. The unknown-batch refusal says how
many names matched no batch and lists the compiled batches. Introduced compares a held
cycle's radius relatively with RiseNoise = 1e-12, replacing the bare absolute 1e-9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:38:08 +01:00
soul
7df0a0943f Economy checks: consumer demand derived from shared uses; a weapon's draw is a sink
ConsumerDemand drops the two-or-three-step window (rulings consumer-demand-shared-only,
consumer-demand-skips-final-assemblies): a processed good owes a consumer use when it is
shared, derived from the recipe graph each check. A weapon's draw of a round is one use
(ruling draw-is-one-use). SourceSink counts a drawn commodity as sunk. Drawn is the one
reader of offered designs' WeaponData.AmmoType. Tests are unrun at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:38:05 +01:00
soul
c6a49e5568 Merge eureka/aetheria-living-world-economy-content-b1: economy batch 1 (Zhestokost munitions) applied; one owner of drawable station hulls
Operator approved the batch (aetheria-living-world:ruling:batch-1-munitions-approved). Soul s2 holds; suite 2836/0 at the merged head; catalog LFS oid 68b37cb5 on GitHub and the Yggdrasil cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:09:35 +02:00
soul
dd1031ce6a StationKindTests: a maker-less or Price 0 station hull is unreachable by the draw; the stand-in check is exercised on Zhestokost
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:16:48 +02:00
soul
b481363b9b StationKinds.HullKindFor takes a stand-in's kind only when some maker offers it
The any-maker fallback in Choose could seat a faction on a kind it has a dead stand-in for; the hull draw then looked for the dead stand-in's kind and returned
null. A stand-in no maker offers is no longer followed, so a kind Choose places is a kind the hull draw can fill. FieldableKindsOf reads the same two helpers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:04:24 +02:00
soul
e3824360aa StationKindTests: stand-ins judged from the catalog, zones test against an oracle, the partial and fallback-spread cases pinned
Deletes the name-keyed AwaitingFirstModel exemption: AssertStandInsLive derives liveness from the sold hulls (live exactly when some maker sells the
target kind; otherwise the kind leaves the pool and the any-maker fallback is observed in the log). GeneratedZonesPlaceStationsByKind checks against
a records-level oracle and asserts the fallback case. New: a kind is kept exactly when a hull of it can be drawn (Price 0, no maker), a faction with
one fieldable and one unfieldable kind places only the first, the fallback spreads over every offered kind.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:02:48 +02:00
soul
e403905e12 LoadoutGenerator.IsDrawable is the one definition of 'a maker offers this design'; StationKinds keeps a kind by it
AvailableProducts (Price > 0, a set Manufacturer, offered) and StationKinds.OfferedKinds (offered only) disagreed, so Choose could keep a kind
whose hull draw returns null. Both now ask IsDrawable; OfferedKinds and OfferedStationHulls read the same drawable station products.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:02:47 +02:00
soul
78b176a0ff Merge remote-tracking branch 'origin/master' into eureka/aetheria-living-world-economy-content-b1 2026-10-23 17:58:47 +02:00
soul
d5c0063b7e CommodityRecordsTests: the compound commodity count follows the batch (51 to 63)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 17:29:50 +02:00
soul
b1b5031e4b Economy batch 1 (munitions): apply Munitions to the shipped catalog; re-pin what the catalog stales
economy author Munitions apply: 49 added, 4 changed. The pins that name the shipped catalog's contents follow it: the kind list gains
munitions-works, the simple commodity count, the retirement check counts only its own stand-in, the stand-in liveness check exempts
a faction awaiting its first station model (Zhestokost), and generated stations draw from the fieldable kinds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 17:23:26 +02:00
soul
fce8c66284 StationKinds: FieldableKindsOf is the pool Choose draws from
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 17:23:24 +02:00
soul
fe7ea72c9b StationKinds.Choose: a kind the faction places but no maker can field leaves its pool and falls back to any maker
A stand-in whose hull kind no offered station hull carries (Zhestokost's depot, whose model has not landed) used to leave the
tutorial entrance with no station. The existing no-kinds fallback now covers it: one rule, no faction special case.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 17:20:04 +02:00
soul
37677c4893 Research: recipe-graph structure in factory games (shared vs single-use intermediates)
Prior art for aetheria-living-world❓consumer-demand-gear-specific-intermediates: Factorio 1.1 and Space Age, Dyson Sphere Program, Satisfactory, No Man's Sky.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:05:31 +02:00
soul
722e618cac Merge eureka/aetheria-arena-sim-rate h7: Combat releases every held weapon it no longer commands, beams and locks included
Soul pass 5 (aetheria-arena:verdict:cut-sim-rate.s5) holds all five promises. Unity compile and feel unproven.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:00:00 +02:00
soul
9b56bf0136 Digest pin comment names both causes of the h6 repin
The candidate-predicate change and the release sweep each move the digest alone (b559789a, d9493bbe); releasing beams and locks
moves nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 15:31:43 +02:00
soul
780e68be71 Combat lets go of every held weapon it no longer commands: beams and locks, not only AutoWeapons; its end releases a same-step pull
One sweep over what is held (Firing, or pulled this step): released when uncommanded, and on exit regardless of
command. Tests through Minion and Zone.Update for a beam, direct for a lock, the not-the-pick group, the cooling
candidate clause by gun kind, the same-step pull on exit, and PilotCommand.Commands.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 15:25:11 +02:00
soul
8ca5425fcc Merge eureka/aetheria-arena-sim-rate h6: the AI releases a held automatic gun it no longer commands
Soul pass 4 (aetheria-arena:verdict:cut-sim-rate.s4) holds all three promises; suite 2822/2822 on Yggdrasil. Unity compile and feel unproven.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:21:21 +02:00
soul
163bdf9b79 Test: Combat releases a held AutoWeapon whose target moves out of reach
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:46:37 +02:00
soul
3715c9edf0 Repin the two-AI digest once: Combat releases a held AutoWeapon it no longer commands
Measured 85e85c46 on linux-x64 (55ecd858 before), on two full runs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:39:43 +02:00
soul
d4bec37c6a Combat's release sweep leaves a weapon it already commanded alone: PilotCommand.Commands
One command per weapon per step: the sweep released a gun the group loop had already released.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:34:18 +02:00
soul
d2c293b799 Test: the semi-auto floor is the 0.1 s constant, not a multiple of the step
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:27:54 +02:00
soul
65b0e00cd6 Test: a behaviour created from a consumable AutoWeaponData honours Automatic
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:27:53 +02:00
soul
848ff83cd7 Combat releases a held AutoWeapon it no longer pulls: no target, no track, other group, exit
The weapon holds a down trigger; Combat's early returns issued no release and had no exit hook, so a held
Slug Rifle, Autocannon or Flak Gun fired on at nothing. Combat is the one owner of the AI's trigger level:
every held AutoWeapon it does not pull this step is released. A held gun stays a group candidate between
rounds so cooling does not drop it. Tests run through Minion/Combat.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:27:52 +02:00
soul
5177154c17 Economy batch 1: Zhestokost munitions chain (unapplied, for operator review)
Dry run on the composed catalog: zero shape faults introduced; 49 records
added, 4 changed. Review page published separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:45:20 +02:00
soul
bd7cf4b5d4 Merge eureka/aetheria-living-world-economy-authoring-command: AetherDb economy author and report; the batch gate refuses every shape fault a batch introduces
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 07:23:04 +02:00
soul
dd7f40b559 Merge origin/master into economy authoring command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 07:16:44 +02:00
soul
6befccce74 Economy command: L-productive is one fault per unproductive component; a held-open catalog is a named refusal at the first read too
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 07:15:59 +02:00
soul
ae3e4a7c58 Economy command tests: a held cycle whose radius falls still applies
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:48:22 +02:00
soul
fe7c5226cc Economy command tests: a cycle that moves to a lower radius is still a changed membership
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:43:56 +02:00
soul
2da2afd468 Economy command: refuse a read-only catalog before the trial; the trial copy is writable
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:38:50 +02:00
soul
f403485dc0 Economy command tests: the summary line counts removals
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:36:54 +02:00
soul
e5099c0363 Economy command: escape the refusal line breaks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:32:52 +02:00
soul
ade0b0fbf3 Merge eureka/aetheria-arena-sim-rate: the game ticks at 30 Hz; one owner of the rate; timers carry overshoot; guns fire every round their cooldown allows; plain guns are semi-auto
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 06:31:10 +02:00
soul
352a94fe21 Economy command: faults keyed by rule, record and message; L-productive by cycle members with a rising radius refused; removals listed, counted and applied; pending only for the recipe-less; swap failures named, read-only refused, file mode kept
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:28:20 +02:00
soul
a90ec3d450 Economy command tests: fault identity by rule/record/message, cycle members and radius, removals listed and counted, pending only for the recipe-less, swap failure and file mode (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:27:54 +02:00
soul
9036d39f30 Two-AI digest repinned once for arrival-order application (commits of a step precede its applies)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:22:01 +02:00
soul
62a641bf15 Catalog: Slug Rifle and Long Slug Rifle are AutoWeapons (auto-guns apply on master's catalog)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:12:57 +02:00
soul
1f7b971966 Fire mode is one property of a weapon's data (Automatic): creation and the semi-auto floor read it, the floor holds guided, mine, launcher and charged kinds and consumables; a step's rounds apply in arrival order, ties by shot id
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:12:55 +02:00
soul
e92c2140b4 SemiAutoCatalogTests: the shipped catalog holds no plain gun, only guns
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:10:07 +02:00
soul
d3da985290 Fire mode is the design: Slug Rifle and Long Slug Rifle are AutoWeapons; tap and hold tests for both kinds at 15, 30 and 60 Hz; the semi-auto cooldown check
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:10:06 +02:00
soul
5367c899a6 auto-guns: copy the plain gun's fields
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:53 +02:00
soul
35ef321ff3 A plain InstantWeapon is semi-automatic; AutoWeapon repeats while held; the shortest semi-auto cooldown is a named constant with a catalog check; auto-guns command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:52 +02:00
soul
0d8457bc34 SignatureTests: only a gun is tapped; a beam burns while its trigger is held
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:46 +02:00
soul
1aceaa576d Two-AI digest repinned once for resolving a step's rounds in fire-time order (the trigger change alone moves nothing)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:45 +02:00
soul
43042177d6 Tests that press a trigger once release it: a held trigger keeps firing now
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:44 +02:00
soul
46912a20c8 A trigger is the weapon's own state: a tap fires one burst and a held trigger fires its cooldown's rounds at any tick; a step resolves its rounds in fire-time order; the idle-clamp test really idles
InstantWeapon repeats while its trigger is down (Activate to Deactivate), not when it was pulled this step; AutoWeapon's override of that is gone. FireControl keeps the shot queue in fire-time order (ties by shot id) and Step walks it oldest first.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:43 +02:00
soul
37a7bb76f7 Scenario tests step the arena at SimClock.GameStep, the rate the game runs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:42 +02:00
soul
99f5820565 SimRateTests: a zero cooldown ends its step 2026-10-23 06:09:41 +02:00
soul
2b09e87733 SimRateTests: rounds leave from the shooter's pose at their moment; a zero cooldown fires at the shortest cycle 2026-10-23 06:09:40 +02:00
soul
aeac4e2958 SimRateEquivalence: survivor hull and first kill are held to the 95% interval of their difference, as the win rate is 2026-10-23 06:09:39 +02:00
soul
b679a38edc Two-AI digest repinned once for rounds-per-step; the step-cost test measures a round's birth with the shot queue grown 2026-10-23 06:09:36 +02:00
soul
7c9a970c81 Economy command: drop the untested second fingerprint check before the swap
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:35 +02:00
soul
c1f9c05c48 Entity.GetBehavior walks Equipment by index: the per-round targeting-system reads no longer allocate an enumerator 2026-10-23 06:09:35 +02:00
soul
b1380204a3 SimRateTests: split the step cost into a round's birth and its resolution 2026-10-23 06:09:34 +02:00
soul
d038ee393a SimRateTests: the fixture gun's round costs what the fixture reactor can pay at 0.01 s 2026-10-23 06:09:32 +02:00
soul
a1519c9b2b diag (temporary) 2026-10-23 06:09:31 +02:00
soul
c00be8b35c SimRateTests: probe the fast-gun capacitor on failure 2026-10-23 06:09:30 +02:00
soul
93f69ecb80 Rounds per step: a held weapon asks the bus for its step's whole inflow; mine-laying test cooldown stays inside one activation's two steps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:29 +02:00
soul
1870e23bfd Rounds per step: a gun's capacitor holds a step's inflow so every round of the step is paid; the mine-laying test's cooldown no longer pins the one-lay-per-step cap
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:28 +02:00
soul
04128e2f81 InstantWeapon: a pull is held through its step 2026-10-23 06:09:26 +02:00
soul
3b466f8c9b SimRateTests: step-cost test compiles 2026-10-23 06:09:25 +02:00
soul
de9e1ba2f9 Projectile: a round is told of its shot by its manager's one subscription, and first seen where its barrel was when the sim fired it inside the step
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:24 +02:00
soul
95b7aeb7a2 Rounds per step: a held gun fires every round its cooldown allows in a step, each born at its own moment inside it; the shortest-timer pin is gone
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:22 +02:00
soul
3d4508d196 ChargedWeapon: a failed charge's cooldown restarts through InstantWeapon.StartCountdown and carries its overshoot
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:21 +02:00
soul
5adaef4697 Two-AI digest repinned once at the game step (step-exact rules, 30 Hz); shortest-timer test pins the four guns authored below two steps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:20 +02:00
soul
ed5dd92283 Game tick lowered to 30 Hz (ruling tick-rate-stays-30); digest repin follows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:19 +02:00
soul
48ba0a6204 SimRateTests: idle banking, magazine cadence, glowing passive target; equivalence and shortest-timer tests; ping-arm trace repinned for the exact decay
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:18 +02:00
soul
549437536a Cooldowns shorter than a step end in it; tests that pinned the old tick-quantised cadence and Euler decay follow the exact rules
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:17 +02:00
soul
b9e0e5b5eb SimRateTests: whole-step counts by MathF.Round
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:15 +02:00
soul
f0362025d9 Step-exact rules: timers keep their overshoot, passive sensing decays exactly (group B, 60 Hz, digest not repinned)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:14 +02:00
soul
e2f0fc9dc4 SimClock owns the game tick: TickRate, GameStep, ForGame(); the 60 default and ActionGameManager's literal step are gone
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:12 +02:00
soul
a19d0eb73b Arena: sim-rate map and target section 7 (sim rate cut, step part of A-pure)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:11 +02:00
soul
7f3b06222d Economy command: run the batches once on the trial, check that copy, replace Aetheria.cc with it atomically; refuse introduced shape faults by rule and record
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:03:44 +02:00
soul
dc69f5dc5b Economy command: run the batches once on the trial, check that copy, replace Aetheria.cc with it atomically; refuse introduced shape faults by rule and record
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:03:29 +02:00
soul
3092ce81cd Economy checks: null outside-market Rows or row and a null Fills entry are L-recipe faults; a missing recipe is marked pending
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:02:06 +02:00
soul
98aef507f1 Economy command tests: a later batch closing a cycle, in-place edit, one run, real-catalog write, throwing batch, null market rows and Fills (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:01:59 +02:00
soul
0394e33ed8 Economy command tests: pin the printed report, the written set and the trial cleanup (Stryker survivors)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 05:40:51 +02:00
soul
b31595add2 Economy command tests: an edit naming two items is refused (kills the ambiguity mutant)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 05:36:28 +02:00
soul
7367f21c8a AetherDb economy: report and author, dry-run gated by the catalog checks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 05:28:42 +02:00
soul
b4510d5737 EconomyChecks: the key check reads the record key, not the cache's reference to a sound copy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 05:27:50 +02:00
soul
94a14b8dec Economy command: contract and tests, skeleton throws (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 05:26:47 +02:00
soul
dccac5047d EconomyChecks: a recipe with a null list or entry is an L-recipe fault; every other rule reads it as empty
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 05:25:24 +02:00
soul
88a36fff4e Economy null guards: a recipe with a null list is an L-recipe fault, not a throw (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 05:25:13 +02:00
soul
a487f6c0a4 Merge eureka/aetheria-living-world-recipe-schema-and-checks: recipe and outside-market records; catalog checks for sources and sinks, productivity, recipes, lore and consumer demand
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 05:21:37 +02:00
soul
e2e01b2266 Merge eureka/aetheria-release-nakovalnya-boss-loadout: Zhestokost's demo boss is the Nakovalnya Boss loadout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 05:15:41 +02:00
soul
084c77fb12 Boss loadout tests: shipped catalog now holds the demo boss; tests that wrote their own loadouts count only those
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 04:38:27 +02:00
soul
79caa8b32d Zhestokost boss: the Nakovalnya Boss Loadout, committed through loadout-commit --boss-of; RunGoal tests reach it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 04:32:46 +02:00
soul
5584c0d7c8 Merge eureka/aetheria-release-loadout-commit: AetherDb loadout-commit with --boss-of; Loadouts.Commit accepts package designs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	tools/AetherDb/Program.cs
2026-10-23 04:27:12 +02:00
soul
6b89019c02 Economy checks: an undefined radius refuses; factor refusal says why 2026-10-23 04:24:32 +02:00
soul
c6c8b4465b Economy checks: pins from mutation testing; drop two redundant guards 2026-10-23 04:14:42 +02:00
soul
cdee1a27f8 LoadoutTests: a commit over a loadout written after the read writes nothing (kills the dropped-Expect mutant)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 03:43:55 +02:00
soul
3506d48b1f Spectral radius: converge on the vector, not on two equal maxima 2026-10-23 02:50:57 +02:00
soul
91f9b5553c Economy fixture: keyed writes through UpsertAsync 2026-10-23 02:46:19 +02:00
soul
263fa02a73 EconomyFixture is public for the theory data 2026-10-23 02:45:24 +02:00
soul
bacb56c613 Recipe schema and catalog checks: RecipeData, OutsideMarketData, role counts, EconomyChecks over fixture catalogs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 02:43:35 +02:00
soul
d233194cb2 Merge eureka/aetheria-living-world-station-kind-datum: station kinds are one catalog list; stations and fields are placed by seed; bad station packages are excluded by name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:14:33 +02:00
soul
0e50c1d101 RunGoalTests: a boss Loadout on a removed package's hull still boots and fails closed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:48:34 +02:00
soul
37de26291d AetherDb loadout-commit, with --boss-of, validated against the composed package stack
Ported and narrowed from 90f977bf (evidence only; no boss-hull code). Names resolve
exactly against ShipModCatalog.ResolveCatalog over GameData/Aetheria.cc and GameData/Mods,
composed into a scratch file, the catalog the game boots; validation is one
Loadouts.Materialize on a throwaway ItemManager; the commit goes into Aetheria.cc only,
through Loadouts.Commit, with --boss-of in the same batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:48:33 +02:00
soul
48606e4253 Merge eureka/aetheria-release-scenario-picker: New Game is a two-pane scenario picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:44:07 +02:00
soul
f920cd7052 Revert the TextMesh Pro fallback font asset Unity re-serialised
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:43:04 +02:00
soul
2fb8e88fcf ScenarioPicker: find the list's ScrollRect while the panel is still inactive (play mode builds it before showing it); verify builds the real list inactive
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:43:03 +02:00
soul
9961a0df6d ScenarioPicker.Reveal: content moves against the row (the sign the first fix kept inverted)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:43:01 +02:00
soul
ca24a32028 ScenarioPicker: Reveal moves the content the right way; Launch is one-shot; the scrollbar takes no focus; the picker holds focus itself so the scene's background-click deselect is back on
ScenarioPickerVerify builds the real scenario list after a layout pass and arrows to every row, up and down, requiring each wholly visible; Launch by click, second click and Submit runs once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:42:59 +02:00
soul
169679dbc4 ScenarioPicker: viewport span from the rect's own corners so FitViewport and Reveal see the real mask; Launch chrome stretched to its 220-wide button
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:42:58 +02:00
soul
585942259d Scenario picker attempt 2: body heading instead of a second giant title, small left group labels, chrome Launch in the accent, tight Seed/Ambient block, capped brief, visible scrollbar track
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:42:57 +02:00
soul
09de7e5eb2 ScenarioPicker: selected row in the menu accent, title is group over name, panes docked 128 apart; verify and capture run green in Unity; metas
The prefab row tint is nearly invisible on the row's graphic, the long scenario name overflowed a 480-wide title, and the list scrolls to keep the selection in view.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:42:56 +02:00
soul
79a3a62fc1 ScenarioPicker verify and capture: selection and key rules through real UI events, and a 1920x1080 render of the screen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:42:55 +02:00
soul
3bfac25027 New Game is a two-pane scenario picker: names by group, selected brief/seed/ambient, one Launch, arrows and Enter
MainMenu.AddScenarios and the per-scenario launching buttons are deleted; the seven Clear+Title pairs collapse into Next. The main menu EventSystem no longer deselects on background click so the arrows keep working.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:42:54 +02:00
soul
80283d18a9 Loadouts.Commit takes bossOf: the Loadout and Faction.BossLoadout land in one batch
With bossOf set, the same conditional batch Expects the faction unchanged and Upserts it
with BossLoadout = KeyOf(Name), so both land or neither does. The batch lives in an
internal overload over a caller's cache; tests pin that a faction changed on disk after
the read refuses both, and that a missing boss faction writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:42:50 +02:00
soul
7f1337167d Loadouts.Commit stops refusing package designs (ruling catalog-is-a-package-stack)
The catalog is a stack of packages, the shipped content included, so a Loadout may
name any design the stack holds. The mod-design refusal (from 54917bb3, backed by no
ruling) is deleted, and its test now pins that a package design commits under its key.
The IsModKey comment no longer claims the shipped catalog never references a mod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:42:49 +02:00
soul
bd628de3bf Merge eureka/aetheria-release-cultlib-2-0-adopt: Aetheria on CultLib 2.0.1 and CultMath 0.5.0; LockWeaponData is a BehaviorData arm; 1.0.x saves open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:41:50 +02:00
soul
02d60691dc Same-seed belt test: fixed kind keys through a batch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:37:40 +02:00
soul
b1ca2ead90 Same-seed tests: authored kind keys are fixed; the station sample spans eight zones
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:32:34 +02:00
soul
d9bc465355 Packages: lock regenerated by Unity at cultlib-unity-v2.0.1 (b8fc5eb0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:26:14 +02:00
soul
4918fcd993 AetherDb is disposable: it owns the cache it opened
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:26:00 +02:00
soul
b878734158 station-kinds hull: the dry-run and applied lines are one line each
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:23:59 +02:00
soul
d2f4b2c347 A bad package station kind excludes the package; AetherDb opens a catalog with a kindless station hull, names it, and station-kinds hull sets it; the raw opener is gone
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:21:04 +02:00
soul
37a8110907 Station and belt kinds are seeded: the pick hashes the zone seed and the place in generation order, not a minted key
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:21:02 +02:00
soul
17e2da0092 AetherDb tests: a kindless station hull does not lock the tool out; station-kinds sets a hull's kind (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:18:21 +02:00
soul
d55ac30905 Station kinds tests: a bad package kind is excluded not fatal, same seed gives the same station and belt kinds, the stand-in check can fail (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:18:20 +02:00
soul
ea3e983aa2 StationKindsCatalog: OpenCatalog takes a path; tests pin Zenith's kind, the ids and the command's report 2026-10-23 01:16:22 +02:00
soul
0a468f24a8 Station kinds: tests pin the log text, a null faction, and one-hop stand-ins; kind strings default to null 2026-10-23 01:16:21 +02:00
soul
ac4b771f5e StationKindTests: worlds carry the run store zone generation writes orbits to 2026-10-23 01:16:20 +02:00
soul
353ca96b5e StationKindTests: the any-maker log is the hull draw's, not Choose's 2026-10-23 01:16:19 +02:00
soul
a7364ed13e GameData: 17 station kinds and Zenith as a trade-hub (station-kinds apply)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:16:17 +02:00
soul
fba7bd1b47 Station kinds: StationKindData catalog list, HullData.Kind, placed station Kind; GenerateStationLoadout takes a kind (the kindless draw is deleted)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:16:17 +02:00
soul
7886f0a231 ShipAuthoringStore.Load names the file when its store format is newer than this runtime reads
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:14 +02:00
soul
e045af344f Pins: cultlib-unity-v2.0.1 at b8fc5eb0 (1.0.x saves with ref lists open as compatible drift)
packages-lock.json follows from the Unity resolve. caching-unity stays at v2.0.0; cultmath at 7a8933b0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:12 +02:00
soul
da5ba653cb Tests (red at 42dce361): a CultLib 1.0.60 run.cc opens; Load names a ship file of a newer store format
tests/vectors/cultlib-1.0.60/run.cc is the run store Soul wrote with the base build (cl2-old-run-save-refused).
Both new tests fail at the 2.0.0 pin; the first needs 2.0.1, the second needs ShipAuthoringStore.Load.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:11 +02:00
soul
f448957f3a Brush: name AnimatedSimplexBrush and RadialWaveBrush as arms; pull-failure test asserts the record key
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:09 +02:00
soul
e518a0b3d1 packages-lock: regenerated by Unity at the 2.0.0 / 0.5.0 tags
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:08 +02:00
soul
fbf88e85c5 ship_cc.read owns its unreadable-path refusal, naming no path
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:07 +02:00
soul
fb8f2ea2fb ShipAuthoring: Load follows the pull's InvalidDataException, RefuseLegacyEmbeddedHull the nil-stamp NotSupportedException
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:05 +02:00
soul
bd0293d683 BehaviorData: LockWeaponData is arm 52; union-closure and null-gear tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:04 +02:00
soul
1dce24a226 Pin CultLib 2.0.0 (42dce361) and CultMath 0.5.0 (7a8933b0): manifest tags and build revisions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:03 +02:00
soul
8b0b50382a WeaponData: drop the dead [Union] list; BehaviorData's union owns behavior arms
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:01 +02:00
soul
00e60344ab Merge eureka/aetheria-release-cargo-store-owns-quantity: the cargo bay owns stack quantity; restore splits oversize stacks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:10:00 +02:00
soul
c986adcf20 Cargo restore tests: only the oversize stack's own bay is full, so the spill has another bay to go to
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:54:52 +02:00
soul
9958fe55ab Cargo purchase: a bay stack is one lot, so the lot loop is deleted; stock above MaxStack from outside a bay is refused up front
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:50:24 +02:00
soul
b027b36ab4 Cargo restore: a saved stack above MaxStack splits into MaxStack stacks, the same bay first, then the others; only what fits nowhere is dropped, logged
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:50:23 +02:00
soul
d66b2a2c3c Cargo restore tests: an oversize saved stack splits into MaxStack stacks and drops only what fits nowhere (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:49:22 +02:00
soul
fd50f02f1b Cargo purchase tests: a purchase of no units is refused, not bought
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:29:24 +02:00
soul
f7ba043b13 Cargo restore test: share the fixture's provenance ledger
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:21:08 +02:00
soul
fa5820f516 Cargo give: the bay no longer holds a stack below one, so the give's positive-stack filter and its zero-stack test go
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:15:41 +02:00
soul
ddae55759d Cargo bay owns stack quantity: TryStore refuses below 1 and above MaxStack, TryBuyLots owns the stock limit, restore logs dropped stacks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:01:42 +02:00
soul
ffe43b41e8 Cargo store tests: the empty-stock purchase test reaches the charge (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:01:39 +02:00
soul
708fbeee59 Cargo store tests: the bay owns stack quantity, purchases charge units moved, restore drops bad stacks (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:59:06 +02:00
soul
689dbfa9d0 Merge eureka/aetheria-release-cargo-quantity-guards: cargo moves refuse quantities below 1; purchases charge the lot delivered; a give ends when its giver leaves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:55:02 +02:00
soul
4d462c771c Give: Zone.Remove ends the leaving giver's give GiverGone
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:10:09 +02:00
soul
f0d7c12cbd Trade: the purchase loop moves to StationServices.TryBuyLots, charging each lot it delivers; the Buy Quantity field clamps to at least one
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:10:07 +02:00
soul
f9a09e0d0a Cargo: TryTransferItem and Remove refuse a quantity below one and change nothing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:10:04 +02:00
soul
66671cf036 Cargo tests: quantities below one, give routing and ends, purchase lots (red); soul-tv probes P4-P6, P9 adopted
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:09:30 +02:00
soul
7e2fff1ff1 Merge eureka/aetheria-release-transfer-verb: give-only ship-to-ship transfer; partial cargo moves conserve units
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:03:37 +02:00
soul
c37fbdb3b9 Cargo transfer tests: drop the two-bay receiver test; the fixture hulls take one bay
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:11:38 +02:00
soul
b43b238d6a Merge readme-front-door: README opens with value, problem, entry points, live versus planned 2026-10-22 20:10:22 +02:00
soul
1691f47cc6 README: front door with simulation entry points, demo in progress, directions and package hulls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:09:53 +02:00
soul
00d34e5ab5 Cargo transfer tests: the second receiver bay is a one-cell bin
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:05:33 +02:00
soul
3f265b6c73 Cargo transfer tests: fill the one-cell bay the way the other fixtures fill a shape
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:59:19 +02:00
soul
dadb6f327d Cargo transfer tests: long steps after the receiver leaves or strays move nothing; a full first bay hands on to the next
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:56:54 +02:00
soul
0267a191c4 Merge eureka/aetheria-arena-view-seam: one PilotView per side; brains reach other ships only through their side's sensors; unaffiliated ships are sides of one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:50:35 +02:00
soul
488b3fa434 PilotView enforces its own rules: members only, fire-control readouts only on the member's own track; constructor and View setter internal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:26:07 +02:00
soul
961bdafade Determinism ledger: the TwoAiDigestIsPinned comment names both causes of the second repin
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:26:03 +02:00
soul
d38ee7cdd6 Approach track-loss test: warm up before reading the follower's aim (the 60 km anchor is lost by the sensors)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:25:58 +02:00
soul
70aa1ed037 Transfer verb: drop the redundant pre-check, let range, step length and quantity edges show in tests
TryStore already takes all of a part or none, so the pre-check in TryTransferItem changed nothing observable.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:22:43 +02:00
soul
c40a6e8229 View seam tests: per-shooter aim, view gates, sees-hostile, honest track loss (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:21:12 +02:00
soul
19901c1a92 Merge eureka/aetheria-living-world-catalog-subtraction: DemandProfile and the commodity categories go
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:01:45 +02:00
soul
ae52857a91 SimpleCommodityData: name key 10 as the removed stored Category (Soul s1 simple-key10-comment-misnamed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:01:23 +02:00
soul
5b599f66bf Merge eureka/aetheria-release-kit-schematic: agent hulls get schematic lines from their kit; the Nakovalnya package lands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:35:42 +02:00
soul
d3cd7813d7 Catalog subtraction: commodity records lose DemandProfile and both stored categories; the trade menu stops filtering by them
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 17:31:08 +02:00
soul
66c38d9506 Build asks for the marks part only when the kit drew lines (OpenSCAD refuses a part that draws nothing); hull_source documents marks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 17:17:40 +02:00
soul
8d58bca27a Nakovalnya: casemate guns and plate seams emit their lines from the frame that draws them; the copied offsets are gone (geometry and lines unchanged)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 17:11:38 +02:00
soul
98b4f4f946 Build checks that every kit line is drawn in the frame of its geometry: lp_line's marks part meets its lines part
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 17:11:35 +02:00
soul
71b12d594b Kit lines tests: a line drawn outside its geometry's frame fails Build; side-facing fittings emit no line (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 17:09:04 +02:00
soul
dab415ad87 Package: her art's takeover leaves no retired hull lines in the .cc (state decides the source; the slot is cleared)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:55:50 +02:00
soul
87dc0f013f Capture Ship Lines frames the ship as Package does, so kit and Grease Pencil lines land in ship.glb's frame
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:54:13 +02:00
soul
bb38036e51 Cargo transfer tests: build the second stack with a second store
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:54:12 +02:00
soul
2b053b34c2 Smoke: a retired hull's lines are not hers, and Capture writes the kit's lines in Package's frame (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:52:17 +02:00
soul
bcfc776513 Cargo transfer tests: two stacks of one design come from a commodity that stacks two to a stack
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:50:35 +02:00
soul
4e559d822e Cargo transfer tests: the second bay goes where the hull has room
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:46:35 +02:00
soul
c1aab929b1 Give verb: a stop is a verb with no receiver, and the type pattern skipped it; tests: second bay at a free cell
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:42:42 +02:00
soul
7ac8f38901 Cargo transfer tests: the give verb's rate, ends, instance choice and press order
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:38:18 +02:00
soul
cada2fde3e Transfer verb: a pilot gives cargo to a ship alongside, committed through the conserving bay move
TryTransferItem(SimpleCommodity) checks the target can take the part and stores the part removed, not the stack
left behind (it duplicated and lost units on every partial move, the player's commodity buy among them).
TryTransferItems goes. CargoTransfer.Step owns a give's progress and end; PilotCommand.Give and StopGiving are applied
by PilotApplier; TransferRate is the new tunable, DockingDistance the range.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:38:17 +02:00
soul
3fa4768d1c Cargo transfer tests: a partial commodity move duplicates the stack left behind (red), also through the buy path
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:31:18 +02:00
soul
c1d55952eb Smoke: Build records the kit lines and retiring the hull source deletes them
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:31:11 +02:00
soul
4a6ddaa413 hull_source tests: a non-UTF-8 byte in the drawing, an unreadable number
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:17:00 +02:00
soul
970155a8ac Nakovalnya package rebuilt through the add-on: schematic lines from the kit (silhouette plus 102 kit lines)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:12:17 +02:00
soul
0cb78d2610 hull_source tests: pin style, echo parts, contour area, even-odd and edge tolerance, process refusals, check details
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:11:53 +02:00
soul
b064051efd Retarget test: east is staged knowing the third ship (ruling retarget-test-stages-the-track)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:11:03 +02:00
soul
0350255237 PilotSeamTests: drop the probe text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:07:35 +02:00
soul
fd1188a797 hull_source: the silhouette is the zones' top view, what Rasterise reads
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:06:59 +02:00
soul
66c69cdc90 Smoke: sideways-facing kit parts draw no lines (octagon and line counts of the Nakovalnya)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:06:19 +02:00
soul
1a2a24ceb1 Staged knowledge: repin the two-AI digest for fighters staged knowing each other; LockOnSituation drops the no-track premise
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:05:39 +02:00
soul
5bb4ad5f0f Smoke: the silhouette may be several contours, all before the kit's lines
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:02:53 +02:00
soul
d5d94fb483 Staged Approach: the pilot starts knowing its anchor, through the sensors' own info state (ruling stage-states-pilot-knowledge)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:00:08 +02:00
soul
905074defb Add-on: schematic lines are hers (one Grease Pencil) else Build's from the kit; smoke and C# fact
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:59:56 +02:00
soul
b982fb9cd0 hull_source: kit lines echo, SVG silhouette projector, SchematicLine, three line checks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:57:44 +02:00
soul
5c38029ec0 Research: crafting chains and non-gear demand, prior art (serves follow-up crafting-chains-and-demand)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:56:21 +02:00
soul
63e71a1f30 PilotView tests: sides, tracks, handles, designation, fire-control readouts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:56:00 +02:00
soul
28ed24b28e Zhestokost kit and Nakovalnya emit schematic lines from their own frames (outline, step, seam, fitting)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:54:27 +02:00
soul
bd343b2a4e Kit frames: mates are lp_at placements that track the Ship Root frame; zone OFFs unchanged
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:53:20 +02:00
soul
a18d3b812f Merge Nakovalnya package head a67711b3 into kit-schematic
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:50:10 +02:00
soul
2ad52e7e9e PilotView: brains reach other ships only through their side's view, by opaque handles
One view per side over its members' sensors and books. Flight, Combat, Follow and Minion read contacts as ContactId
and fire-control readouts as view calls; FollowTask.Anchor is a handle. Own-ship reads stay direct.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:48:02 +02:00
soul
6252c29efa Determinism ledger: repin TwoAiDigestIsPinned for the side split (the fighters were one side and followed themselves)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:42:12 +02:00
soul
9b4b46e243 Zone: a side is a faction or one unaffiliated ship; each unaffiliated ship flies a flight of its own (brain-one-per-side-r2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:39:02 +02:00
soul
33103ce5da Seam fix tests: a neutral declaration drops the target, so a second press in that step finds none, as before the seam
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:44:28 +02:00
soul
8692cf8600 Command seam: the stance press resolves its target at the step, after the step's target verbs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:39:46 +02:00
soul
2f15c79176 Seam fix tests: the stance press resolves its target at the step, flips the belief, and the ping press pings once (red)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:39:15 +02:00
soul
cfbd044551 Command seam fixes: drop the ping observation, which this fixture's uncharged sensor cannot show
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 06:29:02 +02:00
soul
87f29cb0c8 Command seam fixes: the ping pin waits for the ping's cooldown and charge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 06:18:32 +02:00
soul
a7477e679d Command seam fixes: the ping pin runs before any switch is thrown; a stance set is pinned as a set beside the flips
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 05:51:10 +02:00
soul
72ad1b8036 Command seam fixes: the shield switch is pinned on a shielded target; the Longinus has none
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 05:07:25 +02:00
soul
bc05417450 Command seam fixes: the player's switches, ping and stance flips are PilotCommand presses; triggers, switches and ping are internal
ActivatedBehavior replaces IActivatedBehavior so Activate/Deactivate can be internal under Unity's C# 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 04:33:20 +02:00
soul
25750c225b Command seam fixes (red): stance flips and the player's switches and ping are presses on PilotCommand
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 04:32:17 +02:00
soul
30bcabf172 Command seam fixes: pin Combat's release of a pulled lock, its commanded-aim pricing, and the order and count of same-step presses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 04:31:47 +02:00
soul
b180fd732d Command seam, player: the Previous and NearestEnemy pins start from no target, where no other verb gives their answer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:47:57 +02:00
soul
d8a4e1567d Command seam: the trio's third ship sits in east's sensor cone; the lock pin holds the guns so the lock completes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:31:44 +02:00
soul
76e4e6f58f Command seam: the trio fixture waits for its contacts, and the lock pin for any launcher's lock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:30:53 +02:00
soul
06435bdefa Command seam: the lock-pull pin carries the target past the launchers' minimum range
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:29:58 +02:00
soul
bee079b1c8 Command seam, player: the reticle verb's pin answers a target no other verb would
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:28:16 +02:00
soul
6f4dbff317 Command seam, player: the control setters and pilot methods are internal, so no Unity script can write them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:27:30 +02:00
soul
394c13a099 Command seam, player: ActionGameManager and ActionBarSlot write only Entity.Command (the 18 direct writers)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:27:29 +02:00
soul
76143e50d1 Command seam, player: PilotCommand gains Tractor, the target Verb, CycleAim and Iff; HelmInput.Demand returns a TurnCommand resolved each step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:27:28 +02:00
soul
9d9a8ed031 Command seam: the applier is the only writer from PilotCommand; the pilot reads back its own pending decisions (Soul finding only-reader-comment-false)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:27:08 +02:00
soul
7427a70971 Command seam: pin the Release path, Combat's lock pull and release guard, and the retarget step's pending reads (Soul findings release-path-untested, retarget-step-pending-reads-untested)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:26:28 +02:00
soul
b519fbd3d0 Merge remote-tracking branch 'origin/master' into eureka/aetheria-arena-command-seam-agents 2026-10-22 02:12:19 +02:00
soul
bbb1dc717e Merge remote-tracking branch 'origin/master' into eureka/aetheria-gravity-laned-belts 2026-10-22 02:03:21 +02:00
soul
67e2647753 Gravity: restore the swarm hosts' Empty guard; a standing binary's bodyless node keeps its mass and hosted swarms (finding swarm-on-bodyless-node)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:40:28 +02:00
soul
dc36b8400f Gravity tests: no swarm rides a binary's bodyless node; lane filters on a tuned sample; a lane splits rocks by band area (red)
NoSwarmRidesABinarysBodylessNode is red at 95903ab0 (finding swarm-on-bodyless-node). The tuned LanedBeltTests
sample reaches the swarm-host and one-shape-per-parent filters (lane-filters-unexercised); ALaneSplitsItsRocksByBandArea
pins LaneRocks' area share (lane-split-unpinned).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:40:15 +02:00
soul
a67711b3d3 aetheria.nakovalnya: side flak rows and radiators are 2x1 in the grid frame (drive.py rerun through the add-on)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:31:16 +02:00
soul
c7e85676fe ship_cc: replace_layout refuses on hull_grid.MAX_CELLS, the grid limit's one owner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:28:07 +02:00
soul
da77380a04 Hull source: build refuses a hull type that is not a HullType name, before reading the source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:28:06 +02:00
soul
efeadec265 Blender ships tests: build refuses a hull type that is not a HullType name; the grid limit is hull_grid's (red)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:27:38 +02:00
soul
d76563dd5a NakovalnyaPackageTests: every mount takes its gear through Takes; rows inside the shape and disjoint; no footprint-count pin (red)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:25:22 +02:00
soul
8bfb06ed64 Hull source tests: a Station 32 cells long fits the grid (height bound, not only width)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:15:11 +02:00
soul
28496f27df Hull source smoke: HullType gate (refused as Ship, built as Station, Station refused on the grid)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:07:54 +02:00
soul
2267d8beec Hull source: language proportions judge Ship hulls only; zones must fit the layout grid
build() takes the hull's HullData.HullType, with no default; the add-on reads
it from the bound ship.cc. A Station or Turret lists no proportion check; every
hull keeps symmetry, length, budget and anchor checks and gains the grid check,
judged over zone meshes through hull_grid.grid_size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:03:27 +02:00
soul
0a70e0cf93 Blender ships: hull_grid.grid_size; ship_cc reads HullData.HullType, pinned to C#
grid_size is rasterise's grid arithmetic without its MAX_CELLS refusal, so a
check can judge the bound; rasterise calls the same private filter and sizing.
ship_cc gains HULL_TYPE_SLOT (Key 25), HULL_TYPE_NAMES and read_hull_type;
ShipSchemaPinTests ties both to HullData.HullType and the HullType enum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:00:06 +02:00
soul
321e34a0da PilotSeamTests: an agent's command lands before the next agent decides
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:46:10 +02:00
soul
95903ab00e Merge origin/master (arena cut 1: ItemManager takes a seed) into eureka/aetheria-gravity-laned-belts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:44:50 +02:00
soul
0e9b9c71e2 Gravity: lane half-width 1.5 body radii (ruling lane-narrow), code default and Settings.asset
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:44:36 +02:00
soul
74257926b1 Gravity: a parent's outermost child never hosts a lane (ruling lane-not-outermost); one host filter, an only child pinned
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:44:35 +02:00
soul
f7314b2661 ShipProductTests: stats --top-speed refuses a hull with two VelocityLimits and leaves it untouched
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:41:12 +02:00
soul
07bd57d9e1 ConsumablesFirstSetTests: the vent's smallest cloud holds every sold ship hull
Ruling vapour-cloud-sold-hulls; the radius is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:41:12 +02:00
soul
bc53361ff9 Census tests: sold hulls are LoadoutGenerator.SoldHulls; a hull's asset is its Prefab or its mod-ship Visual record
Ruling census-tests-read-the-owners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:41:10 +02:00
soul
9ac8f23307 RunStartTests: the own-maker and factionless reach claims hold for every product LoadoutGenerator.IsOffered offers
Ruling census-tests-read-the-owners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:41:09 +02:00
soul
487c45f2bd PilotSeamTests: price the commanded aim item after the guns come online
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:39:43 +02:00
soul
2f71975194 PilotSeamTests: the seam's rules (agent writes only its command, held and one-shot fields, apply order, lone-ship side, commanded aim item)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:36:15 +02:00
soul
3795bdb066 Tests: Flight and Steering fixtures drive pilots through the seam (apply after a direct update; scripted demands are commands)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:36:14 +02:00
soul
26a25fce3a Tests: drive agents through the seam (apply after a direct agent update; the duel's scripted player commands)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:32:12 +02:00
soul
9a2a9390e1 Command seam, agents: one PilotCommand per entity, applied once per step by PilotApplier in Zone.Update
Agent, Flight, Combat, MoveTo and Follow write only Ship.Command. Entities
with no agent apply first, then each agent applies right after it decides.
A pilot reads its own pending designation and aim item back from its command;
FireControl gains Refuses/AgentFires/HitProbability overloads that take them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:32:11 +02:00
soul
85e539af2b NakovalnyaPackageTests: import System.Collections.Generic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:29:47 +02:00
soul
95f89a38a6 NakovalnyaPackageTests: the package composes, its Zhestokost product is never offered or drawn, and its layout is the brief's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:28:59 +02:00
soul
b13aa7385a Command seam, agents: delete the 17 direct pilot writes in Agent, Flight, Combat, MoveTo, Follow
Each write is replaced by a /*DEL*/ marker; the next commit routes every
marked site through the entity's PilotCommand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:28:53 +02:00
soul
7cb6fb0c9d Mods: aetheria.nakovalnya, the Zhestokost boss hull package (hull.scad, ship.cc, ship.glb)
Built from hull-bench Nakovalnya B by the Starfire job: New Ship --like Djinni, ship-authoring stats (mass 60000, durability 8000, armor 40, drag 0.35, top speed 25), price 60000000, a not-offered Zhestokost product, Build, Rasterise (19x30), 27 layout rows, Package. The validator accepted it and compose excludes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:28:31 +02:00
soul
0bccb395a4 ShipProductTests: compare the stats hull by its MessagePack bytes (Newtonsoft loops on Shape)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:22:44 +02:00
soul
2fd8cbe8e9 AetherDb: ship-authoring stats sets a package hull's mass, durability, armor, drag and top speed, judged before the file opens
Ruling ship-authoring-stats-command. --top-speed writes a fixed top speed (Min = Max) on the hull's one VelocityLimit and refuses a hull with none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:17:59 +02:00
soul
3efc8cddd7 Merge remote-tracking branch 'origin/master' into eureka/aetheria-arena-determinism-ledger 2026-10-22 00:01:30 +02:00
soul
ad05486388 Zhestokost kit: zh_z keys a donor's zones 'zhestokost:<zone>' when HULL_DONOR is zhestokost
zh_key in ewanhart.scad's form, not AU's either-name match: a Pirate hull's own zone named like a Zhestokost
zone no longer receives Zhestokost geometry. Closes the gap in follow-up zhestokost-kit-donor-routing.

Spec: aetheria-release:cut_spec:cut-zh-kit-landing.r1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 23:41:15 +02:00
soul
2fe4129777 Zhestokost kit: land the hull-bench kit; zh-ammo-tender fixture rebuilt from Oboz A with one defensive mount
factions/zhestokost.scad is the bench draft byte for byte (battered masses, typed mates, five zones with cream
marking), its lang comment naming the maker element. The fixture is bench Oboz A plus Gun.D on T[2]'s deck
centreline aft of its hatches; the smoke's palette, shadow colour and turned-mount pair follow.

Spec: aetheria-release:cut_spec:cut-zh-kit-landing.r1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 23:40:26 +02:00
soul
462adf2693 determinism-ledger: pin each scatter draw's ordinal and bounds, and the zero item-seed refusal
Three origin drifters, each placed at For(CombatSeed, ScatterStream, k) within half the radius;
a galaxy seed whose ContinueStep item seed wraps to zero is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 23:38:03 +02:00
soul
a46c0536cc release map: ProductWeight's line share counts only offered bins
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 23:25:26 +02:00
soul
25c2a18ab3 Merge remote-tracking branch 'origin/master' into eureka/aetheria-release-product-not-offered 2026-10-21 23:25:16 +02:00
soul
f102b082fd Gravity: laned belts, a belt on a copy of a light body's rail filling its slot less a visual lane
Place records every child's slot; GenerateEntities marks belt candidates. After the swarm pass, a lane pass on its own
stream gives a candidate that kept its body, has a band at least the lane's half-width on both sides, carries no swarm and
whose parent has no other belt shape, an annulus on a copy of its rail: rocks over [slot inner, D - E] and [D + E, slot
outer], E = LaneBodyRadii body radii. ZoneGenerationSettings gains LaneProbability (0 in code, 0.5 authored) and
LaneBodyRadii (5). LanedBeltTests pins it; EveryBeltIsKindedAtGeneration moves there and asserts lanes; BeltShapes.Lanes
lands with it; both TrojanSwarmTests fixtures draw no lane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:42:49 +02:00
soul
f7cdb766cd LoadoutGenerator: IsOffered reads a bin's line, and BinShares counts only offered bins, so an off-market bin leaves its line the whole weight
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:01:41 +02:00
soul
f2cd036890 ProductChoiceTests: rig bins honour the not-offered list; an off-market bin leaves its line the whole weight, and an off-market line's bin is never drawn or stocked
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:00:55 +02:00
soul
749bdb09f5 ShipProductTests: the product grammar refuses a misnamed flag or a dangling description, and the on-market line is the off-market one without its clause
Kills the four Stryker survivors of the --not-offered parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:58:09 +02:00
soul
d0fc8adb70 Gravity: a wider zone fingerprint, one copied-rail helper, belts kinded at generation, adjacent belts pinned
ZoneFingerprint digests rock spin and periods, orbital entities (type, position, rail), other entities by type, the
pack radius, a 9x9 height grid at two times and optionally PopulatedRadius (finding fingerprint-omits).
TrojanSwarmTests.BaseDigest is repinned with it on Yggdrasil at C1's generator, swarms off.

BeltShapes.CopiedRail replaces StationPlacementTests.SwarmRail and the RidesRail slot filters of
ChildrenWithinReachTests: a rail a belt rides that copies a body's rail is never a placed slot.

The Zones fixture counts the belts the generator writes and those with no field kind, before Zone's own
FieldKinds.Ensure; EveryBeltIsKindedAtGeneration pins it at zero (finding ensure-unpinned).
AdjacentBeltsKeepTheirSpacing places two belts side by side and pins the second's inner edge a sibling spacing
outside the first's outer edge (follow-up belt-after-belt-edge-pin).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:43:23 +02:00
soul
73d1cee9de Gravity: one belt extent rule, read by Zone, the generator's clearance tree and the station tests
AsteroidBelt.Extent(data, rail) is now static over the belt's data and the only spelling: the clearance tree's
'Distance + BeltHalfWidth' and the instance Extent are gone. The swarm host filter loses its dead '!p.Empty'
(no bodyless node has a parent). Findings extent-second-spelling and dead-empty-guard (trojan-swarms-residuals).

The clearance tree now reads the outermost rock, not the rail plus half-width, so 29 of the 1920 zones of seeds
1-30 place a station or turret differently. TrojanSwarmTests.BaseDigest is repinned for seeds 3-6, which hold such
zones; seeds 1 and 2 are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:38:43 +02:00
soul
51a3fd0bc9 Merge remote-tracking branch 'origin/master' into eureka/aetheria-release-delete-muzzle-facing 2026-10-21 20:38:06 +02:00
soul
aea0ce823d release map: AH-P1 checks line no longer lists the deleted nose-ward rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:37:59 +02:00
soul
0c6f359cb5 ProductLinesCatalogTests: FactionProductData's wire pin covers slot 8, and a record written before it is on the market
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:34:50 +02:00
soul
bd5d27c52b test_hull_source: a mount is not paired by another gun's muzzle
Kills the surviving and->or mutant in the kept pairing rule's unmuzzled list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:31:47 +02:00
soul
3d005ab559 LoadoutGenerator: IsAvailable's off-market comment names no boss term
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:30:04 +02:00
soul
9cedb4efe6 AetherDb ship-authoring product --not-offered writes the package product off the market
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:28:41 +02:00
soul
9c50b7499b FactionProductData.NotOffered: an off-market product is never drawn, stocked, chosen or a gear home
One reader, LoadoutGenerator.IsOffered, read by IsAvailable (ahead of the no-faction
shortcut), the AvailableProducts candidates (so the any-maker fallback cannot reach one)
and SoldHulls. A Loadout that pins the product still builds it. Rulings
off-market-is-unoffered-product and boss-hulls-exclusive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:28:40 +02:00
soul
51e82624d5 test_hull_source: pin that a muzzle facing any way passes the hull source
Muzzles aft of and beside their mounts pass every check, so a reintroduced
facing judgement fails a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:26:17 +02:00
soul
6f12283dc0 gravity map: laned-belts mapped and admitted; lane host and half-width cells filled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:26:15 +02:00
soul
ee78b85fd0 hull_source: delete the nose-ward muzzle rule; gun facing is HullData's
The Blender scene cannot know which way a hardpoint points: HullData's
hardpoint rotation owns that (ruling aetheria-release:ruling:gun-facing-is-hulldata).
Deletes the forward list and its term in the muzzle check, the two facing
tests, and the smoke's nose-ward require. Pairing stays whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:26:02 +02:00
soul
0524d3729d determinism-ledger: pin the two-AI digest captured on Yggdrasil (linux-x64)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:09:20 +02:00
soul
ced96568bc determinism-ledger: the ItemSeed test's expected values wrap as the formula does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:06:49 +02:00
soul
cc5d498b7e determinism-ledger: tests for the seeds, the scatter, Scenario.Id and the two-AI fight
DeterminismLedgerTests (a RunStartTests partial): ItemManager refuses seed 0;
ItemSeed is the run seed and step; the unpack scatter ignores the item stream
and differs per zone; the SimulationDice streams are distinct; scenario Ids are
unique kebab; a two-AI fight is one history, and its digest is pinned for
linux-x64 (pin lands in the next commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:04:51 +02:00
soul
9379712c78 determinism-ledger: Scenario.Id, a stable kebab identity for matches and replays
Every scenario, and the test Scripted scenario, names an Id; Name is
display only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:01:43 +02:00
soul
cc0c164574 determinism-ledger: every ItemManager states its seed; scatter on the zone's dice
- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it.
- RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a
  zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed.
- Continue seeds the item stream from the saved run's seed (ContinueStep);
  a scene with no galaxy uses NoRunSeed.
- Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream,
  1-based pack ordinal), never the item stream.
- Loadout's scratch manager and AetherDb's catalog command take fixed seeds;
  every test and Editor construction gains 1u (mechanical rewrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:01:41 +02:00
soul
c13bb2368e determinism-ledger: delete the clock-seeded item stream and the item-stream scatter
ItemManager no longer seeds itself from DateTime.Now, and Zone's unpack no
longer scatters ships with _itemManager.Random. Deletes before adds: this
commit does not build (the scatter and every ItemManager seed return in the
next commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:59:52 +02:00
soul
3df3a9549a aetheria-arena target: deterministic headless matches between swappable brains
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:48:50 +02:00
soul
d52b50f911 Research: strategic AI arena and living world prior art (persistent faction worlds, bot tournaments, believability, restricted play, fun proxies, off-screen combat models), for rulings arenas-explore-the-metagame and colosseum-feeds-matchup-model
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:06:49 +02:00
soul
2f4b617e50 Merge remote-tracking branch 'origin/master' into eureka/aetheria-release-package-agent-hull-guards 2026-10-19 14:25:09 +02:00
soul
a9207abba9 smoke_hull_source: pin H10, H6 all-edited, maker-less record, collider-alone and the unbuilt exit text
Soul s4 survivors: the zone test keyed on hull.scad, an edited-zones exclusion, the maker-None branch, a
collider counted as art, and the unbuilt refusal losing its exit text or becoming the agent state.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:45:09 +02:00
soul
2882907f66 smoke_hull_source: pin agent hulls with no zones and no art (1.9a), her art over an unbuilt hull.scad (3.1) and a plain hull (3.2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:11:10 +02:00
soul
82dab50ab5 Aetheria ships: _hull_state is the one owner of a hull's state; Package's guard and retirement read it
Deletes _art_takes_over and _refuse_wrong_maker's hull.scad branch. Her art over a hull.scad Build never ran now ships and retires the source (operator ruling art-over-unbuilt-hull-source-takes-over).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:11:09 +02:00
soul
96cb6d20f1 Research: prior art for agent-authored stand-in hulls (render-critique loops, part libraries, self-judging limits)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:01:22 +02:00
soul
1ecddc0e69 Merge remote-tracking branch 'origin/master' into eureka/aetheria-release-boss-loadout 2026-10-19 02:45:13 +02:00
soul
3ac185a84e aetheria-release map: hull collection states (HS) and the agent-built row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:07:26 +02:00
soul
f121b46463 Package's art exemption keys on Build's maker record, not hull.scad; case-variant Manufacturer and attack maker keys pinned in smoke_hull_source
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:44:15 +02:00
soul
f3719d9943 Boss refusal tests: compare the serialized ledger, not the pack (pack bytes differ between identical runs)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:30:10 +02:00
soul
820ec73f76 Boss refusal tests: a mismatch prints both zones and the log
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:29:03 +02:00
soul
66510b003f Boss refusal tests: gear projection reads ships only (turrets are not ships)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:25:40 +02:00
soul
8ae052bb47 Materialize refuses an unbuildable Loadout before minting or drawing; delete the boss item-stream restore
Materialize now settles what the catalog alone decides, tries the cells on a
throwaway ItemManager (own ledger and stream, designs built unbranded), and only
then asks the chooser and mints lots. ZoneGenerator's copy-and-restore of
itemManager.Random is deleted, and the boss faction's LoadoutGenerator is created
on the first choice instead of as an argument. Resolve keeps its signature and is
Settle then Build.

Tests: an unbuildable boss zone equals the no-boss zone (serialized pack, ledger,
roster and gear, next item draw) for one failure, several failures and design-only
slots, across every faction as boss faction.

Not yet built or run when committed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:22:38 +02:00
soul
70c366b541 smoke_hull_source: update the view layer before comparing world positions around a refused Package
matrix_world is stale after a location edit until the depsgraph runs, so the
scene snapshot taken after moving Ship Root compared stale to fresh.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:15:26 +02:00
soul
8a1125f1fc ShipSchemaPinTests: remove a stray line left in the maker-key pin
The previous commit does not compile (a/2284391b: ShipSchemaPinTests.cs line 118).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:10:42 +02:00
soul
a2284391ba Pin the three language maker keys to the catalog; pin product_makers' exact comparison; fix a stale derivation line
ShipSchemaPinTests reads each factions/*.scad maker literal against its Faction
record key in GameData/Aetheria.cc. test_ship_cc pins that Manufacturer keys are
not case folded and that a bare string product body is refused, not read as a
maker. hardpoint-sizes-prior-art.md no longer describes the deleted derivation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:09:12 +02:00
soul
399dc769df Package's maker guard keys on Build's record, not on hull.scad; smoke_hull_source fixes
_refuse_wrong_maker returned early when hull.scad was absent, so a built hull
packaged with any product once the file was deleted. It now keys on
aetheria.hull_maker; only art takeover (which retires both) and a package that
never held a built hull are exempt.

smoke_hull_source: ATTACKS lang literals gain the eighth (maker) element; new
refusal steps for hull.scad gone and for a right-maker plus a wrong-maker
product; a refused Package must leave the scene untouched (guard order).
Unrun at this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:09:11 +02:00
soul
8a6816ed3e test_ship_cc: a product body one short of the Manufacturer slot is refused, not an IndexError
Kills mutant short-body-reads-past-end.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:25:31 +02:00
soul
af993acf07 ZoneGenerator: an unbuildable boss Loadout restores itemManager.Random, so the zone's later draws equal the no-boss zone's
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:21:11 +02:00
soul
7f35c79ecc Package refuses an agent-built hull unless its product is by the language's maker
Language files carry their Faction record key (eighth element of the lang echo);
Build records the maker it judged (aetheria.hull_maker); Package compares it with
ship_cc.product_makers before writing anything (ruling hull-faction-is-product-maker,
finding hull-names-its-own-faction). _art_takes_over is shared with _retire_hull_source.
Unit tests untested at this commit until the Yggdrasil run; bpy smokes not run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:20:00 +02:00
soul
adaa8b8865 Build and Package refuse objects that share an aetheria.id, by selection
One _refuse_shared_ids: a copy of the collider, an emitter, a radiator or a mount
selects the original and the copy and names a count, not an object
(follow-up hull-copy-refusals). smoke_hull_source covers it (not run: bpy smokes).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:17:18 +02:00
soul
eaf7b799d4 Package moves no row: delete the schematic-from-model derivation
Remove _plan_mounts/_apply_mounts, _derived_position, CENTRELINE, hull_grid.cell_at,
mounted_position/mounted_cell and their tests (ruling remove-package-schematic-derivation).
Add Weapon makes a default-Position row; Mirror Mount mirrors each space within itself.
smoke_mounts rewritten (not run: bpy smokes are held off Starfire).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:16:35 +02:00
soul
35c93dd6ab aetheria-release map: body facts and rationale for cut-package-agent-hull-guards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:13:44 +02:00
soul
1950397630 RunGoalTests: Soul's s1 probes as behaviour tests (referenced Loadout wins on a shared hull, refusals fly no catalog Loadout, no maker fallback, boss path draws only what Materialize draws)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:04:45 +02:00
soul
5acbaa1b2f Merge eureka/aetheria-release-agent-hulls (head 3c4ea230)
Agent-built hulls, first cut: per-faction form-language files, per-hull sources, Build From Hull Source (rulings hull-art-communicates-form, zhestokost-language-lore-text). Reports cut-agent-hull-source.h1, h2; Soul s1, s2 hold on the fixes (open: question who-names-a-hulls-faction, follow-up hull-copy-refusals). The branch touches only tools/blender, which master did not change since base d28ce42d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:58:46 +02:00
soul
3c4ea230bc test_hull_source: a fingerprint tells nested empty lists apart
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:36:54 +02:00
soul
d5491c1c57 test_hull_source: a genuine language echoed after an altered one, and a faction name that escapes the library
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:35:14 +02:00
soul
1e7678526a Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.

Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.

Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
soul
5a050f0fca Merge eureka/aetheria-release-run-seed (head cb1c093e)
Reports cut-run-seed h1/h2; Soul verdicts s1, s2 hold.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:29:04 +02:00
soul
d83cacc6a7 Merge eureka/aetheria-release-product-tiers (head e6a17a5e)
Reports cut-product-tiers h1/h2; Soul verdicts s1, s2 hold.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:29:02 +02:00
soul
f9acdc1e9c smoke_hull_source: the collider is the language's body zone mesh (a hand mutant took the first zone and survived)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:08:53 +02:00
soul
2e597b0715 test_hull_source: the survivors of the first hand-mutant round get tests (vertex bound, exact bilateral bound, emitter triangles in the budget, non-mount muzzle parent, donor colours, missing donor language, empty silhouette)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:04:42 +02:00
soul
24f057d18f Build From Hull Source: zone meshes, mount anchors and a collider from the package's hull.scad; Package retires a hull.scad that operator art replaces
Build refuses a source that breaks its language or brief, and a collection holding render meshes it did not make;
it replaces only objects marked aetheria.hull_source and writes no layout row. Package deletes hull.scad, and says so,
once the collection holds art and none of Build's objects. OpenSCAD path is an add-on preference.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:00:15 +02:00
soul
763fa0987f hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
soul
f17b04ec1a FlightTests: Faction's wire array ends at slot 22 (BossLoadout), so it is 23 long
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:31:57 +02:00
40db8291d8 Exit_open_only_in_the_exit_zone: the gate boss pins its hull's brand, so any faction builds it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:25:21 +02:00
d8afb9d184 Exit_open_only_in_the_exit_zone: a failed boss build reports the log
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:24:46 +02:00
f235eea3d9 Exit_open_only_in_the_exit_zone: the faction with the boss Loadout is one that can get its hull's brand
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:22:05 +02:00
3ad3fcc505 RunGoalTests: using GameCult.Caching.MessagePack
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:19:14 +02:00
d2ec3a6678 RunGoalTests: using GameCult.Caching
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:18:49 +02:00
ad503059e5 RunGoalTests: LoadoutCells reads the fixture's cache
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:18:20 +02:00
e5485bb3e0 aetheria-release map: agent-built hulls replace the stand-in section (form languages, probe, first cut)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:17:42 +02:00
e2b1b9ca7b RunGoalTests: the boss flies its faction's boss Loadout; refusals, exact cells, brand by context, market rule, main-galaxy pick
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:17:05 +02:00
e6a17a5e01 ProductLinesCommandTests: the bins of a removed line go with it, as the catalog read now requires
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:14:14 +02:00
352e221453 Faction.BossLoadout (key 22): the boss is its faction's Loadout, resolved by Loadouts.BossOf and built by Materialize
BossOf refuses typed (BossRefusal Unset or Missing, Describe names the field and echoes no input).
ZoneGenerator builds the boss from the Loadout with ChooseProduct as the chooser; a refused or
unbuildable boss logs and spawns none. Galaxy picks boss factions through BossOf; AetherDb factions
prints the boss Loadout. Loadout vocabulary: no preset, no variant. Production builds; the tests
project does not until the next commit (RunGoalTests still sets BossHull).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:14:09 +02:00
900fa51e43 Delete Faction.BossHull (key 10 retired), GenerateBossLoadout and the three tests that pin its fallback
A boss is a Loadout the faction references, never drawn (rulings faction-boss-is-a-loadout-reference,
boss-gear-is-a-loadout, demo-boss-hull-invented). This commit does not build: ZoneGenerator, Galaxy,
AetherDb and the tests still read the deleted members; the next commit replaces them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:12:58 +02:00
0769314ee6 aetheria-release map: boss loadout replaces boss hull derived (Faction.BossLoadout, off-market by product)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:11:01 +02:00
8d65e957f8 sensor-stat-set skips a bin, as the other role writers do; it takes an optional root for its test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:08:11 +02:00
f73507ff2f Catalog read refuses a bin with a non-finite Floor, a missing Line or a Line that is a bin; a bin mints from exactly its named line (tested)
The runtime no longer trusts the authoring tool for a bin: a NaN Floor weighed NaN and nulled every
draw of its design. AetheriaStores.Open names the field and echoes no value.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:08:10 +02:00
129e5ff4dc ProductWeight is the one weight of every design-only draw; a line's bin shares are read once per pass
The all-weights-zero branch of ChooseProduct and the required-item fallback of RandomProducts weighed
every product 1, so a bin doubled its maker's presence there. Both now weigh by Share alone (the
preference drops out, the bins' shares do not). The sum of a line's bins' pass fractions is computed
once per weighting pass instead of scanning the product table per candidate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:08:09 +02:00
d28ce42df9 Research: faction form language and role readability for agent-built hulls, for ruling hull-art-communicates-form
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:37:22 +02:00
a40072d9ab aetheria-release map: mark the stand-in section superseded by ruling hull-art-communicates-form
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:30:06 +02:00
096f833c24 Merge eureka/aetheria-gravity-trojan-swarms (head de3ecd9b)
Trojan swarms: belts riding their host's rail period at L4/L5 (rulings trojans-first, trojans-ride-host-period-with-stations). Report cut-trojan-swarms.h1; Soul s1 holds (four Lows deferred to follow-up trojan-swarms-residuals); GravityFieldParityVerify passes. Master's commits since base 67517f40 are docs only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:24:12 +02:00
cb1c093e84 ConsumableAiTests: open the run store so a scene can continue a run at a fixed seed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:21:31 +02:00
0ed0605aa5 Pin the run seed's edges: AI dice stay on CombatSeed, full-width seed, refusal names its field, refusal test cannot hang
- ConsumableAiTests.ThePilotsDiceDoNotMoveWithTheRunSeed: sample offset and patrol circuit equal at different run seeds.
- RunSaveTests.TheRunSeedRidesTheSave uses a seed with the top bit set.
- RunStartTests.AGeneratedGalaxyRefusesSeedZero builds from authored settings (small ZoneCount) so a constructor that stops refusing fails by assertion, and pins ParamName and no ActualValue.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:13:03 +02:00
9457e4c8f9 ProductBinTests: the search's 64-draw bound and its stop at the first clearing draw, and the missing-line log, are pinned
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 19:51:40 +02:00
beb733f4b5 Census role gaps become RoleGaps, so a bin skipped by the census has a test; a bin authored on another line is refused
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:46:05 +02:00
de3ecd9bb0 Trojan swarm tests: host class, rock side parity, width and arc reach
Stryker survivors on the generator showed the host-mass gate, the side of each
rock and the width/arc scale were unpinned. Builds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:42:44 +02:00
112dd24536 aetheria-release map: boss hull derived r4 (fixed Loadouts), stand-in hulls (SI) probe and cut sequence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:20:07 +02:00
fb7b95fb06 ProductChoiceTests: a bin takes nothing from another maker's product
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:19:00 +02:00
aec7b3c019 product-lines tiers lists every row, present or new, before it reports there is nothing to do
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:16:27 +02:00
9b7a1c00dc Catalog: the six tier bins (product-lines tiers apply); TierRowsAreShipped; the tier step pinned from both sides
Aetheria.cc written only by the command, on the pinned CultLib. Replay the command at merges; never merge the blob.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:11:11 +02:00
6768cdcb95 product-lines tiers: authors the six bins from a table of (line, bin, floor, text)
A bin gets its line's design and maker, Bin = { Line, Floor }, and no roles or assembly. Resolve-then-write
as the other lines do; a replay is a no-op. The catalog is not yet applied.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:08:56 +02:00
2806b443c9 Census, roles-migrate and roles-backfill skip a bin: it authors no roles, it reads its line's
RolesMigrate takes an optional root, as RolesBackfill does, so its bin skip has a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:06:07 +02:00
7f09b05725 ProductWeight: a bin takes its pass fraction of its maker's preference and its line the rest (ruling bin-share-by-floor)
Phi comes from CultMath's math.erf.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:04:08 +02:00
49853d3166 ProductBin: a binned product names its maker's line and a floor; the mint draws above that floor
FactionProductData slot 7 Bin (schema id unchanged, additive as slot 6). Mint derives a bin's
distributions from its line on every lot, truncated by NextGaussianAbove (64 tries, then the cutoff).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:58:51 +02:00
b19d6add89 aetheria-release map: product tiers follow ruling bin-share-by-floor (spec r2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:51:50 +02:00
47f834ea23 Pin all five pre-run-seed drops of the dice zone (the first pin kept three)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:39:52 +02:00
727c409659 Pin the dice of a zone without a run seed at their pre-run-seed values
A zone with no galaxy and a zone of a save written before run-seed (seed 0)
drop the items and scatter the mines they did at eb27ced1; values computed
at that base.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:33:11 +02:00
f23d3c6542 Drops roll on the run's seed: Zone.DropSeed feeds the loot and mine dice
DropSeed = CombatSeed ^ Mix(Galaxy.Seed), derived at construction. A zone with
no galaxy, or a restored seed of 0, keeps its dice bit for bit; CombatSeed
still roots shot, patrol and range-sample dice. RunSeedFixture builds a test
zone's galaxy through RunSave.Commit and the Continue constructor.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:30:50 +02:00
020dc58a81 The run's seed rides the save: SavedGame key 14, written by Capture, read by Continue
Galaxy.Seed on a continued run is the saved seed instead of zero. A save
written before run-seed decodes Seed 0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:29:17 +02:00
8779304499 Galaxy generation refuses seed 0: delete the clock fallback and the seed defaults
GalaxyStage.SeedFrom is the one minter of a run's seed; both generation
constructors now take progressCallback and seed without defaults and throw
ArgumentOutOfRangeException on zero instead of reading the clock.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:28:36 +02:00
6d10331c05 aetheria-release map: boss hull derived, product tiers, run seed (body facts BH/PT/RS, cut sequence; InfluenceScale moves to SavedGame key 15)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:24:54 +02:00
ac835a8ab2 Research: agent-built hull art prior art (LLM 3D workflows, ship generators, low-poly style, internal fit), for ruling hull-programmer-art-stopgap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:52:32 +02:00
9cdd885af0 Trojan swarms: tests for the period rule, stations, the unmoved zone and the save format
ZoneFingerprint pins a swarmless zone to digests computed at 67517f40 over seeds
1-6. Builds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:28:44 +02:00
92d6153c2e Trojan swarms: a belt on a copy of its host's rail whose rocks ride the rail's period
Rulings trojans-first, trojans-ride-host-period-with-stations. AsteroidBeltData
key 11 RidesRail; AsteroidBelt.Extent is the one belt extent rule (replaces
Zone's inline farthest-rock loop and the Math.Max at the well node); a swarm's
Periods are its rail's period. The generator draws swarms from a stream of its
own (TrojanStream), so no other draw in the zone moves. Tuning defaults are the
spec's proposals; the code default TrojanProbability 0 draws none. Existing
annulus tests skip swarm rails. Builds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:28:42 +02:00
477 changed files with 25425 additions and 9045 deletions

1
.gitignore vendored
View file

@ -95,7 +95,6 @@ Packages/packages-lock.json
GameData/run.cc
GameData/player.cc
GameData/RemovedMods/
Vault/Aetheria/.obsidian/plugins/copilot/data.json
# Agent scratch, sandboxed toolchain caches, and throwaway checkouts. These had
# grown to 47 untracked entries; the worktree must stay legible, so they never

2
.lfsconfig Normal file
View file

@ -0,0 +1,2 @@
[lfs]
url = https://eureka.gamecult.org/forge/GameCult/Aetheria.git/info/lfs

View file

@ -8,12 +8,12 @@ using UnityEditor.AddressableAssets.Settings;
using UnityEditor.Build.Reporting;
using Debug = UnityEngine.Debug;
// The one way to make a Win64 player directory. Owns what the directory contains: Aetheria.exe and its data,
// The one way to make a player directory, for Windows or Linux. Owns what the directory contains: the player and its data,
// GameData/Aetheria.cc, GameData/Narrative/**, GameData/Mods/** and ModTools/AetherDb. Manual builds, smokes and
// release cuts all call this and stage nothing themselves.
//
// Unity -batchmode -quit -projectPath <repo> -executeMethod AetheriaBuild.Build
// -aetheriaVersion <semver> -aetheriaOut <dir> -logFile <log>
// -aetheriaVersion <semver> -aetheriaOut <dir> [-aetheriaTarget win64|linux64] -logFile <log>
//
// AetherDb is published from tools/AetherDb, so the headless build's CULTLIB_ROOT / CULTMATH_ROOT (Directory.Build.props)
// must be set for dotnet when the defaults do not hold the pinned revisions.
@ -22,6 +22,14 @@ public static class AetheriaBuild
{
private static readonly Regex SemVer = new Regex(@"^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$");
// A target: the player Unity builds, its executable, and the runtime AetherDb is published for.
private static (BuildTarget target, string player, string runtime, string aetherDb) Target(string name) => name switch
{
"win64" => (BuildTarget.StandaloneWindows64, "Aetheria.exe", "win-x64", "AetherDb.exe"),
"linux64" => (BuildTarget.StandaloneLinux64, "Aetheria.x86_64", "linux-x64", "AetherDb"),
_ => throw new InvalidOperationException($"-aetheriaTarget {name}: expected win64 or linux64"),
};
public static void Build()
{
var step = "arguments";
@ -30,6 +38,7 @@ public static class AetheriaBuild
var version = Argument("-aetheriaVersion");
if (!SemVer.IsMatch(version)) throw new InvalidOperationException("-aetheriaVersion is not a semver");
var output = Path.GetFullPath(Argument("-aetheriaOut"));
var target = Target(Argument("-aetheriaTarget", "win64"));
var repoRoot = Directory.GetParent(UnityEngine.Application.dataPath).FullName;
step = "version";
@ -46,8 +55,8 @@ public static class AetheriaBuild
var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions
{
scenes = scenes,
locationPathName = Path.Combine(output, "Aetheria.exe"),
target = BuildTarget.StandaloneWindows64,
locationPathName = Path.Combine(output, target.player),
target = target.target,
options = BuildOptions.None,
});
if (report.summary.result != BuildResult.Succeeded || report.summary.totalErrors != 0)
@ -57,7 +66,7 @@ public static class AetheriaBuild
PlayerStaging.Stage(repoRoot, output);
step = "aetherdb";
Publish(repoRoot, Path.Combine(output, "ModTools"));
Publish(repoRoot, Path.Combine(output, "ModTools"), target.runtime, target.aetherDb);
step = "burst";
var burst = Path.Combine(output, "Aetheria_BurstDebugInformation_DoNotShip");
@ -74,11 +83,11 @@ public static class AetheriaBuild
EditorApplication.Exit(0);
}
private static void Publish(string repoRoot, string modTools)
private static void Publish(string repoRoot, string modTools, string runtime, string aetherDb)
{
var info = new ProcessStartInfo("dotnet")
{
Arguments = $"publish tools/AetherDb -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o \"{modTools}\"",
Arguments = $"publish tools/AetherDb -c Release -r {runtime} --self-contained -p:PublishSingleFile=true -o \"{modTools}\"",
WorkingDirectory = repoRoot,
UseShellExecute = false,
RedirectStandardOutput = true,
@ -92,15 +101,16 @@ public static class AetheriaBuild
if (process.ExitCode != 0)
throw new InvalidOperationException($"dotnet publish exited {process.ExitCode}:\n{text}\n{errors.Result}");
}
if (!File.Exists(Path.Combine(modTools, "AetherDb.exe")))
throw new InvalidOperationException("dotnet publish produced no ModTools/AetherDb.exe");
if (!File.Exists(Path.Combine(modTools, aetherDb)))
throw new InvalidOperationException($"dotnet publish produced no ModTools/{aetherDb}");
}
private static string Argument(string name)
private static string Argument(string name, string fallback = null)
{
var args = Environment.GetCommandLineArgs();
var at = Array.IndexOf(args, name);
if (at < 0 || at + 1 >= args.Length) throw new InvalidOperationException(name + " is required");
if (at < 0 || at + 1 >= args.Length)
return fallback ?? throw new InvalidOperationException(name + " is required");
return args[at + 1];
}
}

View file

@ -1,129 +0,0 @@
//
// Noise Shader Library for Unity - https://github.com/keijiro/NoiseShader
//
// Original work (webgl-noise) Copyright (C) 2011 Stefan Gustavson
// Translation and modification was made by Keijiro Takahashi.
//
// This shader is based on the webgl-noise GLSL shader. For further details
// of the original shader, please see the following description from the
// original source code.
//
//
// GLSL textureless classic 2D noise "cnoise",
// with an RSL-style periodic variant "pnoise".
// Author: Stefan Gustavson (stefan.gustavson@liu.se)
// Version: 2011-08-22
//
// Many thanks to Ian McEwan of Ashima Arts for the
// ideas for permutation and gradient selection.
//
// Copyright (c) 2011 Stefan Gustavson. All rights reserved.
// Distributed under the MIT license. See LICENSE file.
// https://github.com/ashima/webgl-noise
//
float4 mod(float4 x, float4 y)
{
return x - y * floor(x / y);
}
float4 mod289(float4 x)
{
return x - floor(x / 289.0) * 289.0;
}
float4 permute(float4 x)
{
return mod289(((x*34.0)+1.0)*x);
}
float4 taylorInvSqrt(float4 r)
{
return (float4)1.79284291400159 - r * 0.85373472095314;
}
float2 fade(float2 t) {
return t*t*t*(t*(t*6.0-15.0)+10.0);
}
// Classic Perlin noise
float cnoise(float2 P)
{
float4 Pi = floor(P.xyxy) + float4(0.0, 0.0, 1.0, 1.0);
float4 Pf = frac (P.xyxy) - float4(0.0, 0.0, 1.0, 1.0);
Pi = mod289(Pi); // To avoid truncation effects in permutation
float4 ix = Pi.xzxz;
float4 iy = Pi.yyww;
float4 fx = Pf.xzxz;
float4 fy = Pf.yyww;
float4 i = permute(permute(ix) + iy);
float4 gx = frac(i / 41.0) * 2.0 - 1.0 ;
float4 gy = abs(gx) - 0.5 ;
float4 tx = floor(gx + 0.5);
gx = gx - tx;
float2 g00 = float2(gx.x,gy.x);
float2 g10 = float2(gx.y,gy.y);
float2 g01 = float2(gx.z,gy.z);
float2 g11 = float2(gx.w,gy.w);
float4 norm = taylorInvSqrt(float4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11)));
g00 *= norm.x;
g01 *= norm.y;
g10 *= norm.z;
g11 *= norm.w;
float n00 = dot(g00, float2(fx.x, fy.x));
float n10 = dot(g10, float2(fx.y, fy.y));
float n01 = dot(g01, float2(fx.z, fy.z));
float n11 = dot(g11, float2(fx.w, fy.w));
float2 fade_xy = fade(Pf.xy);
float2 n_x = lerp(float2(n00, n01), float2(n10, n11), fade_xy.x);
float n_xy = lerp(n_x.x, n_x.y, fade_xy.y);
return 2.3 * n_xy;
}
// Classic Perlin noise, periodic variant
float pnoise(float2 P, float2 rep)
{
float4 Pi = floor(P.xyxy) + float4(0.0, 0.0, 1.0, 1.0);
float4 Pf = frac (P.xyxy) - float4(0.0, 0.0, 1.0, 1.0);
Pi = mod(Pi, rep.xyxy); // To create noise with explicit period
Pi = mod289(Pi); // To avoid truncation effects in permutation
float4 ix = Pi.xzxz;
float4 iy = Pi.yyww;
float4 fx = Pf.xzxz;
float4 fy = Pf.yyww;
float4 i = permute(permute(ix) + iy);
float4 gx = frac(i / 41.0) * 2.0 - 1.0 ;
float4 gy = abs(gx) - 0.5 ;
float4 tx = floor(gx + 0.5);
gx = gx - tx;
float2 g00 = float2(gx.x,gy.x);
float2 g10 = float2(gx.y,gy.y);
float2 g01 = float2(gx.z,gy.z);
float2 g11 = float2(gx.w,gy.w);
float4 norm = taylorInvSqrt(float4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11)));
g00 *= norm.x;
g01 *= norm.y;
g10 *= norm.z;
g11 *= norm.w;
float n00 = dot(g00, float2(fx.x, fy.x));
float n10 = dot(g10, float2(fx.y, fy.y));
float n01 = dot(g01, float2(fx.z, fy.z));
float n11 = dot(g11, float2(fx.w, fy.w));
float2 fade_xy = fade(Pf.xy);
float2 n_x = lerp(float2(n00, n01), float2(n10, n11), fade_xy.x);
float n_xy = lerp(n_x.x, n_x.y, fade_xy.y);
return 2.3 * n_xy;
}

View file

@ -1,5 +0,0 @@
fileFormatVersion: 2
guid: e1b0b5fd6a4e44923b40d856017562e1
ShaderImporter:
defaultTextures: []
userData:

View file

@ -1,194 +0,0 @@
//
// Noise Shader Library for Unity - https://github.com/keijiro/NoiseShader
//
// Original work (webgl-noise) Copyright (C) 2011 Stefan Gustavson
// Translation and modification was made by Keijiro Takahashi.
//
// This shader is based on the webgl-noise GLSL shader. For further details
// of the original shader, please see the following description from the
// original source code.
//
//
// GLSL textureless classic 3D noise "cnoise",
// with an RSL-style periodic variant "pnoise".
// Author: Stefan Gustavson (stefan.gustavson@liu.se)
// Version: 2011-10-11
//
// Many thanks to Ian McEwan of Ashima Arts for the
// ideas for permutation and gradient selection.
//
// Copyright (c) 2011 Stefan Gustavson. All rights reserved.
// Distributed under the MIT license. See LICENSE file.
// https://github.com/ashima/webgl-noise
//
float3 mod(float3 x, float3 y)
{
return x - y * floor(x / y);
}
float3 mod289(float3 x)
{
return x - floor(x / 289.0) * 289.0;
}
float4 mod289(float4 x)
{
return x - floor(x / 289.0) * 289.0;
}
float4 permute(float4 x)
{
return mod289(((x*34.0)+1.0)*x);
}
float4 taylorInvSqrt(float4 r)
{
return (float4)1.79284291400159 - r * 0.85373472095314;
}
float3 fade(float3 t) {
return t*t*t*(t*(t*6.0-15.0)+10.0);
}
// Classic Perlin noise
float cnoise(float3 P)
{
float3 Pi0 = floor(P); // Integer part for indexing
float3 Pi1 = Pi0 + (float3)1.0; // Integer part + 1
Pi0 = mod289(Pi0);
Pi1 = mod289(Pi1);
float3 Pf0 = frac(P); // Fractional part for interpolation
float3 Pf1 = Pf0 - (float3)1.0; // Fractional part - 1.0
float4 ix = float4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
float4 iy = float4(Pi0.y, Pi0.y, Pi1.y, Pi1.y);
float4 iz0 = (float4)Pi0.z;
float4 iz1 = (float4)Pi1.z;
float4 ixy = permute(permute(ix) + iy);
float4 ixy0 = permute(ixy + iz0);
float4 ixy1 = permute(ixy + iz1);
float4 gx0 = ixy0 / 7.0;
float4 gy0 = frac(floor(gx0) / 7.0) - 0.5;
gx0 = frac(gx0);
float4 gz0 = (float4)0.5 - abs(gx0) - abs(gy0);
float4 sz0 = step(gz0, (float4)0.0);
gx0 -= sz0 * (step((float4)0.0, gx0) - 0.5);
gy0 -= sz0 * (step((float4)0.0, gy0) - 0.5);
float4 gx1 = ixy1 / 7.0;
float4 gy1 = frac(floor(gx1) / 7.0) - 0.5;
gx1 = frac(gx1);
float4 gz1 = (float4)0.5 - abs(gx1) - abs(gy1);
float4 sz1 = step(gz1, (float4)0.0);
gx1 -= sz1 * (step((float4)0.0, gx1) - 0.5);
gy1 -= sz1 * (step((float4)0.0, gy1) - 0.5);
float3 g000 = float3(gx0.x,gy0.x,gz0.x);
float3 g100 = float3(gx0.y,gy0.y,gz0.y);
float3 g010 = float3(gx0.z,gy0.z,gz0.z);
float3 g110 = float3(gx0.w,gy0.w,gz0.w);
float3 g001 = float3(gx1.x,gy1.x,gz1.x);
float3 g101 = float3(gx1.y,gy1.y,gz1.y);
float3 g011 = float3(gx1.z,gy1.z,gz1.z);
float3 g111 = float3(gx1.w,gy1.w,gz1.w);
float4 norm0 = taylorInvSqrt(float4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
g000 *= norm0.x;
g010 *= norm0.y;
g100 *= norm0.z;
g110 *= norm0.w;
float4 norm1 = taylorInvSqrt(float4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
g001 *= norm1.x;
g011 *= norm1.y;
g101 *= norm1.z;
g111 *= norm1.w;
float n000 = dot(g000, Pf0);
float n100 = dot(g100, float3(Pf1.x, Pf0.y, Pf0.z));
float n010 = dot(g010, float3(Pf0.x, Pf1.y, Pf0.z));
float n110 = dot(g110, float3(Pf1.x, Pf1.y, Pf0.z));
float n001 = dot(g001, float3(Pf0.x, Pf0.y, Pf1.z));
float n101 = dot(g101, float3(Pf1.x, Pf0.y, Pf1.z));
float n011 = dot(g011, float3(Pf0.x, Pf1.y, Pf1.z));
float n111 = dot(g111, Pf1);
float3 fade_xyz = fade(Pf0);
float4 n_z = lerp(float4(n000, n100, n010, n110), float4(n001, n101, n011, n111), fade_xyz.z);
float2 n_yz = lerp(n_z.xy, n_z.zw, fade_xyz.y);
float n_xyz = lerp(n_yz.x, n_yz.y, fade_xyz.x);
return 2.2 * n_xyz;
}
// Classic Perlin noise, periodic variant
float pnoise(float3 P, float3 rep)
{
float3 Pi0 = mod(floor(P), rep); // Integer part, modulo period
float3 Pi1 = mod(Pi0 + (float3)1.0, rep); // Integer part + 1, mod period
Pi0 = mod289(Pi0);
Pi1 = mod289(Pi1);
float3 Pf0 = frac(P); // Fractional part for interpolation
float3 Pf1 = Pf0 - (float3)1.0; // Fractional part - 1.0
float4 ix = float4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
float4 iy = float4(Pi0.y, Pi0.y, Pi1.y, Pi1.y);
float4 iz0 = (float4)Pi0.z;
float4 iz1 = (float4)Pi1.z;
float4 ixy = permute(permute(ix) + iy);
float4 ixy0 = permute(ixy + iz0);
float4 ixy1 = permute(ixy + iz1);
float4 gx0 = ixy0 / 7.0;
float4 gy0 = frac(floor(gx0) / 7.0) - 0.5;
gx0 = frac(gx0);
float4 gz0 = (float4)0.5 - abs(gx0) - abs(gy0);
float4 sz0 = step(gz0, (float4)0.0);
gx0 -= sz0 * (step((float4)0.0, gx0) - 0.5);
gy0 -= sz0 * (step((float4)0.0, gy0) - 0.5);
float4 gx1 = ixy1 / 7.0;
float4 gy1 = frac(floor(gx1) / 7.0) - 0.5;
gx1 = frac(gx1);
float4 gz1 = (float4)0.5 - abs(gx1) - abs(gy1);
float4 sz1 = step(gz1, (float4)0.0);
gx1 -= sz1 * (step((float4)0.0, gx1) - 0.5);
gy1 -= sz1 * (step((float4)0.0, gy1) - 0.5);
float3 g000 = float3(gx0.x,gy0.x,gz0.x);
float3 g100 = float3(gx0.y,gy0.y,gz0.y);
float3 g010 = float3(gx0.z,gy0.z,gz0.z);
float3 g110 = float3(gx0.w,gy0.w,gz0.w);
float3 g001 = float3(gx1.x,gy1.x,gz1.x);
float3 g101 = float3(gx1.y,gy1.y,gz1.y);
float3 g011 = float3(gx1.z,gy1.z,gz1.z);
float3 g111 = float3(gx1.w,gy1.w,gz1.w);
float4 norm0 = taylorInvSqrt(float4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
g000 *= norm0.x;
g010 *= norm0.y;
g100 *= norm0.z;
g110 *= norm0.w;
float4 norm1 = taylorInvSqrt(float4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
g001 *= norm1.x;
g011 *= norm1.y;
g101 *= norm1.z;
g111 *= norm1.w;
float n000 = dot(g000, Pf0);
float n100 = dot(g100, float3(Pf1.x, Pf0.y, Pf0.z));
float n010 = dot(g010, float3(Pf0.x, Pf1.y, Pf0.z));
float n110 = dot(g110, float3(Pf1.x, Pf1.y, Pf0.z));
float n001 = dot(g001, float3(Pf0.x, Pf0.y, Pf1.z));
float n101 = dot(g101, float3(Pf1.x, Pf0.y, Pf1.z));
float n011 = dot(g011, float3(Pf0.x, Pf1.y, Pf1.z));
float n111 = dot(g111, Pf1);
float3 fade_xyz = fade(Pf0);
float4 n_z = lerp(float4(n000, n100, n010, n110), float4(n001, n101, n011, n111), fade_xyz.z);
float2 n_yz = lerp(n_z.xy, n_z.zw, fade_xyz.y);
float n_xyz = lerp(n_yz.x, n_yz.y, fade_xyz.x);
return 2.2 * n_xyz;
}

View file

@ -1,5 +0,0 @@
fileFormatVersion: 2
guid: 3ba1abd1cb44c47b68fbb577c6c4ba3e
ShaderImporter:
defaultTextures: []
userData:

View file

@ -1,114 +0,0 @@
//
// Noise Shader Library for Unity - https://github.com/keijiro/NoiseShader
//
// Original work (webgl-noise) Copyright (C) 2011 Ashima Arts.
// Translation and modification was made by Keijiro Takahashi.
//
// This shader is based on the webgl-noise GLSL shader. For further details
// of the original shader, please see the following description from the
// original source code.
//
//
// Description : Array and textureless GLSL 2D/3D/4D simplex
// noise functions.
// Author : Ian McEwan, Ashima Arts.
// Maintainer : ijm
// Lastmod : 20110822 (ijm)
// License : Copyright (C) 2011 Ashima Arts. All rights reserved.
// Distributed under the MIT License. See LICENSE file.
// https://github.com/ashima/webgl-noise
//
float3 mod289(float3 x)
{
return x - floor(x / 289.0) * 289.0;
}
float4 mod289(float4 x)
{
return x - floor(x / 289.0) * 289.0;
}
float4 permute(float4 x)
{
return mod289((x * 34.0 + 1.0) * x);
}
float4 taylorInvSqrt(float4 r)
{
return 1.79284291400159 - r * 0.85373472095314;
}
float snoise(float3 v)
{
const float2 C = float2(1.0 / 6.0, 1.0 / 3.0);
// First corner
float3 i = floor(v + dot(v, C.yyy));
float3 x0 = v - i + dot(i, C.xxx);
// Other corners
float3 g = step(x0.yzx, x0.xyz);
float3 l = 1.0 - g;
float3 i1 = min(g.xyz, l.zxy);
float3 i2 = max(g.xyz, l.zxy);
// x1 = x0 - i1 + 1.0 * C.xxx;
// x2 = x0 - i2 + 2.0 * C.xxx;
// x3 = x0 - 1.0 + 3.0 * C.xxx;
float3 x1 = x0 - i1 + C.xxx;
float3 x2 = x0 - i2 + C.yyy;
float3 x3 = x0 - 0.5;
// Permutations
i = mod289(i); // Avoid truncation effects in permutation
float4 p =
permute(permute(permute(i.z + float4(0.0, i1.z, i2.z, 1.0))
+ i.y + float4(0.0, i1.y, i2.y, 1.0))
+ i.x + float4(0.0, i1.x, i2.x, 1.0));
// Gradients: 7x7 points over a square, mapped onto an octahedron.
// The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294)
float4 j = p - 49.0 * floor(p / 49.0); // mod(p,7*7)
float4 x_ = floor(j / 7.0);
float4 y_ = floor(j - 7.0 * x_); // mod(j,N)
float4 x = (x_ * 2.0 + 0.5) / 7.0 - 1.0;
float4 y = (y_ * 2.0 + 0.5) / 7.0 - 1.0;
float4 h = 1.0 - abs(x) - abs(y);
float4 b0 = float4(x.xy, y.xy);
float4 b1 = float4(x.zw, y.zw);
//float4 s0 = float4(lessThan(b0, 0.0)) * 2.0 - 1.0;
//float4 s1 = float4(lessThan(b1, 0.0)) * 2.0 - 1.0;
float4 s0 = floor(b0) * 2.0 + 1.0;
float4 s1 = floor(b1) * 2.0 + 1.0;
float4 sh = -step(h, 0.0);
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
float3 g0 = float3(a0.xy, h.x);
float3 g1 = float3(a0.zw, h.y);
float3 g2 = float3(a1.xy, h.z);
float3 g3 = float3(a1.zw, h.w);
// Normalise gradients
float4 norm = taylorInvSqrt(float4(dot(g0, g0), dot(g1, g1), dot(g2, g2), dot(g3, g3)));
g0 *= norm.x;
g1 *= norm.y;
g2 *= norm.z;
g3 *= norm.w;
// Mix final noise value
float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m = m * m;
m = m * m;
float4 px = float4(dot(x0, g0), dot(x1, g1), dot(x2, g2), dot(x3, g3));
return 42.0 * dot(m, px);
}

View file

@ -1,9 +0,0 @@
fileFormatVersion: 2
guid: b577a94f58a1398409bfcfe9cd5dbf99
timeCreated: 1448788105
licenseType: Pro
ShaderImporter:
defaultTextures: []
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,118 +0,0 @@
//
// Noise Shader Library for Unity - https://github.com/keijiro/NoiseShader
//
// Original work (webgl-noise) Copyright (C) 2011 Ashima Arts.
// Translation and modification was made by Keijiro Takahashi.
//
// This shader is based on the webgl-noise GLSL shader. For further details
// of the original shader, please see the following description from the
// original source code.
//
//
// Description : Array and textureless GLSL 2D/3D/4D simplex
// noise functions.
// Author : Ian McEwan, Ashima Arts.
// Maintainer : ijm
// Lastmod : 20110822 (ijm)
// License : Copyright (C) 2011 Ashima Arts. All rights reserved.
// Distributed under the MIT License. See LICENSE file.
// https://github.com/ashima/webgl-noise
//
float3 mod289(float3 x)
{
return x - floor(x / 289.0) * 289.0;
}
float4 mod289(float4 x)
{
return x - floor(x / 289.0) * 289.0;
}
float4 permute(float4 x)
{
return mod289((x * 34.0 + 1.0) * x);
}
float4 taylorInvSqrt(float4 r)
{
return 1.79284291400159 - r * 0.85373472095314;
}
float3 snoise_grad(float3 v)
{
const float2 C = float2(1.0 / 6.0, 1.0 / 3.0);
// First corner
float3 i = floor(v + dot(v, C.yyy));
float3 x0 = v - i + dot(i, C.xxx);
// Other corners
float3 g = step(x0.yzx, x0.xyz);
float3 l = 1.0 - g;
float3 i1 = min(g.xyz, l.zxy);
float3 i2 = max(g.xyz, l.zxy);
// x1 = x0 - i1 + 1.0 * C.xxx;
// x2 = x0 - i2 + 2.0 * C.xxx;
// x3 = x0 - 1.0 + 3.0 * C.xxx;
float3 x1 = x0 - i1 + C.xxx;
float3 x2 = x0 - i2 + C.yyy;
float3 x3 = x0 - 0.5;
// Permutations
i = mod289(i); // Avoid truncation effects in permutation
float4 p =
permute(permute(permute(i.z + float4(0.0, i1.z, i2.z, 1.0))
+ i.y + float4(0.0, i1.y, i2.y, 1.0))
+ i.x + float4(0.0, i1.x, i2.x, 1.0));
// Gradients: 7x7 points over a square, mapped onto an octahedron.
// The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294)
float4 j = p - 49.0 * floor(p / 49.0); // mod(p,7*7)
float4 x_ = floor(j / 7.0);
float4 y_ = floor(j - 7.0 * x_); // mod(j,N)
float4 x = (x_ * 2.0 + 0.5) / 7.0 - 1.0;
float4 y = (y_ * 2.0 + 0.5) / 7.0 - 1.0;
float4 h = 1.0 - abs(x) - abs(y);
float4 b0 = float4(x.xy, y.xy);
float4 b1 = float4(x.zw, y.zw);
//float4 s0 = float4(lessThan(b0, 0.0)) * 2.0 - 1.0;
//float4 s1 = float4(lessThan(b1, 0.0)) * 2.0 - 1.0;
float4 s0 = floor(b0) * 2.0 + 1.0;
float4 s1 = floor(b1) * 2.0 + 1.0;
float4 sh = -step(h, 0.0);
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
float3 g0 = float3(a0.xy, h.x);
float3 g1 = float3(a0.zw, h.y);
float3 g2 = float3(a1.xy, h.z);
float3 g3 = float3(a1.zw, h.w);
// Normalise gradients
float4 norm = taylorInvSqrt(float4(dot(g0, g0), dot(g1, g1), dot(g2, g2), dot(g3, g3)));
g0 *= norm.x;
g1 *= norm.y;
g2 *= norm.z;
g3 *= norm.w;
// Compute gradient of noise function at P
float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
float4 m2 = m * m;
float4 m3 = m2 * m;
float4 m4 = m2 * m2;
float3 grad =
-6.0 * m3.x * x0 * dot(x0, g0) + m4.x * g0 +
-6.0 * m3.y * x1 * dot(x1, g1) + m4.y * g1 +
-6.0 * m3.z * x2 * dot(x2, g2) + m4.z * g2 +
-6.0 * m3.w * x3 * dot(x3, g3) + m4.w * g3;
return 42.0 * grad;
}

View file

@ -1,9 +0,0 @@
fileFormatVersion: 2
guid: a7111eafc2463224faacf7be3bddb9a4
timeCreated: 1449292284
licenseType: Pro
ShaderImporter:
defaultTextures: []
userData:
assetBundleName:
assetBundleVariant:

BIN
Assets/Resources/Settings.asset (Stored with Git LFS)

Binary file not shown.

View file

@ -122,7 +122,7 @@ namespace Aetheria.EditorTools
var cache = AetheriaStores.Open(Path.Combine(_work, "belts.cc"), Path.Combine(_work, "belts-run.cc"), catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.FlushAsync().Wait();
var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { });
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default)
{

View file

@ -62,7 +62,7 @@ public static class CapabilityPresentationSmoke
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(cache, ledger, settings, _ => { });
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Smoke", Owner = null }, null);
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Smoke Hull"));
var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings());

View file

@ -115,7 +115,7 @@ namespace Aetheria.EditorTools
cache.Upsert(new SimpleCommodityData { Name = "Wiring Ore", Shape = oneCell, MaxStack = 100 });
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
var items = new ItemManager(cache, ledger, settings, _ => { });
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
// A sun and a planet on a moving rail round it, both near the bodies below: the field under a floater changes with the
// drawn time, so a presenter that samples it at the step's time, drops it, or reads lead 0 for the height draws a different y.
@ -159,7 +159,7 @@ namespace Aetheria.EditorTools
MaxRange = 1e6f, FireRange = 1e6f, CommitTime = 1e5f, ArrivalTime = 2e5f
};
var projectile = new GameObject("Wiring Round").AddComponent<Projectile>();
projectile.Launch(zone, shot, new Vector3(0, 0, 0));
projectile.Launch(null, zone, shot, new Vector3(0, 0, 0));
yield return null;
zone.Update(.5f);

View file

@ -124,7 +124,7 @@ public static class FloatingItemsDrawnSmoke
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(cache, ledger, settings, _ => { });
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), null, null);
EquippableItem Make(string name, int lot) => new EquippableItem
{

View file

@ -106,7 +106,7 @@ namespace Aetheria.EditorTools
var cache = AetheriaStores.Open(Path.Combine(work, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.FlushAsync().Wait();
var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
return new Zone(items, new PlanetSettings { Flow = flow }, new ZonePack { Time = time }, new GalaxyZone { Name = "Parity", Owner = null }, null);
}

View file

@ -154,7 +154,7 @@ namespace Aetheria.EditorTools
var cache = AetheriaStores.Open(Path.Combine(work, "catalog.cc"), Path.Combine(work, "run.cc"), catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.FlushAsync().Wait();
var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
var positions = new List<Vector2> { new Vector2(-130f, 85f), new Vector2(610f, -240f), new Vector2(670f, -270f), new Vector2(1700f, 300f) };
var masses = new List<float> { 14152f, 273.9f, 29.2f, 0f };
var parents = new List<int> { -1, 0, 1, -1 };

View file

@ -52,7 +52,7 @@ public static class MapBeltPerf
Planets = { new CultRecordRef<BodyData>(sunKey), new CultRecordRef<BodyData>(beltKey) },
Radius = 5000f, Mass = 10000f, Time = 0
};
var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
var zone = new Zone(items, new PlanetSettings
{
BodyRadius = new ExponentialCurve(), WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f },

View file

@ -115,7 +115,7 @@ public static class MapPresenterSmoke
};
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(cache, ledger, settings.GameplaySettings, _ => { });
var items = new ItemManager(1u, cache, ledger, settings.GameplaySettings, _ => { });
var zone = new Zone(items, planetSettings, pack, new GalaxyZone { Name = "Smoke", Owner = null }, null);
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Smoke Hull"));
var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings());

View file

@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.UI;
// A render of the New Game screen as the game builds it: the real Main Menu scene's panel prototype, docked and filled
// by ScenarioPicker.Dock and ScenarioPicker.Show with every scenario group, on a plain dark background (the cloud
// backdrop is a play-mode renderer). Writes <project>/Temp/scenario-picker.png at 1920x1080, or the path after -pngOut (Unity empties Temp on exit).
//
// Unity -batchmode -projectPath <project> -executeMethod ScenarioPickerCapture.Run [-pngOut <file>] -logFile <log>
//
// Needs a GPU device; do not pass -nographics or -quit: Run exits the Editor itself, 0 on success and 1 on any failure.
public static class ScenarioPickerCapture
{
private const int Width = 1920, Height = 1080;
public static void Run()
{
try
{
Capture();
EditorApplication.Exit(0);
}
catch (Exception exception)
{
Debug.LogException(exception);
EditorApplication.Exit(1);
}
}
// The menu's two panes built the way MainMenu.Start builds them, against the Main Menu scene.
internal static (MainMenu menu, PropertiesPanel list, PropertiesPanel detail) Build()
{
EditorSceneManager.OpenScene("Assets/Scenes/Main Menu.unity", OpenSceneMode.Single);
var menu = UnityEngine.Object.FindObjectsByType<MainMenu>(FindObjectsInactive.Include, FindObjectsSortMode.None)[0];
menu.PanelPrototype.gameObject.SetActive(false);
var list = menu.PanelPrototype.Instantiate<PropertiesPanel>();
var detail = menu.PanelPrototype.Instantiate<PropertiesPanel>();
// Edit mode runs no Awake, which is what activates each row a panel adds.
list.Awake();
detail.Awake();
ScenarioPicker.Dock(detail, list);
return (menu, list, detail);
}
internal static List<(string label, Scenario[] scenarios)> AllGroups() => new List<(string label, Scenario[] scenarios)>
{
("modes", Scenarios.Modes), ("development", Scenarios.Development), ("test arenas", Scenarios.Tests)
};
private static void Capture()
{
var (menu, list, detail) = Build();
menu.Dialog.gameObject.SetActive(false);
list.Title.text = MainMenu.TitleSubtitle("new game", "scenarios");
ScenarioPicker.Show(list, detail, AllGroups(), _ => { }, () => { }, menu.Dialog.Confirm);
list.RefreshValues();
detail.RefreshValues();
var canvas = menu.GetComponentInParent<Canvas>(true).rootCanvas;
var target = new RenderTexture(Width, Height, 24, RenderTextureFormat.ARGB32);
var cameraObject = new GameObject("ScenarioPickerCapture camera");
var camera = cameraObject.AddComponent<Camera>();
camera.orthographic = true;
camera.orthographicSize = 5;
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(.04f, .05f, .08f);
camera.cullingMask = 1 << LayerMask.NameToLayer("UI");
camera.targetTexture = target;
canvas.renderMode = RenderMode.ScreenSpaceCamera;
canvas.worldCamera = camera;
canvas.planeDistance = 10;
canvas.gameObject.SetActive(true);
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform) list.transform);
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform) detail.transform);
foreach (var text in list.transform.GetComponentsInChildren<TMPro.TMP_Text>())
text.ForceMeshUpdate();
Canvas.ForceUpdateCanvases();
list.Content.GetComponentInParent<ScrollRect>().onValueChanged.Invoke(Vector2.zero);
camera.Render();
var previous = RenderTexture.active;
RenderTexture.active = target;
var read = new Texture2D(Width, Height, TextureFormat.RGBA32, false);
read.ReadPixels(new Rect(0, 0, Width, Height), 0, 0);
RenderTexture.active = previous;
var background = read.GetPixel(0, 0);
var differing = 0;
foreach (var pixel in read.GetPixels32())
if (Mathf.Abs(pixel.r / 255f - background.r) + Mathf.Abs(pixel.g / 255f - background.g) + Mathf.Abs(pixel.b / 255f - background.b) > .05f)
differing++;
if (differing < Width * Height / 100)
throw new InvalidOperationException($"The capture is uniform: only {differing} pixels differ from the background.");
var path = Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Temp", "scenario-picker.png"));
var args = Environment.GetCommandLineArgs();
var outIndex = Array.IndexOf(args, "-pngOut");
if (outIndex >= 0) path = Path.GetFullPath(args[outIndex + 1]);
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllBytes(path, read.EncodeToPNG());
Debug.Log($"[ScenarioPickerCapture] {path} ({differing} of {Width * Height} pixels differ from the background)");
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2e79f280179150e4080250c45e07cd69

View file

@ -0,0 +1,265 @@
using System;
using System.Collections.Generic;
using System.Linq;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
// The picker's selection and keyboard rules, driven through the events the game delivers: Move and Cancel reach the
// focused Launch button through ExecuteEvents, as the Input System UI module sends them, and a click is Button.onClick.
//
// Unity -batchmode -projectPath <project> -executeMethod ScenarioPickerVerify.Run -logFile <log>
//
// Exits the Editor itself, 0 when every rule holds and 1 on the first that does not.
public static class ScenarioPickerVerify
{
private sealed class Fake : Scenario
{
private readonly uint _seed;
private readonly bool _ambient;
public Fake(string name, uint seed = 0, bool ambient = true) { Name = name; _seed = seed; _ambient = ambient; }
public override string Name { get; }
public override string Id => Name.ToLowerInvariant();
public override string Brief => $"brief of {Name}";
public override uint Seed => _seed;
public override bool Ambient => _ambient;
public override Galaxy Generate(GalaxyStage stage) => throw new NotSupportedException();
public override void Stage(ScenarioStage stage) => throw new NotSupportedException();
}
private sealed class Screen
{
public ScenarioPicker Picker;
public PropertiesPanel List, Detail;
public Button[] Rows;
public Button Back, Launch;
public readonly List<Scenario> Launched = new List<Scenario>();
public int Backs;
public Scenario[] A, B, C;
}
public static void Run()
{
try
{
SelectionFollowsClicksAndArrows();
ArrowsStopAtTheEnds();
LeftAndRightAreIgnored();
LaunchIsOnePathAndFocusClears();
CancelAndBackAreOnePath();
DetailPaneFollowsSelection();
OneGroupHasNoSectionLabel();
LaunchesOnceHoweverItIsReached();
ArrowingThroughTheRealListKeepsTheSelectionVisible();
Debug.Log("[ScenarioPickerVerify] ok");
EditorApplication.Exit(0);
}
catch (Exception exception)
{
Debug.LogException(exception);
EditorApplication.Exit(1);
}
}
private static void Require(bool condition, string what)
{
if (!condition) throw new InvalidOperationException(what);
}
private static Screen Build(bool oneGroup = false, bool real = false)
{
var (menu, list, detail) = ScenarioPickerCapture.Build();
// Build opens the scene afresh, which destroys the previous EventSystem.
var events = new GameObject("verify EventSystem").AddComponent<EventSystem>();
// Edit mode never calls OnEnable, which is what registers an EventSystem as current.
typeof(EventSystem).GetMethod("OnEnable", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).Invoke(events, null);
var screen = new Screen
{
List = list, Detail = detail,
A = new Scenario[] { new Fake("Alpha", 7), new Fake("Beta") },
B = new Scenario[] { new Fake("Gamma", ambient: false) },
C = new Scenario[] { new Fake("Delta"), new Fake("Echo") }
};
var groups = real ? ScenarioPickerCapture.AllGroups() : oneGroup
? new List<(string label, Scenario[] scenarios)> { ("modes", screen.A) }
: new List<(string label, Scenario[] scenarios)> { ("modes", screen.A), ("development", screen.B), ("test arenas", screen.C) };
// The menu builds a panel while it is inactive and shows it afterwards; the picker must find its scroll all the same.
if (real) list.gameObject.SetActive(false);
screen.Picker = ScenarioPicker.Show(list, detail, groups, screen.Launched.Add, () => screen.Backs++, menu.Dialog.Confirm);
var buttons = list.GetComponentsInChildren<PropertyButton>(false).Where(button => !button.transform.IsChildOf(detail.transform)).Select(button => button.Button).ToArray();
screen.Rows = buttons.Take(buttons.Length - 1).ToArray();
screen.Back = buttons.Last();
screen.Launch = detail.GetComponentsInChildren<Button>(false).Single();
if (real) { list.gameObject.SetActive(true); Layout(menu, list, detail); }
return screen;
}
// A live 1920x1080 layout, as the game has one a frame after Show; edit mode has no frames, so it is forced here.
private static void Layout(MainMenu menu, PropertiesPanel list, PropertiesPanel detail)
{
menu.Dialog.gameObject.SetActive(false);
var canvas = menu.GetComponentInParent<Canvas>(true).rootCanvas;
var camera = new GameObject("verify camera").AddComponent<Camera>();
camera.targetTexture = new RenderTexture(1920, 1080, 24);
canvas.renderMode = RenderMode.ScreenSpaceCamera;
canvas.worldCamera = camera;
canvas.planeDistance = 10;
canvas.gameObject.SetActive(true);
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform) canvas.transform);
foreach (var text in list.GetComponentsInChildren<TMP_Text>()) text.ForceMeshUpdate();
Canvas.ForceUpdateCanvases();
Scroll(list).onValueChanged.Invoke(Vector2.zero);
}
private static ScrollRect Scroll(PropertiesPanel list) => list.Content.GetComponentInParent<ScrollRect>();
// Wholly inside the viewport, in world space, and not hidden by FitViewport's CanvasGroup.
private static bool Shown(Button row, ScrollRect scroll)
{
var view = new Vector3[4];
((RectTransform) scroll.viewport).GetWorldCorners(view);
var rect = new Vector3[4];
((RectTransform) row.transform).GetWorldCorners(rect);
var alpha = 1f;
for (var t = row.transform; t != null && t != scroll.content; t = t.parent)
if (t.TryGetComponent<CanvasGroup>(out var group)) alpha = Mathf.Min(alpha, group.alpha);
return rect[0].y >= view[0].y - .01f && rect[1].y <= view[1].y + .01f && alpha == 1;
}
private static void Move(Screen screen, MoveDirection direction) =>
ExecuteEvents.Execute(screen.Launch.gameObject, new AxisEventData(EventSystem.current) { moveDir = direction }, ExecuteEvents.moveHandler);
private static void Cancel(Screen screen) =>
ExecuteEvents.Execute(screen.Launch.gameObject, new BaseEventData(EventSystem.current), ExecuteEvents.cancelHandler);
private static string Selected(Screen screen) => screen.Picker.Selected.Name;
private static void SelectionFollowsClicksAndArrows()
{
var screen = Build();
Require(screen.Rows.Length == 5, $"Expected 5 scenario rows, found {screen.Rows.Length}.");
Require(Selected(screen) == "Alpha", "The first scenario is not selected on Show.");
Require(EventSystem.current.currentSelectedGameObject == screen.Launch.gameObject, "Launch is not focused on Show.");
Move(screen, MoveDirection.Down);
Require(Selected(screen) == "Beta", "Down did not select the next row.");
Move(screen, MoveDirection.Down);
Require(Selected(screen) == "Gamma", "Down did not cross into the next group.");
Move(screen, MoveDirection.Up);
Require(Selected(screen) == "Beta", "Up did not select the previous row.");
screen.Rows[3].onClick.Invoke();
Require(Selected(screen) == "Delta", "Clicking a name did not select it.");
Require(screen.Launched.Count == 0, "Clicking a name launched it.");
Require(EventSystem.current.currentSelectedGameObject == screen.Launch.gameObject, "A click on a name took focus off Launch.");
Require(screen.Rows.All(row => row.navigation.mode == Navigation.Mode.None) && screen.Launch.navigation.mode == Navigation.Mode.None
&& screen.Back.navigation.mode == Navigation.Mode.None, "A picker button still takes Unity navigation.");
var accent = screen.Rows[3].targetGraphic.color;
Require(screen.Rows.Where((row, i) => i != 3).All(row => row.targetGraphic.color != accent), "Only the selected row is drawn in the accent.");
Move(screen, MoveDirection.Up);
Require(screen.Rows[2].targetGraphic.color == accent && screen.Rows[3].targetGraphic.color != accent, "The accent did not follow the selection.");
}
private static void ArrowsStopAtTheEnds()
{
var screen = Build();
Move(screen, MoveDirection.Up);
Require(Selected(screen) == "Alpha", "Up wrapped off the first row.");
for (var i = 0; i < 10; i++) Move(screen, MoveDirection.Down);
Require(Selected(screen) == "Echo", "Down wrapped or stopped short of the last row.");
}
private static void LeftAndRightAreIgnored()
{
var screen = Build();
Move(screen, MoveDirection.Down);
Move(screen, MoveDirection.Left);
Move(screen, MoveDirection.Right);
Require(Selected(screen) == "Beta", "Left or Right moved the selection.");
}
private static void LaunchIsOnePathAndFocusClears()
{
var screen = Build();
Move(screen, MoveDirection.Down);
screen.Launch.onClick.Invoke();
Require(screen.Launched.Count == 1 && screen.Launched[0] == screen.A[1], "Launch did not launch the selected scenario once.");
Require(EventSystem.current.currentSelectedGameObject == null, "Focus survived Launch, so a second Enter could launch again.");
Require(screen.Backs == 0, "Launch went back.");
}
private static void CancelAndBackAreOnePath()
{
var screen = Build();
Cancel(screen);
Require(screen.Backs == 1 && screen.Launched.Count == 0, "Cancel did not go back exactly once.");
Require(EventSystem.current.currentSelectedGameObject == null, "Focus survived Cancel.");
screen = Build();
screen.Back.onClick.Invoke();
Require(screen.Backs == 1 && screen.Launched.Count == 0, "The Back button did not go back exactly once.");
Require(EventSystem.current.currentSelectedGameObject == null, "Focus survived Back.");
}
private static string DetailText(Screen screen)
{
screen.Detail.RefreshValues();
return string.Join("|", screen.Detail.GetComponentsInChildren<TextMeshProUGUI>(false).Select(text => text.text));
}
private static void DetailPaneFollowsSelection()
{
var screen = Build();
var first = DetailText(screen);
Require(first.Contains("brief of Alpha") && first.Contains("7") && first.Contains("on"), $"Detail does not describe Alpha: {first}");
Require(first.Contains("modes") && first.Contains("Alpha"), "Detail heading is not the group and name.");
Move(screen, MoveDirection.Down);
Move(screen, MoveDirection.Down);
var third = DetailText(screen);
Require(third.Contains("brief of Gamma") && third.Contains("new each launch") && third.Contains("off"), $"Detail does not describe Gamma: {third}");
Require(third.Contains("development") && !third.Contains("Alpha"), "Detail heading did not follow the selection.");
Require(screen.Detail.GetComponentsInChildren<Button>(false).Single() == screen.Launch, "Selecting rebuilt the Launch button.");
}
private static void OneGroupHasNoSectionLabel()
{
var many = Build();
var one = Build(oneGroup: true);
Require(many.List.Children == 5 + 3 + 2 + 1, $"Three groups should list 5 rows, 3 section labels, 2 spacers and Back, not {many.List.Children} children.");
Require(one.List.Children == 2 + 1, $"One group should list its 2 rows and Back, not {one.List.Children} children.");
}
private static void LaunchesOnceHoweverItIsReached()
{
var screen = Build();
screen.Launch.onClick.Invoke();
screen.Launch.onClick.Invoke();
ExecuteEvents.Execute(screen.Launch.gameObject, new BaseEventData(EventSystem.current), ExecuteEvents.submitHandler);
Require(screen.Launched.Count == 1, $"Launch ran {screen.Launched.Count} times after a click, a second click and Submit.");
Require(screen.Launch.navigation.mode == Navigation.Mode.None && Scroll(screen.List).verticalScrollbar.navigation.mode == Navigation.Mode.None,
"The list scrollbar or Launch takes Unity navigation.");
}
// The real scenario groups overflow the list's viewport; every row the arrows reach must end wholly visible.
private static void ArrowingThroughTheRealListKeepsTheSelectionVisible()
{
var screen = Build(real: true);
var scroll = Scroll(screen.List);
var content = (RectTransform) scroll.content;
var viewport = (RectTransform) scroll.viewport;
Require(content.rect.height > viewport.rect.height + 1, $"The real list ({content.rect.height}) does not overflow its viewport ({viewport.rect.height}); the scroll rules are unmeasured.");
Require(Shown(screen.Rows[0], scroll), "The first row is not shown on Show.");
for (var i = 1; i < screen.Rows.Length; i++)
{
Move(screen, MoveDirection.Down);
Canvas.ForceUpdateCanvases();
Require(Shown(screen.Rows[i], scroll), $"Down to row {i} ({Selected(screen)}) left it hidden or cut off.");
}
for (var i = screen.Rows.Length - 2; i >= 0; i--)
{
Move(screen, MoveDirection.Up);
Canvas.ForceUpdateCanvases();
Require(Shown(screen.Rows[i], scroll), $"Up to row {i} ({Selected(screen)}) left it hidden or cut off.");
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7ca0b5db32f745a43a8fdc67dfac2996

View file

@ -75,7 +75,7 @@ public class ActionBarSlot : MonoBehaviour
this,
entity.Equipment[savedActionBarGearBinding.EquipmentIndex],
entity.Equipment[savedActionBarGearBinding.EquipmentIndex]
.Behaviors[savedActionBarGearBinding.BehaviorIndex] as IActivatedBehavior),
.Behaviors[savedActionBarGearBinding.BehaviorIndex] as ActivatedBehavior),
SavedActionBarWeaponGroupBinding savedActionBarWeaponGroupBinding =>
new ActionBarWeaponGroupBinding(entity, this, savedActionBarWeaponGroupBinding.Group),
_ => null
@ -117,7 +117,7 @@ public class ActionBarConsumableBinding : ActionBarBinding
public override void Activate()
{
Entity.TryActivateConsumable(Target);
Entity.Command.Use(Target);
}
public override void Deactivate()
@ -136,11 +136,11 @@ public class ActionBarConsumableBinding : ActionBarBinding
public class ActionBarGearBinding : ActionBarBinding
{
public EquippedItem Item { get; }
public IActivatedBehavior Behavior { get; }
public ActivatedBehavior Behavior { get; }
public bool Active;
public ActionBarGearBinding(Entity entity, ActionBarSlot slot, EquippedItem item, IActivatedBehavior behavior) : base(entity, slot)
public ActionBarGearBinding(Entity entity, ActionBarSlot slot, EquippedItem item, ActivatedBehavior behavior) : base(entity, slot)
{
Item = item;
var data = Entity.ItemManager.GetData(item.EquippableItem);
@ -155,16 +155,17 @@ public class ActionBarGearBinding : ActionBarBinding
else Slot.Icon.texture = ActionGameManager.Instance.Settings.GetIcon(data.HardpointType).texture;
}
// The press and the let-go are each one trigger edge, applied at the next step; Active is the slot's display.
public override void Activate()
{
Active = true;
Behavior.Activate();
Entity.Command.Pull(Behavior);
}
public override void Deactivate()
{
Active = false;
Behavior.Deactivate();
Entity.Command.Release(Behavior);
}
public override void Update()
@ -190,7 +191,7 @@ public class ActionBarWeaponGroupBinding : ActionBarBinding
{
foreach (var weapon in Entity.WeaponGroups[Group].weapons)
{
weapon.Activate();
Entity.Command.Pull(weapon);
}
}
@ -198,7 +199,7 @@ public class ActionBarWeaponGroupBinding : ActionBarBinding
{
foreach (var weapon in Entity.WeaponGroups[Group].weapons)
{
weapon.Deactivate();
Entity.Command.Release(weapon);
}
}

View file

@ -1,4 +1,4 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
/* 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/. */
@ -201,6 +201,10 @@ public class ActionGameManager : MonoBehaviour
private float3 _viewDirection;
// The player's face-the-aim state (Left Shift held, Caps Lock latched); Ship.Turn is its only output.
private readonly HelmInput _helm = new HelmInput();
// Seconds a held turn key takes to reach full turn rate.
public float KeyboardTurnRampTime = .4f;
// Whether the turn axis was last driven by keys; kept while released so the ramp down still applies.
private bool _turnDigital;
private TextMeshProUGUI _faceAimMark;
private (HardpointData[] hardpoints, Transform[] barrels, PlaceUIElementWorldspace crosshair)[] _articulationGroups;
// The outgoing progress indicators on the player's target: one per lock weapon (its Lock, shown only on a hostile target)
@ -224,6 +228,10 @@ public class ActionGameManager : MonoBehaviour
private bool _menuShown;
private List<ActionBarSlot> _actionBarSlots = new List<ActionBarSlot>();
private List<InputAction> _actionBarActions = new List<InputAction>();
// Each action bar slot's control path, index for index with _actionBarSlots.
private List<string> _actionBarPaths = new List<string>();
// Ships whose action bar has been set up: the default weapon-group binding runs once per ship, then the bar is the player's.
private HashSet<Entity> _actionBarSetUp = new HashSet<Entity>();
private float _hitMarkerTime;
public AetheriaInput Input { get; private set; }
@ -248,7 +256,7 @@ public class ActionGameManager : MonoBehaviour
public ItemManager ItemManager { get; private set; }
public Zone Zone { get; private set; }
public static SimClock Clock { get; } = new SimClock(1f / 60f);
public static SimClock Clock { get; } = SimClock.ForGame();
private bool _zoneEntered;
private readonly (float2 direction, string name)[] _directions = {
@ -294,7 +302,11 @@ public class ActionGameManager : MonoBehaviour
ConsoleController.MessageReceiver = this;
// A new run mints into an empty ledger; the saved run's stays in the store until the new run has started.
ItemManager = new ItemManager(CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache),
// A new run is reseeded by RunStart; Continue's item draws come from the saved run's seed.
ItemManager = new ItemManager(
CurrentGalaxy == null ? RunStart.NoRunSeed
: RunStart.ItemSeed(CurrentGalaxy, PendingScenario != null ? RunStart.ArenaStep : RunStart.ContinueStep),
CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache),
Settings.GameplaySettings, Debug.Log);
ZoneRenderer.ItemManager = ItemManager;
@ -392,53 +404,34 @@ public class ActionGameManager : MonoBehaviour
ActionBar.gameObject.SetActive(!_uiHidden);
};
Input.Player.OverrideShutdown.performed += context =>
{
CurrentEntity.OverrideShutdown = !CurrentEntity.OverrideShutdown;
};
Input.Player.Ping.performed += context =>
{
CurrentEntity.Sensor?.Ping();
};
Input.Player.ToggleHeatsinks.performed += context =>
{
CurrentEntity.HeatsinksEnabled = !CurrentEntity.HeatsinksEnabled;
// TODO: SFX: Success/Fail
};
Input.Player.ToggleShield.performed += context =>
{
if (CurrentEntity.Shield != null)
{
CurrentEntity.Shield.Item.Enabled.Value = !CurrentEntity.Shield.Item.Enabled.Value;
// TODO: SFX: Success/Fail
}
};
// The switches and the ping are presses on the entity's command, flipped at the next step.
Input.Player.OverrideShutdown.performed += context => CurrentEntity.Command.ToggleOverrideShutdown();
Input.Player.Ping.performed += context => CurrentEntity.Command.Ping();
// TODO: SFX: Success/Fail
Input.Player.ToggleHeatsinks.performed += context => CurrentEntity.Command.ToggleHeatsinks();
// TODO: SFX: Success/Fail
Input.Player.ToggleShield.performed += context => CurrentEntity.Command.ToggleShield();
// Flips the player's own stance on the current target between hostile and neutral. Going
// neutral mid-fight safes weapons (Weapon.StanceAllowsFire); an unmarked/undetected target
// reads as non-hostile here, so the first press declares hostile.
Input.Player.ToggleStance.performed += context =>
{
var target = CurrentEntity.Target.Value.Entity;
if (target == null) return;
CurrentEntity.SetIff(target, !CurrentEntity.IsHostileTo(target));
};
// reads as non-hostile, so the first press declares hostile. The flip reads the target and its stance when applied,
// after the step's target presses, so two presses in a step cancel.
Input.Player.ToggleStance.performed += context => CurrentEntity.Command.ToggleStance();
#region Targeting
// 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();
// The reticle, nearest, next and previous presses each command a verb; the applier chooses through Entity at the
// next step (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.Command.Verb(TargetVerb.UnderReticle);
Input.Player.TargetNearest.performed += context => CurrentEntity.Command.Verb(TargetVerb.NearestEnemy);
Input.Player.TargetNext.performed += context => CurrentEntity.Command.Verb(TargetVerb.Next);
Input.Player.TargetPrevious.performed += context => CurrentEntity.Command.Verb(TargetVerb.Previous);
// Cut 2 (docs/fire-control-cut.md): steps the aim through the target's schematic rows -- every mount this ship
// has located, empty or not, minus the ones it saw destroyed. Decides nothing: the entity owns the cycle.
Input.Player.CycleTargetItem.performed += context => CurrentEntity.CycleAim();
// has located, empty or not, minus the ones it saw destroyed. Decides nothing: the entity owns the cycle, applied at
// the next step.
Input.Player.CycleTargetItem.performed += context => CurrentEntity.Command.CycleAim();
#endregion
@ -452,6 +445,7 @@ public class ActionGameManager : MonoBehaviour
var slot = Instantiate(ActionBarSlot, ActionBar);
slot.Binding = null;
_actionBarSlots.Add(slot);
_actionBarPaths.Add(controlPath);
action.started += context => slot.Binding?.Activate();
action.canceled += context => slot.Binding?.Deactivate();
@ -477,7 +471,7 @@ public class ActionGameManager : MonoBehaviour
switch (dragAction)
{
case EquippedItemDragObject equippedItemDragAction:
var trigger = equippedItemDragAction.EquippedItem.GetBehavior<IActivatedBehavior>();
var trigger = equippedItemDragAction.EquippedItem.GetBehavior<ActivatedBehavior>();
if (trigger == null) return false;
slot.Binding = new ActionBarGearBinding(CurrentEntity, slot, equippedItemDragAction.EquippedItem, trigger);
return true;
@ -510,16 +504,6 @@ public class ActionGameManager : MonoBehaviour
StartGame();
//if (!PlayerSettings.InputSettings.ActionBarInputs.Any())
{
var newbinds = Enumerable.Range(0, 64)//CurrentEntity.WeaponGroups
.Zip(
bindings,
(i, slot) =>
slot.Binding = new ActionBarWeaponGroupBinding(CurrentEntity, slot, i)
);
}
ConsoleController.AddCommand("revealzones",
_ =>
{
@ -593,6 +577,45 @@ public class ActionGameManager : MonoBehaviour
ItemManager.Random.NextFloat2Direction() * ItemManager.Random.NextFloat(50, 500);
Zone.Admit(turret, piloted: false);
});
// "fly Djinni": a freshly fitted ship of the named hull beside the current one, and you in it. The ship you leave
// stays where it is, unpiloted.
ConsoleController.AddCommand("fly",
args =>
{
var hullName = string.Join(" ", args);
if (_currentEntity == null || _currentEntity.Parent != null)
{
ConsoleController.Instance.AppendLogLine("Refused: undock first");
return;
}
var generator = new LoadoutGenerator(ref ItemManager.Random, ItemManager, CurrentGalaxy, Zone.GalaxyZone, null, 2);
var pack = generator.GenerateShipLoadout(data => string.Equals(data.Name, hullName, StringComparison.InvariantCultureIgnoreCase));
Ship ship;
if (pack != null) ship = (Ship) EntitySerializer.Unpack(ItemManager, Zone, pack);
else
{
// An unoffered hull is drawn by nobody; it reaches a ship only through a Loadout (ruling off-market-is-general).
var failures = new List<string>();
var loadout = ItemManager.ItemData.GetAll<Loadout>().FirstOrDefault(l =>
ItemManager.ItemData.Get(l.Hull) is { HullType: HullType.Ship } hull &&
string.Equals(hull.Name, hullName, StringComparison.InvariantCultureIgnoreCase));
ship = loadout == null ? null : Loadouts.Materialize(ItemManager, Zone, loadout, generator.ChooseProduct, failures) as Ship;
if (ship == null)
{
ConsoleController.Instance.AppendLogLine(failures.Count > 0
? $"Cannot build \"{hullName}\": {string.Join("; ", failures)}"
: $"No ship hull named \"{hullName}\" that a maker offers or a Loadout fits");
return;
}
}
ship.IsPlayerShip = true;
ship.Direction = _currentEntity.Direction;
ship.Position.xz = _currentEntity.Position.xz + _currentEntity.Direction.Rotate(ItemRotation.Clockwise) * 30;
ship.Velocity = _currentEntity.Velocity;
Zone.Admit(ship, piloted: false);
UnbindEntity(); // or the ship you leave still owns your death
BindToEntity(ship);
});
//Temporary, or not
ConsoleController.AddCommand("tow", _ => TowShip());
@ -609,19 +632,9 @@ public class ActionGameManager : MonoBehaviour
console.AppendLogLine("usage: iff hostile|neutral|clear");
return;
}
switch (mode)
{
case "hostile":
_currentEntity.SetIff(target, true);
break;
case "neutral":
_currentEntity.SetIff(target, false);
break;
case "clear":
_currentEntity.SetIff(target, null);
break;
}
console.AppendLogLine($"{target.Name}: {(_currentEntity.IsHostileTo(target) ? "hostile" : "neutral")}");
bool? hostile = mode == "hostile" ? true : mode == "neutral" ? false : (bool?) null;
_currentEntity.Command.Iff(target, hostile);
console.AppendLogLine($"{target.Name}: {mode} queued; it applies at the next step");
});
// Editor only: capturepreset "<name>" [replace] writes the piloted ship as a catalog preset. A multi-word name must
@ -642,9 +655,9 @@ public class ActionGameManager : MonoBehaviour
{
var written = Loadouts.Commit(CatalogPath, Loadouts.Capture(ItemManager, ship, name), replace);
console.AppendLogLine(written
? $"Preset '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " +
"Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured presets."
: $"Preset '{name}' changed on disk since load; nothing written");
? $"Loadout '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " +
"Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured loadouts."
: $"Loadout '{name}' changed on disk since load; nothing written");
}
catch (InvalidOperationException e)
{
@ -1020,6 +1033,35 @@ public class ActionGameManager : MonoBehaviour
if (DebugInfoText != null) DebugInfoText.text = "";
}
// The weapon group a slot's input fires by default: mouse left, right and middle fire groups 1-3, and number key N fires group N.
private static int DefaultWeaponGroup(string controlPath) => controlPath switch
{
"<Mouse>/leftButton" => 0,
"<Mouse>/rightButton" => 1,
"<Mouse>/middleButton" => 2,
_ when controlPath.StartsWith("<Keyboard>/") && int.TryParse(controlPath.Substring("<Keyboard>/".Length), out var n) && n >= 1 => n - 1,
_ => -1
};
// A new ship's first action bar: each slot's default weapon group when the group has weapons. Gear and consumables the
// player placed are kept; a slot holding another ship's weapon group is rebound to this ship, or cleared when its default
// group here is empty.
private void AutoBindWeaponGroups()
{
if (CurrentEntity?.WeaponGroups == null) return;
for (var i = 0; i < _actionBarSlots.Count; i++)
{
var slot = _actionBarSlots[i];
if (slot.Binding != null && slot.Binding is not ActionBarWeaponGroupBinding) continue;
var group = DefaultWeaponGroup(_actionBarPaths[i]);
if (group < 0) continue;
if (group < CurrentEntity.WeaponGroups.Length && CurrentEntity.WeaponGroups[group].weapons.Any())
slot.Binding = new ActionBarWeaponGroupBinding(CurrentEntity, slot, group);
else if (slot.Binding is ActionBarWeaponGroupBinding)
slot.Binding = null;
}
}
private void BindToEntity(Entity entity)
{
if (!ZoneRenderer.EntityInstances.ContainsKey(entity))
@ -1070,6 +1112,10 @@ public class ActionGameManager : MonoBehaviour
crosshair.gameObject.SetActive(false);
foreach (var group in _articulationGroups)
group.crosshair.gameObject.SetActive(true);
// A loaded run's bar is restored after this and replaces it, so a saved setup is never overwritten.
if (_actionBarSetUp.Add(CurrentEntity))
AutoBindWeaponGroups();
_shipSubscriptions.Add(CurrentEntity.Target.Subscribe(target =>
{
@ -1320,7 +1366,7 @@ public class ActionGameManager : MonoBehaviour
var sensitivity = PlayerSettings.InputSettings.Sensitivity;
_entityYawPitch = float2(_entityYawPitch.x + look.x * sensitivity.x, clamp(_entityYawPitch.y + look.y * sensitivity.y, -.45f * PI, .45f * PI));
_viewDirection = mul(float3(0, 0, 1), CultMath.float3x3.Euler(float3(_entityYawPitch.yx, 0), RotationOrder.YXZ));
CurrentEntity.Aim = _viewDirection;
CurrentEntity.Command.Aim = _viewDirection;
HeatstrokePost.weight = saturate(unlerp(0, Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, CurrentEntity.Heatstroke));
var severeHeatstrokeLerp = saturate(unlerp(Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, 1, CurrentEntity.Heatstroke));
SevereHeatstrokePost.weight =
@ -1330,9 +1376,12 @@ public class ActionGameManager : MonoBehaviour
_faceAimMark.enabled = _helm.Latched;
if(CurrentEntity is Ship ship)
{
ship.MovementDirection = Input.Player.Move.ReadValue<Vector2>().ToCultMath();
ship.Command.Movement = Input.Player.Move.ReadValue<Vector2>().ToCultMath();
_helm.Held = Input.Player.FaceAim.IsPressed();
ship.Turn = _helm.Demand(ship, _viewDirection.xz, Input.Player.Turn.ReadValue<float>());
if (Input.Player.Turn.activeControl is { } turnControl)
_turnDigital = turnControl.device is UnityEngine.InputSystem.Keyboard;
var turnAxis = _helm.TurnAxis(Input.Player.Turn.ReadValue<float>(), _turnDigital, Time.deltaTime, KeyboardTurnRampTime);
ship.Command.Turn = _helm.Demand(_viewDirection.xz, ship.BandedTurn(turnAxis));
}
UpdateMusic();
@ -1347,7 +1396,7 @@ public class ActionGameManager : MonoBehaviour
TargetShieldsFill.fillAmount = target.Shield == null ? 0 : lerp(.25f, .75f, target.Shield.Progress);
}
CurrentEntity.TractorCommand = Input.Player.TractorBeam.ReadValue<float>();
CurrentEntity.Command.Tractor = Input.Player.TractorBeam.ReadValue<float>();
}
}
// One clock steps the zone, paused or not: a paused frame feeds it no real time.
@ -1460,11 +1509,18 @@ public class ActionGameManager : MonoBehaviour
else if (target.Entity != null)
TargetIndicator.Target = DrawAhead.Position(target.Entity, lead).ToUnity();
var distance = length(ViewDot.Target.ToCultMath() - ownDrawn);
// The aim's pitch, applied to each barrel's planar bearing: the crosshair sits where the guns point at the view's
// elevation, not on the ship's level plane (which is the horizon).
var aim = CurrentEntity.Aim;
var aimLevel = length(aim.xz);
foreach (var (_, barrels, crosshair) in _articulationGroups)
{
var averagePosition = Vector3.zero;
foreach (var barrel in barrels)
averagePosition += barrel.position + barrel.forward * distance;
{
var bearing = new Vector2(barrel.forward.x, barrel.forward.z).normalized;
averagePosition += barrel.position + new Vector3(bearing.x * aimLevel, aim.y, bearing.y * aimLevel) * distance;
}
averagePosition /= barrels.Length;
crosshair.Target = averagePosition;
}

View file

@ -408,6 +408,9 @@ public class EntityInstance : MonoBehaviour
var aimDistance = !Entity.Target.Value.IsNone
? max(Entity.TargetRange, Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance)
: 10000;
// The aim's pitch: a planar firing bearing is drawn at the same elevation as the view, so a group aims where the
// player looks, not at the horizon of the ship's plane.
var aimLevel = length(Entity.Aim.xz);
LookAtPoint.position = transform.position + Entity.Aim.ToUnity() * aimDistance;
foreach (var group in _groupAims)
{
@ -415,7 +418,7 @@ public class EntityInstance : MonoBehaviour
if (weapon == null) { group.Value.position = LookAtPoint.position; continue; }
var barrel = Barrels.TryGetValue(weapon.Item.Hardpoint, out var barrels) ? barrels[0].position : transform.position;
var direction = FireControl.Solution(weapon, Entity, Entity.Target.Value).Direction;
group.Value.position = barrel + new Vector3(direction.x, 0, direction.y) * aimDistance;
group.Value.position = barrel + new Vector3(direction.x * aimLevel, Entity.Aim.y, direction.y * aimLevel) * aimDistance;
}
if (_influenceInstance)
_influenceInstance.position = new Vector3(drawn.x, 0, drawn.z);

View file

@ -24,19 +24,16 @@ public class Projectile : MonoBehaviour
private CultMath.float3 _barrel;
private ShotOutcome _known;
private bool _alive;
private CompositeDisposable _binding;
private ProjectileManager _owner;
// Takes the shot as the sim holds it, the zone whose sim time it is drawn at, and where the barrel is as this round is first seen.
public void Launch(Zone zone, PendingShot shot, Vector3 barrel)
public void Launch(ProjectileManager owner, Zone zone, PendingShot shot, Vector3 barrel)
{
_owner = owner;
_zone = zone;
_shot = shot;
_barrel = new CultMath.float3(barrel.x, barrel.y, barrel.z);
_known = null;
_binding?.Dispose();
_binding = new CompositeDisposable(
zone.ShotCommitted.Where(o => o.ShotId == shot.ShotId).Subscribe(Committed),
zone.ShotResolved.Where(o => o.ShotId == shot.ShotId).Subscribe(Resolve));
_alive = true;
transform.forward = new Vector3(shot.TravelDirection.x, 0, shot.TravelDirection.y);
Draw();
@ -56,14 +53,14 @@ public class Projectile : MonoBehaviour
}
// The sim's commit: the record it holds now (a contact burst's arrival is shortened here) and what it decided.
private void Committed(ShotOutcome outcome)
internal void Committed(ShotOutcome outcome)
{
if (_zone.TryGetShot(outcome.ShotId, out var record)) _shot = record;
_known = outcome;
}
// The simulation's verdict. A round that hit or burst is drawn at its end and stops; a miss keeps flying.
private void Resolve(ShotOutcome outcome)
internal void Resolve(ShotOutcome outcome)
{
if (!_alive) return;
_known = outcome;
@ -78,16 +75,14 @@ public class Projectile : MonoBehaviour
private void Finish()
{
_alive = false;
_binding?.Dispose();
_binding = null;
_owner?.Forget(_shot.ShotId);
StartCoroutine(Fade());
}
void OnDisable()
{
_alive = false;
_binding?.Dispose();
_binding = null;
_owner?.Forget(_shot.ShotId);
}
IEnumerator Fade()

View file

@ -1,17 +1,46 @@
using UnityEngine;
using System.Collections.Generic;
using UniRx;
using UnityEngine;
public class ProjectileManager : InstantWeaponEffectManager
{
public Prototype ProjectilePrototype;
// The rounds this manager has in the air by shot id, told of the sim's commit and verdict by one subscription per zone: a
// gun that fires hundreds of rounds in a step costs the dictionary an entry per round and nothing else (no subscription,
// closure or disposable per round).
private readonly Dictionary<int, Projectile> _live = new Dictionary<int, Projectile>();
private readonly CompositeDisposable _listening = new CompositeDisposable();
private Zone _zone;
// The round is drawn by DrawAhead from what the simulation publishes, so there is nothing to aim, jitter
// or inherit here: spread is priced by FireControl, and the sim's round inherits nothing from its shooter.
// A round the sim fired inside the step (rounds per step) is first seen where its shooter's barrel was at that moment:
// the barrel as it is now, carried back along the shooter's motion for the time since.
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var zone = source.Entity.Zone;
if (!zone.TryGetShot(shotId, out var shot)) return;
Listen(zone);
var age = (float) (zone.ExactTime - shot.FireTime);
var velocity = source.Entity.Velocity;
var p = ProjectilePrototype.Instantiate<Projectile>();
p.Launch(zone, shot, source.GetBarrel(item.Hardpoint).position);
_live[shotId] = p;
p.Launch(this, zone, shot, source.GetBarrel(item.Hardpoint).position - new Vector3(velocity.x, 0, velocity.y) * age);
p.Trail.Clear();
}
internal void Forget(int shotId) => _live.Remove(shotId);
private void Listen(Zone zone)
{
if (_zone == zone) return;
_listening.Clear();
_live.Clear();
_zone = zone;
zone.ShotCommitted.Subscribe(outcome => { if (_live.TryGetValue(outcome.ShotId, out var p)) p.Committed(outcome); }).AddTo(_listening);
zone.ShotResolved.Subscribe(outcome => { if (_live.TryGetValue(outcome.ShotId, out var p)) p.Resolve(outcome); }).AddTo(_listening);
}
void OnDestroy() => _listening.Dispose();
}

View file

@ -7,17 +7,19 @@ 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(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb) };
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(ProvinceData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData), typeof(RecipeData), typeof(OutsideMarketData) };
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) };
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };
// Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given.
// Throws when a [CultGlobal] type routed to the catalog has no record. The one exemption is a writable catalog with
// no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens.
// A catalog's station hull with no Kind is refused, unless the caller gives kindlessStation: the tool that repairs a catalog
// is told the hull's name and goes on.
// The registry is the process-wide one unless the caller scopes it (the test host does, so its own document types never
// become catalog globals); the global check above is judged against whichever registry opened the cache.
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false,
CultDocumentRegistry registry = null)
CultDocumentRegistry registry = null, Action<string> kindlessStation = null)
{
// A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here.
if (!catalogWritable && !File.Exists(catalogPath))
@ -41,6 +43,8 @@ public static class AetheriaStores
foreach (var data in cache.GetAll<EquippableItemData>()) CultRecordRefs.Validate(data);
foreach (var data in cache.GetAll<ConsumableItemData>()) CultRecordRefs.Validate(data);
VerseGrammarValidation.ValidateAll(cache.GetAll<VerseVerb>());
ValidateBins(cache);
StationKinds.Validate(cache, kindlessStation);
return cache;
}
catch
@ -49,6 +53,24 @@ public static class AetheriaStores
throw;
}
}
// The mint and the weighting trust a bin: they never check its Floor or its Line. So the catalog read refuses one whose Floor is
// not a finite number (it would weigh NaN and poison every draw of its design), whose Line names no product the catalog holds, or
// whose Line is itself a bin (a bin is of a line, never of a bin). The refusals name the field and echo no value.
private static void ValidateBins(CultCache cache)
{
foreach (var product in cache.GetAll<FactionProductData>())
{
if (product.Bin == null) continue;
if (!float.IsFinite(product.Bin.Floor))
throw new InvalidOperationException("A product bin's Floor is not a finite number.");
var line = cache.Get(product.Bin.Line);
if (line == null)
throw new InvalidOperationException("A product bin's Line names no product this catalog holds.");
if (line.Bin != null)
throw new InvalidOperationException("A product bin's Line names another bin; a bin is of a line.");
}
}
}
public static class CultRecordRefs

View file

@ -23,6 +23,9 @@ public class Agent
private VelocityLimit _velocityLimit;
public AgentTask Task { get; set; }
// What this pilot's side knows of other ships. A ship outside a flight is a side of one: a view over itself, until
// Flight.Join sets the flight's.
public PilotView View { get; internal set; }
// Zone-scoped, from 1: the ordinal of this pilot's own dice, set by Zone.CreateAgent as it admits the pilot (0 for
// an agent built outside a zone's admission).
public uint Id { get; set; }
@ -36,6 +39,7 @@ public class Agent
public Agent(Ship ship, RoleDoctrine doctrine)
{
Ship = ship;
View = new PilotView(ship);
Doctrine = doctrine;
ItemManager = Ship.ItemManager;
Settings = Ship.ItemManager.GameplaySettings;
@ -55,9 +59,9 @@ public class Agent
public void Update(float delta)
{
// The pilot tick writes Turn every tick, in every state: a state that pilots overwrites this zero with its
// The pilot tick commands Turn every tick, in every state: a state that pilots overwrites this zero with its
// demand, and one that does not leaves the hull holding course instead of inheriting the last state's rate.
Ship.Turn = 0;
Ship.Command.Turn = TurnCommand.OfRate(0);
VentOnTrack();
_currentState.Update(delta);
foreach (var transition in _currentState.Transitions)
@ -84,7 +88,7 @@ public class Agent
{
if (Ship.HasActiveConsumable(kind)) return;
var consumable = Ship.FirstCarriedConsumable(kind);
if (consumable != null) Ship.TryActivateConsumable(consumable);
if (consumable != null) Ship.Command.Use(consumable);
}
public void Accelerate(float2 targetVelocity, bool noTurn = false)
@ -95,18 +99,18 @@ public class Agent
// If Delta V is above the threshold, direct the ship towards the delta and use only main thrusters
if (!noTurn && deltaVMag > FORWARD_DELTA_THRESHOLD)
{
Ship.Turn = Steering.Toward(Ship, deltaVDirection);
Ship.MovementDirection = float2(0, pow(dot(Ship.Direction, deltaVDirection), 2));
Ship.Command.Turn = TurnCommand.Toward(deltaVDirection);
Ship.Command.Movement = float2(0, pow(dot(Ship.Direction, deltaVDirection), 2));
}
// If Delta V is low, use all thrusters and leave Turn to the caller (MoveTo already pointed it at the target)
else if(deltaVMag > THRUST_DELTA_THRESHOLD)
{
var right = Ship.Direction.Rotate(ItemRotation.Clockwise);
Ship.MovementDirection = float2(dot(right, deltaVDirection), dot(Ship.Direction, deltaVDirection));
Ship.Command.Movement = float2(dot(right, deltaVDirection), dot(Ship.Direction, deltaVDirection));
}
else
{
Ship.MovementDirection = float2.zero;
Ship.Command.Movement = float2.zero;
}
}
}

View file

@ -3,15 +3,17 @@ using System.Collections.Generic;
using System.Linq;
using CultMath;
using static CultMath.math;
using ContactId = PilotView.ContactId;
// One flight per (zone, faction) of piloted ships; unaffiliated ships form one flight of their own. The flight alone
// One flight per (zone, side) of piloted ships; a side is a faction, or one unaffiliated ship. The flight alone
// decides what its members do: which hostile each engages (Ship.Target) and what each does otherwise (Agent.Task).
// The agent graph executes those two and decides nothing. The faction's doctrine (FactionDoctrine) holds the numbers.
//
// The picture is the union of the members' VisibleEnemies. A track passes through phases the flight owns, runtime only:
// None, Hailing (grace running, one hail spoken), Complied (slow enough at the grace's end: followed, not engaged) and
// Engaged. A member still fires only at what its own VisibleEntities hold (FireControl), so the shared picture never
// lets it fire at what it cannot see.
// The flight reads other ships only through its side's PilotView (ruling observation-sensors-only): it holds a contact
// as a handle and never as the ship. The picture is the view's: the union of what the members' sensors hold hostile, never a member.
// A track passes through phases the flight owns, runtime only: None, Hailing (grace running, one hail spoken), Complied
// (slow enough at the grace's end: followed, not engaged) and Engaged. A member still fires only at what its own
// sensors hold (fire control), so the shared picture never lets it fire at what it cannot see.
public class Flight
{
public enum Phase { None, Hailing, Complied, Engaged }
@ -31,7 +33,7 @@ public class Flight
private readonly FactionDoctrine _doctrine;
private readonly GameplaySettings _settings;
private readonly List<Member> _members = new List<Member>();
private readonly Dictionary<Entity, Track> _tracks = new Dictionary<Entity, Track>();
private readonly Dictionary<ContactId, Track> _tracks = new Dictionary<ContactId, Track>();
private double _time;
// No doctrine fights as FactionDoctrine.Default; a doctrine that leaves a role unauthored gets that role's default.
@ -44,58 +46,64 @@ public class Flight
}
public RoleDoctrine Combatant { get; }
// What this side knows of other ships; every member's Agent.View.
public PilotView View { get; } = new PilotView();
public int Count => _members.Count;
public bool Contains(Agent agent) => _members.Any(m => m.Agent == agent);
public Phase PhaseOf(Entity track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None;
public Phase PhaseOf(ContactId track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None;
// The task a member holds when the flight has nothing to follow: its patrol circuit, or the task a scenario stated.
public void Join(Agent agent, AgentTask baseline)
{
_members.Add(new Member { Agent = agent, Baseline = baseline });
View.Add(agent.Ship);
agent.View = View;
agent.Task = baseline;
}
public void Leave(Agent agent) => _members.RemoveAll(m => m.Agent == agent);
public void Leave(Agent agent)
{
if (_members.RemoveAll(m => m.Agent == agent) > 0) View.Remove(agent.Ship);
}
public void Update(float dt)
{
_time += dt;
var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct().ToList();
var picture = View.Picture();
foreach (var gone in _tracks.Keys.Where(t => !picture.Contains(t)).ToList()) _tracks.Remove(gone);
foreach (var entity in picture)
foreach (var contact in picture)
{
if (!_tracks.TryGetValue(entity, out var track)) _tracks[entity] = track = new Track();
Advance(entity, track);
if (!_tracks.TryGetValue(contact, out var track)) _tracks[contact] = track = new Track();
Advance(contact, track);
}
foreach (var member in _members) Order(member, picture);
}
// A grudge is a hostile IFF override that any member holds on the track.
private bool Grudged(Entity entity) => _members.Any(m => m.Agent.Ship.IffOverride(entity) == true);
private bool Grudged(ContactId contact) => _members.Any(m => View.IffOverride(m.Agent.Ship, contact) == true);
private bool Engageable(Entity entity, bool grudge)
private bool Engageable(ContactId contact, bool grudge)
{
switch (_doctrine.EngageOn)
{
case EngageOn.Never: return false;
case EngageOn.Detection: return true;
case EngageOn.Identified:
return grudge || _members.Any(m => m.Agent.Ship.EntityInfoGathered.TryGetValue(entity, out var info) &&
info >= _settings.TargetGearInfoThreshold);
return grudge || _members.Any(m => View.Info(m.Agent.Ship, contact) >= _settings.TargetGearInfoThreshold);
case EngageOn.Provoked: return grudge;
case EngageOn.Trespass: return grudge || _members.Any(m => m.Agent.Ship.BelievesTrespassing(entity));
case EngageOn.Trespass: return grudge || _members.Any(m => View.BelievesTrespassing(m.Agent.Ship, contact));
default: return false;
}
}
private void Advance(Entity entity, Track track)
private void Advance(ContactId contact, Track track)
{
if (track.Phase == Phase.Engaged) return;
var grudge = Grudged(entity);
if (!Engageable(entity, grudge))
var grudge = Grudged(contact);
if (!Engageable(contact, grudge))
{
track.Phase = Phase.None;
return;
@ -115,56 +123,59 @@ public class Flight
{
track.Phase = Phase.Hailing;
track.Start = _time;
if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.SetMessage(_doctrine.Hail);
if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(contact)?.Agent.Ship.Command.Hail(_doctrine.Hail);
}
else track.Phase = Phase.Engaged;
break;
case Phase.Hailing:
if (_time >= track.Start + _doctrine.Grace)
track.Phase = _doctrine.ComplySpeed > 0 && length(entity.Velocity) < _doctrine.ComplySpeed
track.Phase = _doctrine.ComplySpeed > 0 && length(View.SideTrack(contact).Value.Velocity) < _doctrine.ComplySpeed
? Phase.Complied
: Phase.Engaged;
break;
case Phase.Complied:
if (length(entity.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged;
if (length(View.SideTrack(contact).Value.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged;
break;
}
}
private Member Nearest(Entity entity) =>
_members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - entity.Position.xz)).FirstOrDefault();
private Member Nearest(ContactId contact)
{
var at = View.SideTrack(contact).Value.Position.xz;
return _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - at)).FirstOrDefault();
}
private void Order(Member member, List<Entity> picture)
private void Order(Member member, List<ContactId> picture)
{
var ship = member.Agent.Ship;
var seen = ship.VisibleEnemies;
// Engage: keep the current target while it stays engaged, else the nearest engaged track this member can see.
var engaged = picture.Where(e => _tracks[e].Phase == Phase.Engaged && seen.Contains(e)).ToList();
var engaged = picture.Where(c => _tracks[c].Phase == Phase.Engaged && View.Sees(ship, c)).ToList();
if (engaged.Count > 0)
{
var current = ship.Target.Value.Entity;
var pick = current != null && engaged.Contains(current)
? current
: engaged.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First();
Aim(ship, pick);
var current = View.AppliedTargetOf(ship);
var pick = current != null && engaged.Contains(current.Value)
? current.Value
: engaged.OrderBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First();
View.Designate(ship, pick);
return;
}
// No engaged track in this member's own sensors: it steers on the flight's picture, a bearing and never targeting
// data (ruling shared-track-bearing-only). That is the hailed and the complied, and an engaged track only a flightmate sees.
Aim(ship, null);
View.Designate(ship, null);
// Overrides are per member: a flightmate's hostile track is not one this member follows after it has made it friendly.
var held = picture.Where(e => _tracks[e].Phase != Phase.None && ship.IffOverride(e) != false).ToList();
var held = picture.Where(c => _tracks[c].Phase != Phase.None && View.IffOverride(ship, c) != false).ToList();
if (held.Count > 0)
{
// An engaged track outranks a hailed one; the held anchor is kept only while nothing outranks it.
var anyEngaged = held.Any(e => _tracks[e].Phase == Phase.Engaged);
var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor)
&& (!anyEngaged || _tracks[follow.Anchor].Phase == Phase.Engaged)
? follow.Anchor
: held.OrderBy(e => _tracks[e].Phase == Phase.Engaged ? 0 : 1)
.ThenBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First();
var anyEngaged = held.Any(c => _tracks[c].Phase == Phase.Engaged);
var heldAnchor = (member.Agent.Task as FollowTask)?.Anchor;
var anchor = heldAnchor != null && held.Contains(heldAnchor.Value)
&& (!anyEngaged || _tracks[heldAnchor.Value].Phase == Phase.Engaged)
? heldAnchor.Value
: held.OrderBy(c => _tracks[c].Phase == Phase.Engaged ? 0 : 1)
.ThenBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First();
if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor)
member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) };
}
@ -172,12 +183,6 @@ public class Flight
member.Agent.Task = member.Baseline;
}
private static void Aim(Ship ship, Entity target)
{
var wanted = target == null ? TargetRef.None : new TargetRef(target);
if (!ship.Target.Value.Equals(wanted)) ship.SetTarget(wanted);
}
private float Standoff(Ship ship)
{
if (Combatant.HoldStandoff > 0) return Combatant.HoldStandoff;

View file

@ -22,9 +22,9 @@ public class Minion : Agent
var combatState = new CombatState(this);
_rootState.AddTransition(combatState,
() => Ship.Target.Value.Entity != null, null, true, _rootState);
() => View.TargetOf(Ship) != null, null, true, _rootState);
combatState.AddTransition(_rootState,
() => Ship.Target.Value.Entity == null, null, true, _rootState);
() => View.TargetOf(Ship) == null, null, true, _rootState);
}
}

View file

@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using CultMath;
// What one side's brain may know of other ships. A side is a faction, or one unaffiliated ship (brain-one-per-side-r2);
// its view reads only that side's members: their sensors (VisibleEntities, VisibleEnemies, EntityInfoGathered), their
// IFF overrides and reveal books (ruling observation-sensors-only). A brain never holds another ship: it holds a
// ContactId or a RevealId, which only the view that issued it can resolve, so there is no path from a handle to the
// ship's truth that does not pass the sensors.
//
// A contact's Track (position, velocity, facing) exists only while a member tracks it (its VisibleEntities holds it):
// within a zone step nothing moves during the agent phase, so the track equals what the Entity would answer. The
// fire-control readouts call FireControl's own functions with the resolved subject, so the HUD, the player and the
// brains price one model (A-one-sim). Own-ship reads stay the brain's own.
//
// The view enforces its own rules; it does not trust its callers. Every per-member read answers for a member the view
// holds and nothing else, and every per-contact fire-control readout (Intercept, WeaponReveals, Designated, Refuses,
// Fires) answers only while that member itself tracks the contact: a sidemate's track gives a member no aim, no reveals
// and no shot (per-shooter-fire-control). Only Flight builds a view and sets it on its pilots, and the side-of-one view an
// Agent starts with is its own; the constructor and the setter are internal.
public sealed class PilotView
{
private readonly List<Ship> _members = new List<Ship>();
private static Func<ContactId, Entity> _openContact;
private static Func<RevealId, EquippedItem> _openReveal;
internal PilotView(params Ship[] members) => _members.AddRange(members);
private bool Holds(Ship member) => _members.Contains(member);
private bool Tracks(Ship member, Entity subject) => Holds(member) && subject != null && member.VisibleEntities.Contains(subject);
internal void Add(Ship member) => _members.Add(member);
internal void Remove(Ship member) => _members.Remove(member);
// A contact of the view, equal to itself across absences from a member's picture: equality is by reference.
public readonly struct ContactId : IEquatable<ContactId>
{
private readonly Entity _subject;
static ContactId() => _openContact = id => id._subject;
internal ContactId(Entity subject) => _subject = subject;
public bool Equals(ContactId other) => ReferenceEquals(_subject, other._subject);
public override bool Equals(object obj) => obj is ContactId other && Equals(other);
public override int GetHashCode() => RuntimeHelpers.GetHashCode(_subject);
public static bool operator ==(ContactId a, ContactId b) => a.Equals(b);
public static bool operator !=(ContactId a, ContactId b) => !a.Equals(b);
}
// A revealed item of a contact, to aim at; the live item stays behind the handle (ruling contact-reads-book-aim-reads-live).
public readonly struct RevealId : IEquatable<RevealId>
{
private readonly EquippedItem _item;
static RevealId() => _openReveal = id => id._item;
internal RevealId(EquippedItem item) => _item = item;
public bool Equals(RevealId other) => ReferenceEquals(_item, other._item);
public override bool Equals(object obj) => obj is RevealId other && Equals(other);
public override int GetHashCode() => RuntimeHelpers.GetHashCode(_item);
public static bool operator ==(RevealId a, RevealId b) => a.Equals(b);
public static bool operator !=(RevealId a, RevealId b) => !a.Equals(b);
}
public readonly struct Track
{
public readonly float3 Position;
public readonly float2 Velocity;
public readonly float2 Direction;
public Track(float3 position, float2 velocity, float2 direction)
{
Position = position;
Velocity = velocity;
Direction = direction;
}
}
// For fixtures, and for the one place the stage states what a pilot already knows (ScenarioStage.Approach seeds an
// anchor handle for a FollowTask before the zone exists: staged knowledge, ruling stage-states-pilot-knowledge). A
// brain never calls this; the sensors, not the handle, decide whether the view answers about the contact.
internal static ContactId Handle(Entity subject) => new ContactId(subject);
private static Entity Subject(ContactId id)
{
if (_openContact == null) RuntimeHelpers.RunClassConstructor(typeof(ContactId).TypeHandle);
return _openContact(id);
}
private static EquippedItem Item(RevealId id)
{
if (_openReveal == null) RuntimeHelpers.RunClassConstructor(typeof(RevealId).TypeHandle);
return _openReveal(id);
}
// The side's contacts: its members' VisibleEnemies in member order, each once, never a member of the side.
public List<ContactId> Picture()
{
var picture = new List<ContactId>();
var seen = new HashSet<Entity>();
foreach (var member in _members)
foreach (var enemy in member.VisibleEnemies)
if (!_members.Any(m => ReferenceEquals(m, enemy)) && seen.Add(enemy)) picture.Add(new ContactId(enemy));
return picture;
}
public bool Sees(Ship member, ContactId contact) => Holds(member) && member.VisibleEnemies.Contains(Subject(contact));
// The member's own track of the contact, or null while it does not track it.
public Track? TrackOf(Ship member, ContactId contact)
{
var subject = Subject(contact);
if (!Tracks(member, subject)) return null;
return new Track(subject.Position, subject.Velocity, subject.Direction);
}
// The track of the first member, in order, that tracks the contact (question side-shared-kinematics).
public Track? SideTrack(ContactId contact)
{
foreach (var member in _members)
{
var track = TrackOf(member, contact);
if (track != null) return track;
}
return null;
}
public bool? IffOverride(Ship member, ContactId contact) => Holds(member) ? member.IffOverride(Subject(contact)) : null;
public float? Info(Ship member, ContactId contact) =>
Holds(member) && member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null;
public bool BelievesTrespassing(Ship member, ContactId contact) => Holds(member) && member.BelievesTrespassing(Subject(contact));
// The member's pending designation when set, else last step's applied target.
public ContactId? TargetOf(Ship member)
{
if (!Holds(member)) return null;
var subject = (member.Command.Designated ?? member.Target.Value).Entity;
return subject == null ? (ContactId?) null : new ContactId(subject);
}
public ContactId? AppliedTargetOf(Ship member)
{
if (!Holds(member)) return null;
var subject = member.Target.Value.Entity;
return subject == null ? (ContactId?) null : new ContactId(subject);
}
// The weapons the member believes the contact has, in the contact's equipment order; Aimable is FireControl.IsRevealed.
// Empty unless the member tracks the contact.
public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact)
{
var subject = Subject(contact);
if (!Tracks(member, subject)) return new List<(RevealId, EquippableItemData, bool)>();
return member.RevealedWeapons(subject)
.Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList();
}
// The next four answer for a member that tracks the contact: otherwise not designated, refused, no shot, no intercept.
public bool Designated(Ship member, Weapon weapon, ContactId contact)
{
var subject = Subject(contact);
return Tracks(member, subject) && FireControl.Designated(weapon, member, subject, out _);
}
public bool Refuses(Ship member, Weapon weapon, ContactId contact)
{
var subject = Subject(contact);
return !Tracks(member, subject) || FireControl.Refuses(weapon, member, subject);
}
public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim)
{
var subject = Subject(contact);
return Tracks(member, subject) && FireControl.AgentFires(weapon, member, subject, minHitProbability,
aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value)));
}
// Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides; null
// unless the member tracks the contact.
public float3? Intercept(Ship member, Weapon weapon, ContactId contact)
{
var subject = Subject(contact);
if (!Tracks(member, subject)) return null;
var predicted = FireControl.PredictedIntercept(weapon, member, subject);
predicted.y = subject.RideHeight(predicted.xz);
return predicted;
}
// Designate the contact, or nothing for null, when that is not already the member's applied target.
public void Designate(Ship member, ContactId? contact)
{
if (!Holds(member)) return;
var wanted = contact == null ? TargetRef.None : new TargetRef(Subject(contact.Value));
if (!member.Target.Value.Equals(wanted)) member.Command.Designate(wanted);
}
public void SelectItem(Ship member, RevealId? reveal)
{
if (Holds(member)) member.Command.SelectItem(reveal == null ? null : Item(reveal.Value));
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5b0f6c2e9d3a4f71a8c4e1d7b2a63f90
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -43,34 +43,39 @@ public class CombatState : BaseState
_optimumStale = false;
}
var target = _agent.Ship.Target.Value.Entity;
if (target == null) return;
// The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target; and
// only a contact a member tracks has a track to fly on, so an untracked target is no target here.
var view = _agent.View;
var target = view.TargetOf(_agent.Ship);
if (target == null) { ReleaseUnwanted(); return; }
var track = view.TrackOf(_agent.Ship, target.Value);
if (track == null) { ReleaseUnwanted(); return; }
_availableGroups.Clear();
_availableLockingWeapons.Clear();
var toTarget = target.Position - _agent.Ship.Position;
var toTarget = track.Value.Position - _agent.Ship.Position;
var targetDistance = length(toTarget.xz);
// Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at
// this range, or clear the aim point when nothing qualifies -- the same predicate
// (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it
// (the view's Aimable) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it
// believes the target has, and how hard each hits, come from its book's claims, not the target's live gear
// (aetheria-ew, classify-from-belief; ew-claim-weapon-measure-ruled: the claimed design's rated figures); the
// reveal's Item is the aim and stays the truth.
EquippedItem bestTargetItem = null;
PilotView.RevealId? bestTargetItem = null;
var bestTargetDps = float.MinValue;
foreach (var reveal in _agent.Ship.RevealedWeapons(target))
foreach (var reveal in view.WeaponReveals(_agent.Ship, target.Value))
{
if (!FireControl.IsRevealed(_agent.Ship, reveal.Item)) continue;
if (!reveal.Aimable) continue;
var dps = reveal.Claimed.Behaviors.OfType<WeaponData>().First().RatedRangeDamagePerSecond(targetDistance);
if (dps > bestTargetDps)
{
bestTargetDps = dps;
bestTargetItem = reveal.Item;
bestTargetItem = reveal.Id;
}
}
_agent.Ship.TrySelectTargetItem(bestTargetItem);
view.SelectItem(_agent.Ship, bestTargetItem);
for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++)
{
@ -80,10 +85,12 @@ public class CombatState : BaseState
// The range test is FireControl's (Designated), and a weapon Solve refuses adds nothing to a group's
// damage.
if (weapon.Item.Online.Value &&
FireControl.Designated(weapon, _agent.Ship, target, out _) &&
!FireControl.Refuses(weapon, _agent.Ship) &&
view.Designated(_agent.Ship, weapon, target.Value) &&
!view.Refuses(_agent.Ship, weapon, target.Value) &&
(weapon is ConstantWeapon ||
weapon is InstantWeapon instantWeapon && instantWeapon.CanFire))
// A held AutoWeapon stays a candidate between its rounds (it is cooling because it is firing), so the
// group it belongs to is judged by the same Fires verdict below, not dropped for cooling down.
weapon is InstantWeapon instantWeapon && (instantWeapon.CanFire || weapon is AutoWeapon && weapon.Firing)))
{
if(weapon is LockWeapon lockWeapon)
_availableLockingWeapons.Add(lockWeapon);
@ -94,7 +101,7 @@ public class CombatState : BaseState
if (dps > .1) _availableGroups.Add((i, dps));
}
foreach(var w in _availableLockingWeapons) w.Activate();
foreach(var w in _availableLockingWeapons) _agent.Ship.Command.Pull(w);
UseConsumables(toTarget, targetDistance);
@ -109,11 +116,11 @@ public class CombatState : BaseState
}
}
var targetRight = target.Direction.Rotate(ItemRotation.Clockwise);
var targetRight = track.Value.Direction.Rotate(ItemRotation.Clockwise);
var optimumRangeDelta = abs(_optimumRange - targetDistance);
var directionToTarget = normalize(toTarget.xz);
var targetPortAlignment = dot(targetRight, directionToTarget);
var targetForeAlignment = dot(-target.Direction, directionToTarget);
var targetForeAlignment = dot(-track.Value.Direction, directionToTarget);
var forwardness = saturate(optimumRangeDelta / _agent.Settings.AgentMaxForwardDistance) * _agent.Settings.AgentForwardLerp;
if (targetForeAlignment > 0) forwardness = lerp(forwardness, 1, pow(targetPortAlignment, 2));
@ -126,32 +133,47 @@ public class CombatState : BaseState
var testWeapon = _agent.Ship.WeaponGroups[selectedGroup].weapons.First();
if(testWeapon.Velocity > 1)
{
// Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's
// Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with the shot's
// own snapshot and the roll's deviation measurement -- two separate predictions here and in the
// roll would let an AI aim at one point and get judged for missing a different one.
var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target);
predictedPosition.y = target.RideHeight(predictedPosition.xz);
toTarget = normalize(predictedPosition - _agent.Ship.Position);
// The target is tracked here (the guard above), so the intercept is never null.
var predictedPosition = view.Intercept(_agent.Ship, testWeapon, target.Value);
if (predictedPosition != null) toTarget = normalize(predictedPosition.Value - _agent.Ship.Position);
}
// Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a
// verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it
// verdict. The view's Fires is the AI's "worth it" heuristic for a weapon that hits what it
// fires at, and "the target is designated" for a fused one.
foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons)
{
if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability))
weapon.Activate();
else if (weapon.Firing)
weapon.Deactivate();
if (view.Fires(_agent.Ship, weapon, target.Value, _agent.Doctrine.MinHitProbability, bestTargetItem))
_agent.Ship.Command.Pull(weapon);
// A lock pulled above this step counts as firing, as it did when it was activated in place.
else if (weapon.Firing || _agent.Ship.Command.Pulls(weapon))
_agent.Ship.Command.Release(weapon);
}
}
_agent.Ship.Aim = toTarget;
_agent.Ship.Turn = Steering.Toward(_agent.Ship, toTarget.xz);
ReleaseUnwanted();
_agent.Ship.Command.Aim = toTarget;
_agent.Ship.Command.Turn = TurnCommand.Toward(toTarget.xz);
_agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0);
// Fire charged guns!
}
public override void OnExitState() => ReleaseUnwanted(ending: true);
// The AI's trigger level is what it commands this step: a weapon holds a down trigger until it is released (a gun, a lock,
// a beam), so every held one this step has no command for is let go, whether the target is gone, untracked, out of reach,
// its group is not the pick, or Combat has ended. Ending lets go of every held one, a weapon pulled this very step
// (held once the pull is applied) included, since nothing after Combat will command it.
private void ReleaseUnwanted(bool ending = false)
{
var command = _agent.Ship.Command;
foreach (var weapon in _agent.Ship.Weapons)
if ((weapon.Firing || command.Pulls(weapon)) && (ending || !command.Commands(weapon))) command.Release(weapon);
}
// Overdrive to close a distant target ahead, through the same consumable gate as the vent (Agent.VentOnTrack).
private void UseConsumables(float3 toTarget, float targetDistance)
{

View file

@ -11,16 +11,18 @@ public class FollowState : BaseState
{
var task = _agent.Task as FollowTask;
if (task?.Anchor == null) return;
var anchor = _agent.View.SideTrack(task.Anchor.Value);
if (anchor == null) return;
var toAnchor = task.Anchor.Position.xz - _agent.Ship.Position.xz;
var toAnchor = anchor.Value.Position.xz - _agent.Ship.Position.xz;
var distance = length(toAnchor);
var direction = distance > 0 ? toAnchor / distance : _agent.Ship.Direction;
// Positive error: too far, close. Negative: too near, open.
var error = distance - task.Standoff;
var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f));
var desired = task.Anchor.Velocity + pull * _agent.TopSpeed;
var desired = anchor.Value.Velocity + pull * _agent.TopSpeed;
_agent.Ship.Aim = float3(direction.x, 0, direction.y);
_agent.Ship.Command.Aim = float3(direction.x, 0, direction.y);
_agent.Accelerate(desired);
}
}

View file

@ -23,8 +23,8 @@ public abstract class MoveToState : BaseState
// We want to go top speed in the direction of our target
var desiredVelocity = dir * _agent.TopSpeed;
_agent.Ship.Aim = float3(dir.x, 0, dir.y);
_agent.Ship.Turn = Steering.Toward(_agent.Ship, dir);
_agent.Ship.Command.Aim = float3(dir.x, 0, dir.y);
_agent.Ship.Command.Turn = TurnCommand.Toward(dir);
_agent.Accelerate(desiredVelocity);
}
}

View file

@ -5,7 +5,7 @@ using MessagePack;
// Runtime only, like every agent task.
public class FollowTask : AgentTask
{
[IgnoreMember] public Entity Anchor;
[IgnoreMember] public PilotView.ContactId? Anchor;
[IgnoreMember] public float Standoff;
[IgnoreMember] public override TaskType Type => TaskType.Follow;
}

View file

@ -9,23 +9,17 @@ using static CultMath.math;
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class AutoWeaponData : InstantWeaponData
{
public override Behavior CreateInstance(EquippedItem item)
{
return new AutoWeapon(this, item);
}
[IgnoreMember]
public override bool Automatic => true;
}
// A fully automatic gun: it fires every round its cooldown allows while its trigger is held, from the press (Activate)
// to the let-go (Deactivate). A plain InstantWeapon is semi-automatic.
public class AutoWeapon : InstantWeapon
{
protected override bool Repeats => _firing;
public AutoWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { }
public AutoWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { }
public override bool Execute(float dt)
{
if(_firing && _burstRemaining == 0 && _cooldown < 0)
Trigger();
return base.Execute(dt);
}
}

View file

@ -6,6 +6,7 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
using MessagePack;
using Newtonsoft.Json;
using CultMath;
@ -87,10 +88,15 @@ public abstract class Behavior
}
}
public interface IActivatedBehavior
// A behaviour its pilot triggers. Only PilotApplier pulls and releases it, from the pilot's PilotCommand (Pull, Release),
// so Activate and Deactivate are internal to the simulation: no script outside it triggers one in place.
public abstract class ActivatedBehavior : Behavior
{
void Activate();
void Deactivate();
protected ActivatedBehavior(BehaviorData data, EquippedItem item) : base(data, item) { }
protected ActivatedBehavior(BehaviorData data, ConsumableItemEffect consumable) : base(data, consumable) { }
internal abstract void Activate();
internal abstract void Deactivate();
}
public interface IEventBehavior
@ -193,6 +199,7 @@ public interface IPowerConsumer
Union(49, typeof(LaunchSuppressorData)),
Union(50, typeof(ListenerData)),
Union(51, typeof(ScannerData)),
Union(52, typeof(LockWeaponData)),
JsonObject(MemberSerialization.OptIn)]
public abstract class BehaviorData
{
@ -206,4 +213,7 @@ public abstract class BehaviorData
{
return base.ToString().FormatTypeName();
}
// Element identity: minted by the cache on write; "" means unset.
[Key(37)] [CultElementId] public string Id = "";
}

View file

@ -157,8 +157,7 @@ public class ChargedWeapon : InstantWeapon
if (_data.FailureCharge > 1 && _charge > _data.FailureCharge)
{
_charging = false;
_cooldown = 1;
_coolingDown = true;
StartCountdown();
_charge = 0;
OnFailed?.Invoke();
CauseDamage(_data.FailureDamage);
@ -167,7 +166,7 @@ public class ChargedWeapon : InstantWeapon
return base.Execute(dt);
}
public override void Activate()
internal override void Activate()
{
// CanFire also holds a weapon whose released charge has a burst pending: its cooldown starts at its first round,
// so a charge started before then would overlap it.
@ -180,7 +179,7 @@ public class ChargedWeapon : InstantWeapon
}
}
public override void Deactivate()
internal override void Deactivate()
{
if (_charging)
{

View file

@ -46,6 +46,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
{
get => _ammo;
}
public override bool HasMagazine => false;
public float Progress
{
@ -146,9 +147,10 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
}
_ammoInterval -= dt / _data.AmmoInterval;
if (_ammoInterval < 0)
// An interval shorter than the step spends every round it spans.
while (_ammoInterval < 0)
{
_ammoInterval = 1;
_ammoInterval += 1;
if (_data.MagazineSize > 1 && _ammo > 0) _ammo--;
else
{
@ -198,7 +200,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
return true;
}
public override void Activate()
internal override void Activate()
{
// A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and
// audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing.
@ -209,7 +211,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
}
}
public override void Deactivate()
internal override void Deactivate()
{
if (_firing)
{

View file

@ -48,7 +48,7 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior
{
if (_cooldown < 0)
{
_cooldown = 1;
_cooldown += 1;
return true;
}
@ -57,6 +57,8 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior
public void Update(float delta)
{
_cooldown -= delta / Evaluate(_data.Cooldown);
// A finished timer keeps what the last step overshot, at most one step, so the restart in Execute is exact.
var step = delta / Evaluate(_data.Cooldown);
_cooldown = max(_cooldown - step, -step);
}
}

View file

@ -42,6 +42,18 @@ public class InputCapacitor
public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity);
// A weapon held through a step may fire several rounds in it (ruling rounds-per-step): the charge it holds and what flows in
// during the step pay for them, so for that step its buffer is a full activation plus the step's inflow; Settle trims what
// the step did not spend back to Capacity. The fill rate is unchanged, so the energy a gun draws is still exactly its
// rounds' cost. A sensor or a shield, and a weapon not held, keep the one-activation buffer above.
private float StepReach(float dt) => Capacity + Rate * dt;
public float RequestedStepFill(float dt) => min(Rate * dt, max(0f, StepReach(dt) - Charge));
public void AddStepCharge(float amount, float dt) => Charge = clamp(Charge + amount, 0f, StepReach(dt));
public void Settle() => Charge = min(Charge, Capacity);
public bool CanSpend(float cost) => Charge >= cost;
// Atomic and whole-or-nothing, mirroring Entity.TrySpendCapacitorCharge's old contract exactly (down to the

View file

@ -21,14 +21,39 @@ public class InstantWeaponData : WeaponData
[Inspectable, JsonProperty("ammoInterval"), Key(20)]
public bool SingleAmmoBurst;
// The fire mode, the one fact creation and the semi-auto catalog check both read: an automatic gun fires every round its
// cooldown allows while its trigger is held, every other kind is semi-automatic (a press fires one burst).
[IgnoreMember]
public virtual bool Automatic => false;
public override Behavior CreateInstance(EquippedItem item)
{
return new InstantWeapon(this, item);
return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new InstantWeapon(this, item);
return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item);
}
// The shortest cooldown a plain (semi-automatic) gun may be authored with, 0.1 s: about ten presses a second. A faster one
// is a fire-as-fast-as-you-can-press mechanic, unfriendly to accessibility and a special case for the AI (ruling
// fire-mode-is-the-design); a gun that cycles faster is an AutoWeapon.
public const float ShortestSemiAutoCooldown = .1f;
// The semi-automatic weapons of a catalog (gear and consumables, every kind that is not Automatic: plain, guided, mine,
// launcher, charged) that cycle faster than a finger should have to: one fault line per weapon.
public static IEnumerable<string> SemiAutoFaults(IEnumerable<ItemData> catalog)
{
foreach (var item in catalog)
{
var behaviors = (item as GearData)?.Behaviors ?? (item as ConsumableItemData)?.Behaviors;
if (behaviors == null) continue;
foreach (var behavior in behaviors)
if (behavior is InstantWeaponData weapon && !weapon.Automatic &&
Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max) < ShortestSemiAutoCooldown)
yield return $"{item.Name}: a {weapon.GetType().Name} with a {Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max):0.###} s cooldown, below {ShortestSemiAutoCooldown} s; make it an AutoWeapon or slow it";
}
}
// The one formula of an instant weapon's damage per second at a range; the live weapon calls it with its evaluated
@ -58,6 +83,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
protected float _cooldown; // Normalized
private int _ammo = 0;
protected bool _coolingDown;
// Seconds the last countdown ran past zero (less than one step). The next countdown starts that far along, so a held
// trigger fires Cooldown apart on average at any step, never floor(Cooldown/dt)+1 steps apart.
private float _overshoot;
// The shortest cycle a countdown divides by: a cooldown of zero would otherwise fire without end in one step.
private const float ShortestCycle = .0005f;
public float BurstCount { get; protected set; }
public float BurstTime { get; protected set; }
@ -87,6 +117,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// manager can bind its presentation to the one shot it belongs to instead of applying damage itself.
public event Action<int> OnFire;
// Whether a down trigger keeps firing: a plain gun is semi-automatic, one burst per press (a held key does not repeat, and
// an agent presses it again each time it is ready); an AutoWeapon repeats while its trigger is held, every round its
// cooldown allows at any step length. Fire mode is authored by the kind of the weapon (ruling fire-mode-is-the-design).
protected virtual bool Repeats => false;
public virtual void ResetEvents()
{
OnReloadBegin = null;
@ -98,13 +133,13 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
public InstantWeapon(InstantWeaponData data, EquippedItem item) : base(data, item)
{
_data = data;
_ammo = data.MagazineSize;
_ammo = Magazine;
}
public InstantWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item)
{
_data = data;
_ammo = data.MagazineSize;
_ammo = Magazine;
}
protected void Trigger()
@ -165,11 +200,15 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
_capacitor.UpdateStats(perShotEnergy, cooldown, rateOverride: RateOverride);
}
// What the capacitor asks the bus for this step: a weapon held through it may fire several rounds in it, so it asks for
// the step's whole inflow; any other tops up to one activation.
private float Fill(float dt) => Repeats ? _capacitor.RequestedStepFill(dt) : _capacitor.RequestedFill(dt);
// Cut 4: the request PowerBus needs before Execute runs, mirroring EnergyDraw's IPowerConsumer pattern.
public float PowerRequest(float dt)
{
RefreshInputCapacitor();
return _capacitor.RequestedFill(dt);
return Fill(dt);
}
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense. The ruling's own example of what
@ -183,9 +222,57 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// would ever fill this capacitor -- bypass it entirely rather than starving such an instance forever.
protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity);
private bool UseAmmo()
// Ends the countdown once it is below zero, keeping what it ran past zero (at most one step) for the next one.
// True when it ended the countdown now.
private bool FinishCountdown(float dt)
{
if (_data.MagazineSize <= 1) return true;
if (!_coolingDown || !(_cooldown < 0)) return false;
_overshoot = min(-_cooldown * CountdownSpan, dt);
_coolingDown = false;
if (Magazine > 0 && _ammo == 0 && TakeReloadRound())
{
_ammo = Magazine;
OnReloadComplete?.Invoke();
}
else
OnCooldownComplete?.Invoke();
return true;
}
// A reload pays a round (ruling ballistics-real-ammo): one unit of the weapon's AmmoType leaves the cargo as the magazine refills,
// and with none to take the magazine stays empty. A weapon with no AmmoType reloads free.
private bool TakeReloadRound()
{
if (!_data.AmmoType.IsSet()) return true;
var cargo = Entity.FindItemInCargo(_data.AmmoType.Key);
if (cargo == null) return false;
if (cargo.ItemsOfType[_data.AmmoType.Key][0] is SimpleCommodity simpleCommodity) cargo.Remove(simpleCommodity, 1);
return true;
}
private float CountdownSpan => max(ShortestCycle, Magazine > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
// A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart.
protected void StartCountdown()
{
_cooldown = 1 - TakeOvershoot() / CountdownSpan;
_coolingDown = true;
}
private float TakeOvershoot()
{
var overshoot = _overshoot;
_overshoot = 0;
return overshoot;
}
// A weapon that names an AmmoType is magazine-fed whatever MagazineSize says: its magazine is at least one round, every round
// leaves it and the reload takes a crate. A weapon with no AmmoType and a magazine of one or none fires free.
private int Magazine => _data.AmmoType.IsSet() ? max(1, _data.MagazineSize) : _data.MagazineSize;
private bool UseAmmo(float dt)
{
if (!_data.AmmoType.IsSet() && _data.MagazineSize <= 1) return true;
if (_ammo > 0)
{
@ -193,22 +280,12 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
return true;
}
var hasAmmo = true;
if (_data.AmmoType.IsSet())
{
var cargo = Entity.FindItemInCargo(_data.AmmoType.Key);
if (cargo != null)
{
var item = cargo.ItemsOfType[_data.AmmoType.Key][0];
if (item is SimpleCommodity simpleCommodity)
cargo.Remove(simpleCommodity, 1);
}
else hasAmmo = false;
}
// The reload starts only if a round is there to load; FinishCountdown takes it.
var hasAmmo = !_data.AmmoType.IsSet() || Entity.CountItemsInCargo(_data.AmmoType.Key) > 0;
if(hasAmmo)
{
OnReloadBegin?.Invoke();
_cooldown = 1;
_cooldown = 1 - (_burstTimer + TakeOvershoot()) / _data.ReloadTime;
_coolingDown = true;
_firing = false;
}
@ -218,29 +295,32 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
}
public override bool Execute(float dt)
{
try { return Step(dt); }
finally { _capacitor.Settle(); }
}
// One step of rounds. A round is due at a moment inside the step (_burstTimer is how long ago, at the step's end), and a
// cooldown that ends inside the step while the trigger is held starts the next burst at that moment, so a gun fires
// every round its cooldown allows in a step, each with its own roll, however short the cooldown or long the step.
private bool Step(float dt)
{
base.Execute(dt);
// Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item
// instance never reaches PowerBus.Step (see TrySpendActivationEnergy) and so never accrues charge.
if (Item != null)
_capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply);
_capacitor.AddStepCharge(Fill(dt) * Item.PowerSupply, dt);
var ended = false;
var retriggered = false;
if (_coolingDown)
{
_cooldown -= dt / (_data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
if (_cooldown < 0)
{
_coolingDown = false;
if (_data.MagazineSize > 0 && _ammo == 0)
{
_ammo = _data.MagazineSize;
OnReloadComplete?.Invoke();
}
else
OnCooldownComplete?.Invoke();
}
_cooldown -= dt / CountdownSpan;
ended = FinishCountdown(dt);
}
_burstTimer += dt;
while (true)
{
while (_burstRemaining > 0 && _burstTimer > 0)
{
// A fused round whose arming distance Range cannot reach is refused, per round, before anything is
@ -249,7 +329,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
var refused = FireControl.Refuses(this, Entity);
// Ammo and energy are consumed here, per round, or once per burst at its first unrefused round
// (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case
if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo()))
if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo(dt)))
{
_burstRemaining = 0;
return false;
@ -260,8 +340,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
if (!_burstStarted)
{
_burstStarted = true;
_cooldown = 1 - dt / Cooldown;
// The round starts the cooldown after this tick's decrement above: charge the tick to it, so the cadence stays Cooldown
_cooldown = 1 - (_burstTimer + TakeOvershoot()) / CountdownSpan;
// The round starts the cooldown after this tick's decrement above: charge the tick and the last
// countdown's overshoot to it, so the cadence stays Cooldown
_coolingDown = true;
}
}
@ -277,16 +358,31 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// whole expression, argument included, whenever OnFire has no subscriber (a headless run with no
// Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired.
// Evaluate it into a local first.
var shotId = FireControl.Fire(this, Item, Entity);
var shotId = FireControl.Fire(this, Item, Entity, age: _burstTimer + _burstInterval);
OnFire?.Invoke(shotId);
CauseWearDamage(1);
AddHeat(Heat);
Entity.SetSignature(this, SignatureChannel.LaunchFlash, Visibility, Item);
}
// A cooldown shorter than the step it started in is over in that step.
ended |= FinishCountdown(dt);
// The trigger is held and the weapon is ready: the next burst starts now, at the moment the cooldown ended (what it
// ran past zero is how long ago, at the step's end), or at the step's start when the weapon was already ready.
if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) break;
retriggered = true;
Trigger();
if (_burstRemaining == 0) break;
if (ended)
{
_burstTimer = TakeOvershoot();
ended = false;
}
else _burstTimer = dt;
}
return true;
}
public override void Activate()
internal override void Activate()
{
if(CanFire)
Trigger();

View file

@ -136,7 +136,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Utility -- everything else.
public int DefaultPowerTier => PowerTiers.Utility;
public void Ping()
internal void Ping()
{
// Whole-or-nothing against the ping's own input capacitor -- fires only at full charge (operator
// ruling: no fraction of a shot). A consumable-hosted instance (Item == null) has no PowerBus entry, so
@ -145,7 +145,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
{
_pinging = true;
_pingZone = Entity.Zone;
_pingCooldown = 1;
_pingCooldown += 1;
_pingLerp = 0;
_pingRadius = 0;
_reached.Clear();
@ -180,8 +180,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
if (_pingLerp > 1) EndPing();
}
_pingCooldown -= dt / Evaluate(_data.PingCooldown);
// A finished timer keeps what the last step overshot, at most one step, so the restart in Ping is exact.
var pingStep = dt / Evaluate(_data.PingCooldown);
_pingCooldown = max(_pingCooldown - pingStep, -pingStep);
var infoDecay = ItemManager.GameplaySettings.TargetInfoDecay;
var decay = exp(-infoDecay * dt);
var settle = infoDecay > 0 ? (1 - decay) / infoDecay : dt;
var forward = Direction.xz;
foreach (var entity in Entity.Zone.Entities)
{
@ -221,14 +227,16 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
credit += Gain(entity.Visibility, sensitivity, response, sqrt(r.IlluminatedRange * dist), dt, true, sqrt(r.IlluminatedObscuration * obscuration));
}
// The exact decay at this step: x <- x e^(-k dt) + (G / k)(1 - e^(-k dt)) for the passive gain G per second,
// which settles at G / k (Entity.PassiveInfo) at every dt. A ping's credit is an impulse added first.
if (heard)
next = saturate(previous + credit);
next = saturate(previous + credit) * decay;
else
{
var angle = acos(dot(forward, normalize(diff)));
next = saturate(previous + Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
var passive = Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, 1f, false, obscuration);
next = saturate(previous * decay + passive * settle);
}
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
Entity.EntityInfoGathered[entity] = next;
}

View file

@ -20,11 +20,11 @@ public class SwitchData : BehaviorData
}
}
public class Switch : Behavior, IActivatedBehavior
public class Switch : ActivatedBehavior
{
private SwitchData _data;
public bool Activated { get; set; }
public bool Activated { get; private set; }
public Switch(SwitchData data, EquippedItem item) : base(data, item)
{
@ -40,12 +40,12 @@ public class Switch : Behavior, IActivatedBehavior
return Activated;
}
public void Activate()
internal override void Activate()
{
Activated = true;
}
public void Deactivate()
internal override void Deactivate()
{
Activated = false;
}

View file

@ -48,6 +48,7 @@ public class Thruster : Behavior, IPowerConsumer
public float Thrust => Evaluate(_data.Thrust);
// Thrust at a full grant, the same stat read without the power-supply term: what a column promises. Live, like Thrust.
public float NominalThrust => EvaluateNominalPower(_data.Thrust);
// GameplaySettings.ThrustScale scales the push alone: it tunes how hard ships fight gravity, and turning does not.
public float Torque { get; }
// This thruster's effect per unit throttle at the given thrust, in the body frame: xy is the push (x starboard, y
@ -56,7 +57,7 @@ public class Thruster : Behavior, IPowerConsumer
public float3 Column(float thrust)
{
if (Item == null || !Item.Active.Value) return default;
var push = -float2(0, 1).Rotate(Item.EquippableItem.Rotation) * thrust / Entity.Mass;
var push = -float2(0, 1).Rotate(Item.EquippableItem.Rotation) * thrust * ItemManager.GameplaySettings.ThrustScale / Entity.Mass;
return float3(push.x, push.y, Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass);
}
@ -117,7 +118,7 @@ public class Thruster : Behavior, IPowerConsumer
if(_input > 0f && Item.PowerSupply > 1e-4f)
{
var thrust = Thrust;
Entity.Velocity -= Direction.xz * _input * thrust / Entity.Mass * dt;
Entity.Velocity -= Direction.xz * _input * thrust * ItemManager.GameplaySettings.ThrustScale / Entity.Mass * dt;
var turnRate = _input * Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass;
Entity.Direction = mul(Entity.Direction, CultMath.float2x2.Rotate(turnRate * dt));
Entity.TurnRate += turnRate;

View file

@ -20,9 +20,9 @@ public class TriggerData : BehaviorData
}
}
public class Trigger : Behavior, IActivatedBehavior
public class Trigger : ActivatedBehavior
{
public bool _pulled;
private bool _pulled;
public Trigger(TriggerData data, EquippedItem item) : base(data, item) { }
public Trigger(TriggerData data, ConsumableItemEffect item) : base(data, item) { }
@ -38,12 +38,12 @@ public class Trigger : Behavior, IActivatedBehavior
return false;
}
public void Activate()
internal override void Activate()
{
_pulled = true;
}
public void Deactivate()
internal override void Deactivate()
{
}
}

View file

@ -11,12 +11,7 @@ using Newtonsoft.Json;
using CultMath;
using static CultMath.math;
[Inspectable,
Union(0, typeof(InstantWeaponData)),
Union(1, typeof(LauncherData)),
Union(2, typeof(ConstantWeaponData)),
Union(3, typeof(ChargedWeaponData)),
RuntimeInspectable]
[Inspectable, RuntimeInspectable]
public abstract class WeaponData : BehaviorData
{
[Inspectable, JsonProperty("damageType"), Key(1), RuntimeInspectable]
@ -100,13 +95,23 @@ public abstract class WeaponData : BehaviorData
public float RatedRange => Range.Max;
}
public abstract class Weapon : Behavior, IActivatedBehavior
public abstract class Weapon : ActivatedBehavior
{
private WeaponData _data;
public abstract float DamagePerSecond { get; }
public abstract float RangeDamagePerSecond(float range);
public abstract int Ammo { get; }
// Rounds in the ship's cargo that reload this weapon's magazine: crates of AmmoType, 0 when the weapon draws none.
public int Reserve => _data.AmmoType.IsSet() ? Entity.CountItemsInCargo(_data.AmmoType.Key) : 0;
// Whether Ammo counts a magazine. A weapon that names an AmmoType and fires from one (InstantWeapon) does; a beam (ConstantWeapon) draws one
// crate per AmmoInterval straight from the cargo and has none.
public virtual bool HasMagazine => true;
// What the HUD's ammunition label shows: "magazine/reserve" for a magazine weapon, the reserve alone for one without a magazine, empty when
// the weapon draws no ammunition. The one rule the HUD reads, so the label cannot show a magazine a weapon does not have.
public string AmmoReadout => !ShowsAmmoReadout ? "" : HasMagazine ? $"{Ammo}/{Reserve}" : Reserve.ToString();
// Whether the HUD shows the ammunition label at all (and not the infinite-ammunition icon): the weapon names an AmmoType.
public bool ShowsAmmoReadout => _data.AmmoType.IsSet();
public WeaponData WeaponData => _data;
public float RangeFactor(float at) => WeaponData.RangeFactor(MinRange, EffectiveRange, Range, _data.DamageAtMaxRange, at);
@ -179,12 +184,12 @@ public abstract class Weapon : Behavior, IActivatedBehavior
return true;
}
public virtual void Activate()
internal override void Activate()
{
_firing = true;
}
public virtual void Deactivate()
internal override void Deactivate()
{
_firing = false;
}

View file

@ -0,0 +1,125 @@
/* 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.Linq;
using CultMath;
using static CultMath.math;
// Why a give ended. Notification only: nothing reads it to decide anything.
public enum GiveEnd
{
Done,
Stopped,
OutOfRange,
ReceiverGone,
ReceiverFull,
NothingLeft,
GiverDocked,
GiverGone
}
// A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved.
public sealed class GiveOrder
{
public readonly Entity Receiver;
// A commodity order is for any stack of this item's design in the giver's bays; a crafted order is for this instance.
public readonly ItemInstance Item;
// Units still to move.
public int Remaining { get; internal set; }
// Units earned and not yet moved, fractional between steps.
internal double Budget;
internal GiveOrder(Entity receiver, ItemInstance item, int quantity)
{
Receiver = receiver;
Item = item;
Remaining = quantity;
}
}
// The one owner of a give's progress and its end (ruling tender-transfer-verb). A ship's own pilot asks for a give through
// PilotCommand.Give and PilotApplier starts it here; nothing else moves goods out of a hold in space (ruling
// transfer-is-give-only). Every unit moves through EquippedCargoBay.TryTransferItem, the one conserving bay-to-bay commit.
public static class CargoTransfer
{
internal static void Begin(Entity giver, GiveOrder order)
{
End(giver, GiveEnd.Stopped);
giver.Giving = order;
}
// Ends the giver's give, if it has one, for the reason.
internal static void End(Entity giver, GiveEnd reason)
{
if (giver.Giving == null) return;
giver.Giving = null;
giver.LastGiveEnd = reason;
}
// One step of every give in the zone: the receiver is still here and alongside, and the rate earns units, each moved
// into the first receiver bay that takes it.
public static void Step(Zone zone, float deltaTime)
{
foreach (var giver in zone.Entities.ToArray())
{
var order = giver.Giving;
if (order == null) continue;
var settings = giver.ItemManager.GameplaySettings;
var range = settings.DockingDistance;
if (!zone.Entities.Contains(order.Receiver))
{
End(giver, GiveEnd.ReceiverGone);
continue;
}
if (lengthsq(order.Receiver.Position.xz - giver.Position.xz) > range * range)
{
End(giver, GiveEnd.OutOfRange);
continue;
}
order.Budget += (double) settings.TransferRate * deltaTime;
while (order.Budget >= 1 && order.Remaining > 0)
{
var source = Source(giver, order);
if (source.Bay == null)
{
End(giver, GiveEnd.NothingLeft);
break;
}
if (!Move(source.Bay, source.Item, order.Receiver))
{
End(giver, GiveEnd.ReceiverFull);
break;
}
order.Budget -= 1;
order.Remaining--;
}
if (order.Remaining == 0) End(giver, GiveEnd.Done);
}
}
// Where the next unit comes from: the first stack of the order's design in the giver's bays, or the crafted instance.
private static (EquippedCargoBay Bay, ItemInstance Item) Source(Entity giver, GiveOrder order)
{
foreach (var bay in giver.CargoBays)
{
if (order.Item is CraftedItemInstance crafted)
{
if (bay.Cargo.ContainsKey(crafted)) return (bay, crafted);
}
else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks))
{
var stack = stacks.OfType<SimpleCommodity>().FirstOrDefault();
if (stack != null) return (bay, stack);
}
}
return (null, null);
}
// One unit of a commodity, or the crafted item, into the first receiver bay that takes it.
private static bool Move(EquippedCargoBay from, ItemInstance item, Entity receiver) =>
item is SimpleCommodity commodity
? receiver.CargoBays.Any(bay => from.TryTransferItem(bay, commodity, 1))
: receiver.CargoBays.Any(bay => from.TryTransferItem(bay, (CraftedItemInstance) item));
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7be41c09d3a84f5e9a206d18c4f3b5a2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -39,8 +39,7 @@ public class Faction
// Key 9 was the faction's name-file link, retired by ruling faction-curated-place-names; never reuse it.
[Inspectable, JsonProperty("bossHull"), Key(10)]
public CultRecordRef<HullData> BossHull;
// Key 10 was the faction's boss hull reference, retired by ruling faction-boss-is-a-loadout-reference; never reuse it.
[Inspectable, JsonProperty("influence"), Key(11)]
public int InfluenceDistance = 4;
@ -48,14 +47,7 @@ public class Faction
[Inspectable, CultReference(typeof(Faction), many: true), CultInspectorRange(0, 1), JsonProperty("allegiance"), Key(12)]
public Dictionary<CultRecordRef<Faction>, float> Allegiance = new Dictionary<CultRecordRef<Faction>, float>();
[Inspectable, JsonProperty("overworldMusic"), Key(13)]
public uint OverworldMusic;
[Inspectable, JsonProperty("combatMusic"), Key(14)]
public uint CombatMusic;
[Inspectable, JsonProperty("bossMusic"), Key(15)]
public uint BossMusic;
// Keys 13 to 15 (overworldMusic, combatMusic, bossMusic) held Wwise ids; retired, never reuse them.
// Key 16 is reserved for Relations (operator ruling faction-relations-field).
@ -66,7 +58,7 @@ public class Faction
// The maker's final assembly: the distribution every lot of theirs draws its own quality from, and the hard ceiling on each
// of its roles (quality-lean-assembly-hard-ceiling). Null is not authored: a lot of theirs assembles at AssemblyDistribution's
// defaults. assembly-from-maker derives it once at the part mean plus a tolerance margin (quality-min-tolerance-binned-assembly);
// tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery.
// tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery. Key 20 was assemblyDeviation (retired); never reuse it.
[Inspectable, JsonProperty("assembly"), Key(19)]
public AssemblyDistribution Assembly;
@ -74,6 +66,11 @@ public class Faction
// Authored by AetherDb place-names, which restores the table on every apply.
[Inspectable, JsonProperty("places"), Key(21)]
public FactionPlace[] Places = Array.Empty<FactionPlace>();
// The Loadout this faction's boss flies; unset means the faction has no boss (ruling faction-boss-is-a-loadout-reference).
// The one owner of which Loadout is its boss, read through Loadouts.BossOf.
[Inspectable, JsonProperty("bossLoadout"), Key(22)]
public CultRecordRef<Loadout> BossLoadout;
}
// Historical, narrative and strategic intent join as keys 2+ when economics-driven placement lands (ruling faction-curated-place-names).
@ -87,5 +84,8 @@ public class FactionPlace
// treats an invalid entry as absent; AetherDb place-names refuses a table that holds one.
public static bool IsValidName(string name) =>
!string.IsNullOrEmpty(name) && name == name.Trim() && !name.StartsWith("EAC-", StringComparison.OrdinalIgnoreCase);
// Element identity: minted by the cache on write; "" means unset.
[Key(2)] [CultElementId] public string Id = "";
}

View file

@ -0,0 +1,469 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
// What the economy checks read beyond the catalog: the setting's factors on recipe terms (each at least 1) and the units of a
// commodity one worker consumes per second of work. Neutral by default; later cuts pass real values.
public sealed class EconomyLoad
{
public Dictionary<string, double> LabourNeeds = new Dictionary<string, double>();
public double InputFactor = 1;
public double WorkforceFactor = 1;
public double UpkeepFactor = 1;
}
public sealed class EconomyFault
{
public readonly string Invariant;
public readonly string Record;
public readonly string Message;
// A fault only because the chain has not written the record yet (a commodity with no recipe). Reported, never a refusal.
public readonly bool Pending;
// L-productive only: every good the carrying cycle holds (sorted) and the spectral radius. The fault's identity is the member
// set; a radius that rises is a worse fault of the same identity.
public readonly IReadOnlyList<string> Members;
public readonly double Radius;
public EconomyFault(string invariant, string record, string message, bool pending = false, IReadOnlyList<string> members = null, double radius = 0)
{
Members = members;
Radius = radius;
Pending = pending;
Invariant = invariant;
Record = record;
Message = message;
}
public override string ToString() => $"{Invariant} {Record}: {Message}";
}
public sealed class EconomyReport
{
public readonly IReadOnlyList<EconomyFault> Faults;
// Production steps to each good, by the good's record key; null when no finite chain reaches it. Read by reviewers, never a refusal.
public readonly IReadOnlyDictionary<string, int?> Depth;
public EconomyReport(IReadOnlyList<EconomyFault> faults, IReadOnlyDictionary<string, int?> depth)
{
Faults = faults;
Depth = depth;
}
}
// The one owner of the catalog's economy invariants. Pure: draws nothing, reads no clock, writes nothing. One method per rule.
public static class EconomyChecks
{
public const string LRecipe = "L-recipe", LOneKindList = "L-one-kind-list", LLore = "L-lore", LSource = "L-source-sink", LProductive = "L-productive", LDemand = "L-consumer-demand";
private const double Margin = 1e-9;
public static EconomyReport Check(CultCache catalog, EconomyLoad load = null)
{
var faults = new List<EconomyFault>();
faults.AddRange(Recipe(catalog));
faults.AddRange(Lore(catalog));
faults.AddRange(SourceSink(catalog));
faults.AddRange(Productive(catalog, load));
faults.AddRange(ConsumerDemand(catalog));
return new EconomyReport(faults, Deep(catalog));
}
private static IEnumerable<(string Key, RecipeData Recipe)> StoredRecipes(CultCache catalog) =>
catalog.GetAll<RecipeData>().Select(recipe => (catalog.RefOf(recipe).Key.Value, recipe)).OrderBy(pair => pair.Item1, StringComparer.Ordinal);
// Every rule but Recipe reads recipes through here, so a list a record holds as null (or with a null entry) reads as the list
// without it; Recipe alone names the malformation. The stored document is never changed.
private static IEnumerable<(string Key, RecipeData Recipe)> AllRecipes(CultCache catalog) =>
StoredRecipes(catalog).Select(pair => (pair.Key, Sound(pair.Recipe)));
private static RecipeData Sound(RecipeData recipe) =>
recipe.Inputs != null && recipe.Upkeep != null && recipe.Fills != null && recipe.RoleInputs != null
&& !recipe.Inputs.Contains(null) && !recipe.Upkeep.Contains(null) && !recipe.Fills.Contains(null) && !recipe.RoleInputs.Contains(null)
? recipe
: new RecipeData
{
Output = recipe.Output, OutputCount = recipe.OutputCount, Cycle = recipe.Cycle, Workforce = recipe.Workforce,
StationKind = recipe.StationKind, LoreHook = recipe.LoreHook,
Inputs = (recipe.Inputs ?? new List<CommodityCount>()).Where(c => c != null).ToList(),
Upkeep = (recipe.Upkeep ?? new List<CommodityCount>()).Where(c => c != null).ToList(),
Fills = (recipe.Fills ?? new List<string>()).Where(f => f != null).ToList(),
RoleInputs = (recipe.RoleInputs ?? new List<ItemRole>()).Where(r => r != null).ToList()
};
// The designs some faction offers, but a Station or Turret hull, distinct and ordered by key: the designs that need a recipe.
private static List<ItemData> OfferedDesigns(CultCache catalog)
{
var designs = new Dictionary<string, ItemData>(StringComparer.Ordinal);
foreach (var product in catalog.GetAll<FactionProductData>().Where(p => LoadoutGenerator.IsOffered(catalog, p)))
{
var design = catalog.Get(product.Design);
if (design == null || (design is HullData hull && (hull.HullType == HullType.Station || hull.HullType == HullType.Turret))) continue;
designs.TryAdd(catalog.RefOf(design).Key.Value, design);
}
return designs.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => pair.Value).ToList();
}
// The roles some design demands: those of every offered design and of every design a recipe makes, recipe or no.
private static HashSet<string> DesignRoles(CultCache catalog) =>
new HashSet<string>(OfferedDesigns(catalog).Concat(AllRecipes(catalog).Select(p => catalog.Get(p.Recipe.Output)).Where(o => o != null && !Recipes.IsCommodity(o)))
.SelectMany(design => Recipes.RolesOf(design)).Select(role => role.Name), StringComparer.Ordinal);
private static IEnumerable<ItemData> Commodities(CultCache catalog) =>
catalog.GetAll<ItemData>().Where(Recipes.IsCommodity).OrderBy(item => catalog.RefOf(item).Key.Value, StringComparer.Ordinal);
// Every commodity and every offered design (but a Station or Turret hull) has a recipe, and each recipe is well formed.
public static List<EconomyFault> Recipe(CultCache catalog)
{
var faults = new List<EconomyFault>();
void Add(string record, string message) => faults.Add(new EconomyFault(LRecipe, record, message));
foreach (var item in Commodities(catalog))
if (Recipes.RecipeOf(catalog, item) == null)
faults.Add(new EconomyFault(LRecipe, catalog.RefOf(item).Key.Value, $"Commodity '{item.Name}' has no recipe.", pending: true));
foreach (var design in OfferedDesigns(catalog))
if (Recipes.RecipeOf(catalog, design) == null)
faults.Add(new EconomyFault(LRecipe, catalog.RefOf(design).Key.Value, $"Offered design '{design.Name}' has no recipe.", pending: true));
foreach (var market in catalog.GetAll<OutsideMarketData>())
{
var marketKey = catalog.RefOf(market).Key.Value;
if (market.Rows == null) Add(marketKey, "Rows is null; an outside market holds a list, empty when it trades nothing.");
else if (market.Rows.Contains(null)) Add(marketKey, "Rows holds a null entry.");
}
var kindsPlaced = new HashSet<string>(catalog.GetAll<Faction>().SelectMany(f => StationKinds.KindsOf(catalog, f)).Select(k => k.Key.Value), StringComparer.Ordinal);
foreach (var (key, stored) in StoredRecipes(catalog))
{
var recipe = Sound(stored);
foreach (var (list, name) in new[] { (stored.Inputs, "Inputs"), (stored.Upkeep, "Upkeep") })
if (list == null) Add(key, $"{name} is null; a recipe holds a list, empty when it takes nothing.");
else if (list.Contains(null)) Add(key, $"{name} holds a null entry.");
if (stored.Fills == null) Add(key, "Fills is null; a recipe holds a list, empty when it fills no role.");
else if (stored.Fills.Contains(null)) Add(key, "Fills holds a null entry.");
if (stored.RoleInputs == null) Add(key, "RoleInputs is null; a recipe holds a list, empty when it takes no role.");
else if (stored.RoleInputs.Contains(null)) Add(key, "RoleInputs holds a null entry.");
var output = catalog.Get(recipe.Output);
if (output == null) { Add(key, "Output names no item this catalog holds."); continue; }
if (key != RecipeData.KeyOf(recipe.Output.Key).Value)
Add(key, "The recipe's key is not recipe:<output key>; one recipe per output is its key.");
if (recipe.OutputCount <= 0) Add(key, "OutputCount is not above zero.");
if (!float.IsFinite(recipe.Cycle) || recipe.Cycle <= 0) Add(key, "Cycle is not a finite number above zero.");
if (recipe.Workforce < 0) Add(key, "Workforce is below zero.");
foreach (var (list, name) in new[] { (recipe.Inputs, "Inputs"), (recipe.Upkeep, "Upkeep") })
for (var i = 0; i < list.Count; i++)
{
if (list[i].Count <= 0) Add(key, $"{name}[{i}] count is not above zero.");
if (!Recipes.IsCommodity(catalog.Get(list[i].Commodity))) Add(key, $"{name}[{i}] names no commodity; a recipe takes only commodities.");
}
var kind = catalog.Get(recipe.StationKind);
if (kind == null) Add(key, "StationKind names no station kind this catalog holds.");
else if (!kindsPlaced.Contains(recipe.StationKind.Key.Value))
faults.Add(new EconomyFault(LOneKindList, key, $"No faction places station kind '{kind.Name}', so nothing can run this recipe."));
var roles = Recipes.RolesOf(output);
if (!Recipes.IsCommodity(output))
{
if (recipe.Fills.Count > 0) Add(key, "Fills is set on a design; only a commodity fills a role.");
if (roles.Count == 0) Add(key, "A design's recipe needs a design with at least one role.");
}
if (roles.Count > 0 && recipe.Inputs.Count > 0) Add(key, "A role recipe has no Inputs; its inputs are its design's roles.");
if (roles.Count > 0 && recipe.RoleInputs.Count > 0) Add(key, "A role recipe has no RoleInputs; its role inputs are its design's roles.");
foreach (var role in Recipes.RoleInputsOf(catalog, recipe))
{
if (string.IsNullOrWhiteSpace(role.Name)) { Add(key, "A role input has an empty name."); continue; }
if (role.Count < 1) Add(key, $"Role '{role.Name}' count is below one.");
if (Recipes.Fillers(catalog, role.Name).Length == 0) Add(key, $"Role '{role.Name}' has no commodity whose recipe fills it.");
}
}
return faults;
}
// A commodity's lore hook is present. What it says is the operator's review.
public static List<EconomyFault> Lore(CultCache catalog) =>
AllRecipes(catalog).Where(pair => Recipes.IsCommodity(catalog.Get(pair.Recipe.Output)) && string.IsNullOrWhiteSpace(pair.Recipe.LoreHook))
.Select(pair => new EconomyFault(LLore, pair.Key, "A commodity's recipe needs a lore hook.")).ToList();
private static (HashSet<string> Sells, HashSet<string> Buys) Gate(CultCache catalog)
{
var rows = catalog.GetAll<OutsideMarketData>().SelectMany(market => market.Rows ?? new List<OutsideMarketRow>()).Where(r => r != null).ToList();
return (new HashSet<string>(rows.Where(r => r.Sells).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal),
new HashSet<string>(rows.Where(r => r.Buys).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal));
}
// The commodities an offered design's weapon draws as ammunition. One reader, shared by the sink and the consumer-demand rules.
private static HashSet<string> Drawn(CultCache catalog) =>
new HashSet<string>(catalog.GetAll<FactionProductData>().Where(p => LoadoutGenerator.IsOffered(catalog, p))
.Select(p => catalog.Get(p.Design) as EquippableItemData).Where(design => design?.Behaviors != null)
.SelectMany(design => design.Behaviors.OfType<WeaponData>()).Where(weapon => weapon.AmmoType.IsSet())
.Select(weapon => weapon.AmmoType.Key.Value), StringComparer.Ordinal);
// Every commodity has a source (a grounded chain, or a gate that sells it) and a sink (a recipe takes it or a role it fills, a role
// some design (offered, or made by a recipe) names, a weapon some offered design carries draws it, or a gate that buys it). A consumer good's sink is judged once needs
// tables exist.
public static List<EconomyFault> SourceSink(CultCache catalog)
{
var faults = new List<EconomyFault>();
var (sells, buys) = Gate(catalog);
var drawn = Drawn(catalog);
var grounded = Depths(catalog, sells);
var recipes = AllRecipes(catalog).ToList();
var used = new HashSet<string>(recipes.SelectMany(p => p.Recipe.Inputs.Concat(p.Recipe.Upkeep)).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal);
var rolesNamed = DesignRoles(catalog);
rolesNamed.UnionWith(recipes.SelectMany(p => Recipes.RoleInputsOf(catalog, p.Recipe)).Select(r => r.Name));
foreach (var (key, recipe) in recipes)
{
var output = catalog.Get(recipe.Output);
if (!Recipes.IsCommodity(output)) continue;
var outputKey = recipe.Output.Key.Value;
if (!grounded.TryGetValue(outputKey, out var depth) || depth == null)
faults.Add(new EconomyFault(LSource, key, $"'{output.Name}' has no source: no chain of recipes reaches raw goods and no gate sells it."));
if (Recipes.IsConsumerGood(catalog, recipe)) continue;
if (!used.Contains(outputKey) && !recipe.Fills.Any(rolesNamed.Contains) && !drawn.Contains(outputKey) && !buys.Contains(outputKey))
faults.Add(new EconomyFault(LSource, key, $"'{output.Name}' has no sink: no recipe takes it or a role it fills, no design names a role it fills, no weapon draws it, no gate buys it."));
}
return faults;
}
// Production steps to each good: raw goods 0, a recipe's output one more than its deepest input or role, a role as deep as
// its shallowest filler (Knuth's least fixpoint over AND and OR nodes). Null for a good no finite chain reaches. Reported for
// review; it refuses nothing.
public static IReadOnlyDictionary<string, int?> Deep(CultCache catalog) => Depths(catalog, new HashSet<string>());
private static Dictionary<string, int?> Depths(CultCache catalog, ISet<string> seeds)
{
var depth = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var seed in seeds) depth[seed] = 0;
var recipes = AllRecipes(catalog).Where(p => catalog.Get(p.Recipe.Output) != null).ToList();
bool changed;
do
{
changed = false;
foreach (var (_, recipe) in recipes)
{
var candidate = Candidate(catalog, recipe, depth);
var key = recipe.Output.Key.Value;
if (candidate == null || (depth.TryGetValue(key, out var known) && known <= candidate)) continue;
depth[key] = candidate.Value;
changed = true;
}
} while (changed);
var result = new Dictionary<string, int?>(StringComparer.Ordinal);
foreach (var item in Commodities(catalog)) result[catalog.RefOf(item).Key.Value] = null;
foreach (var (_, recipe) in recipes) result[recipe.Output.Key.Value] = null;
foreach (var pair in depth) result[pair.Key] = pair.Value;
return result;
}
private static int? Candidate(CultCache catalog, RecipeData recipe, Dictionary<string, int> depth)
{
if (Recipes.IsRaw(catalog, recipe)) return 0;
var deepest = 0;
foreach (var input in recipe.Inputs)
{
if (!depth.TryGetValue(input.Commodity.Key.Value, out var d)) return null;
deepest = Math.Max(deepest, d);
}
foreach (var role in Recipes.RoleInputsOf(catalog, recipe))
{
int? shallowest = null;
foreach (var filler in Recipes.Fillers(catalog, role.Name))
if (depth.TryGetValue(filler.Value, out var d) && (shallowest == null || d < shallowest)) shallowest = d;
if (shallowest == null) return null;
deepest = Math.Max(deepest, shallowest.Value);
}
return deepest + 1;
}
// Hawkins-Simon over commodities plus one labour good, at the worst single filler of every role slot (a recipe's role inputs): the spectral radius
// of the requirements matrix stays below one however the roles are filled. The worst fill is the spectral simplex
// (Nesterov and Protasov 2013) on the matrix nudged strictly positive, then the radius of the chosen matrix is taken exactly.
public static List<EconomyFault> Productive(CultCache catalog, EconomyLoad load = null)
{
load = load ?? new EconomyLoad();
if (!(load.InputFactor >= 1) || !(load.WorkforceFactor >= 1) || !(load.UpkeepFactor >= 1))
throw new ArgumentException("Setting factors are at least 1.", nameof(load));
var columns = AllRecipes(catalog).Where(p => Recipes.IsCommodity(catalog.Get(p.Recipe.Output)) && p.Recipe.OutputCount > 0 && float.IsFinite(p.Recipe.Cycle)).ToList();
var names = columns.Select(p => p.Recipe.Output.Key.Value).ToList();
var index = new Dictionary<string, int>(StringComparer.Ordinal);
for (var i = 0; i < names.Count; i++) index[names[i]] = i;
var n = names.Count + 1;
var labour = names.Count;
var fixedTerms = new double[n, n];
var slots = new List<(int Column, double Amount, int[] Fillers)>();
for (var j = 0; j < columns.Count; j++)
{
var recipe = columns[j].Recipe;
foreach (var input in recipe.Inputs)
if (index.TryGetValue(input.Commodity.Key.Value, out var row) && input.Count > 0) fixedTerms[row, j] += input.Count * load.InputFactor / recipe.OutputCount;
foreach (var upkeep in recipe.Upkeep)
if (index.TryGetValue(upkeep.Commodity.Key.Value, out var row) && upkeep.Count > 0) fixedTerms[row, j] += upkeep.Count * load.UpkeepFactor / recipe.OutputCount;
fixedTerms[labour, j] = recipe.Workforce * (double) recipe.Cycle * load.WorkforceFactor / recipe.OutputCount;
foreach (var role in Recipes.RoleInputsOf(catalog, recipe))
{
var fillers = Recipes.Fillers(catalog, role.Name).Where(k => index.ContainsKey(k.Value)).Select(k => index[k.Value]).ToArray();
if (fillers.Length > 0 && role.Count > 0) slots.Add((j, role.Count * load.InputFactor / recipe.OutputCount, fillers));
}
}
foreach (var need in load.LabourNeeds)
if (index.TryGetValue(need.Key, out var row)) fixedTerms[row, labour] = need.Value;
double[,] Build(int[] choice, double nudge)
{
var a = new double[n, n];
for (var r = 0; r < n; r++)
for (var c = 0; c < n; c++)
a[r, c] = fixedTerms[r, c] + nudge;
for (var s = 0; s < slots.Count; s++) a[slots[s].Fillers[choice[s]], slots[s].Column] += slots[s].Amount;
return a;
}
var pick = new int[slots.Count];
for (var round = 0; round < 500; round++)
{
var left = LeftPerron(Build(pick, 1e-12), n);
var moved = false;
for (var s = 0; s < slots.Count; s++)
{
var best = pick[s];
for (var f = 0; f < slots[s].Fillers.Length; f++)
if (left[slots[s].Fillers[f]] > left[slots[s].Fillers[best]] * (1 + 1e-9)) best = f;
if (best != pick[s]) { pick[s] = best; moved = true; }
}
if (!moved) break;
}
var final = Build(pick, 0);
var faults = new List<EconomyFault>();
string Name(int i) => i == labour ? "labour" : names[i];
foreach (var (rho, component) in Radii(final, n))
{
if (rho < 1 - Margin) continue;
var order = CycleOrder(final, n, component);
faults.Add(new EconomyFault(LProductive, Name(order[0]),
$"The goods cannot sustain themselves: spectral radius {rho:0.######} is not below 1, carried by {string.Join(" -> ", order.Select(Name))}.",
members: order.Select(Name).OrderBy(name => name, StringComparer.Ordinal).ToArray(), radius: rho));
}
return faults;
}
// Left Perron vector of a strictly positive matrix by power iteration on A + I (aperiodic, so a pure cycle converges too).
private static double[] LeftPerron(double[,] a, int n)
{
var u = Enumerable.Repeat(1.0 / n, n).ToArray();
for (var iteration = 0; iteration < 20000; iteration++)
{
var next = new double[n];
for (var c = 0; c < n; c++)
for (var r = 0; r < n; r++)
next[c] += u[r] * (a[r, c] + (r == c ? 1 : 0));
var norm = next.Sum();
var delta = 0.0;
for (var c = 0; c < n; c++) { next[c] /= norm; delta = Math.Max(delta, Math.Abs(next[c] - u[c])); }
u = next;
if (delta < 1e-15) break;
}
return u;
}
// The spectral radius of every strongly connected component of a nonnegative matrix, each by power iteration on A + I
// (primitive when irreducible). An edge j -> i is a nonzero A[i, j]. A component with no cycle has radius 0.
private static List<(double Rho, List<int> Component)> Radii(double[,] a, int n)
{
var reach = new bool[n, n];
for (var i = 0; i < n; i++)
for (var j = 0; j < n; j++)
reach[i, j] = i == j || a[j, i] > 0;
for (var k = 0; k < n; k++)
for (var i = 0; i < n; i++)
if (reach[i, k])
for (var j = 0; j < n; j++)
if (reach[k, j]) reach[i, j] = true;
var done = new bool[n];
var radii = new List<(double, List<int>)>();
for (var start = 0; start < n; start++)
{
if (done[start]) continue;
var component = Enumerable.Range(0, n).Where(i => reach[start, i] && reach[i, start]).ToList();
foreach (var i in component) done[i] = true;
var rho = Irreducible(a, component);
if (double.IsNaN(rho)) rho = double.PositiveInfinity;
radii.Add((rho, component));
}
return radii;
}
private static double Irreducible(double[,] a, List<int> nodes)
{
var m = nodes.Count;
var x = Enumerable.Repeat(1.0, m).ToArray();
var lambda = 0.0;
for (var iteration = 0; iteration < 200000; iteration++)
{
var next = new double[m];
for (var r = 0; r < m; r++)
{
next[r] = x[r];
for (var c = 0; c < m; c++) next[r] += a[nodes[r], nodes[c]] * x[c];
}
var norm = next.Max();
var moved = 0.0;
for (var r = 0; r < m; r++) { next[r] /= norm; moved = Math.Max(moved, Math.Abs(next[r] - x[r])); }
lambda = norm;
x = next;
if (moved < 1e-15) break;
}
return lambda - 1;
}
// The component's goods in cycle order: breadth-first from its first good along "needs" edges, so a simple cycle reads
// around once and a tangled component reads outward from where it starts.
private static List<int> CycleOrder(double[,] a, int n, List<int> component)
{
var inside = new HashSet<int>(component);
var order = new List<int> { component[0] };
var seen = new HashSet<int> { component[0] };
for (var at = 0; at < order.Count; at++)
for (var i = 0; i < n; i++)
if (a[i, order[at]] > 0 && inside.Contains(i) && seen.Add(i)) order.Add(i);
return order;
}
// A processed good owes a consumer use when it is shared (rulings consumer-demand-shared-only, draw-is-one-use). Its uses are
// the gear or hull roles, and the rounds some weapon draws, that the goods downstream of it (itself included) fill or are; a
// good used as upkeep is shared too. Shared means two or more such uses, or upkeep. A shared good must lead, through the
// chain (an input, upkeep or role-input edge: a role input is an edge from each of its fillers), into a consumer good or a role one names. A good whose every use is one gear role is exempt like its final assembly.
// Derived from the recipe graph on every check; depth and distance play no part. Raw goods and the gate count for nothing.
public static List<EconomyFault> ConsumerDemand(CultCache catalog)
{
var recipes = AllRecipes(catalog).Where(p => catalog.Get(p.Recipe.Output) != null).ToDictionary(p => p.Recipe.Output.Key.Value, p => p.Recipe, StringComparer.Ordinal);
var drawn = Drawn(catalog);
bool IsCommodityRecipe(RecipeData r) => Recipes.IsCommodity(catalog.Get(r.Output));
var gearRoles = DesignRoles(catalog);
var upkeep = new HashSet<string>(recipes.Values.SelectMany(r => r.Upkeep).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal);
var takenBy = recipes.Values.Where(IsCommodityRecipe)
.SelectMany(r => r.Inputs.Concat(r.Upkeep).Select(input => input.Commodity.Key.Value)
.Concat(Recipes.RoleInputsOf(catalog, r).SelectMany(role => Recipes.Fillers(catalog, role.Name)).Select(filler => filler.Value))
.Select(input => (Input: input, Output: r.Output.Key.Value)))
.ToLookup(edge => edge.Input, edge => edge.Output, StringComparer.Ordinal);
var faults = new List<EconomyFault>();
foreach (var key in recipes.Keys.OrderBy(k => k, StringComparer.Ordinal))
{
var recipe = recipes[key];
if (!IsCommodityRecipe(recipe) || Recipes.IsRaw(catalog, recipe) || Recipes.IsConsumerGood(catalog, recipe)) continue;
var reached = new HashSet<string>(StringComparer.Ordinal) { key };
var open = new Stack<string>(reached);
while (open.Count > 0)
foreach (var next in takenBy[open.Pop()])
if (reached.Add(next)) open.Push(next);
var members = reached.Select(k => recipes[k]).ToList();
if (members.Any(r => Recipes.IsConsumerGood(catalog, r))) continue;
var uses = members.SelectMany(r => r.Fills).Where(gearRoles.Contains).Distinct(StringComparer.Ordinal).OrderBy(role => role, StringComparer.Ordinal)
.Concat(reached.Where(drawn.Contains).OrderBy(k => k, StringComparer.Ordinal).Select(k => catalog.Get(recipes[k].Output).Name)).ToList();
var asUpkeep = reached.Any(upkeep.Contains);
if (asUpkeep) uses.Add("upkeep");
if (uses.Count < 2 && !asUpkeep) continue;
var list = uses.Count == 1 ? uses[0] : string.Join(", ", uses.Take(uses.Count - 1)) + " and " + uses[uses.Count - 1];
faults.Add(new EconomyFault(LDemand, RecipeData.KeyOf(new CultRecordKey(key)).Value,
$"'{catalog.Get(recipe.Output).Name}' leads into {list} but no use leads into a consumer good."));
}
return faults;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5e0d62a8f1c34b79b2f58ea3c6d19042
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -85,7 +85,11 @@ public abstract class Entity
// Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the
// hull turns only by Ship.Turn.
public float3 Aim;
public float3 Aim { get; internal set; }
// The entity's one pilot input, written by its one pilot (its agent, the player, a scripted pilot) and applied only by
// PilotApplier in the zone step. Runtime only: never packed.
public PilotCommand Command { get; } = new PilotCommand();
public string Name;
@ -169,7 +173,7 @@ public abstract class Entity
public bool HeatsinksEnabled
{
get => _heatsinksEnabled;
set
internal set
{
if (value == _heatsinksEnabled) return;
_heatsinksEnabled = value;
@ -182,10 +186,14 @@ public abstract class Entity
public EntitySettings Settings { get; }
public bool OverrideShutdown { get; set; }
public bool OverrideShutdown { get; internal set; }
// The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it.
public float TractorCommand;
public float TractorCommand { get; internal set; }
// The give the pilot has under way, and why the last one ended. CargoTransfer is the only writer of both.
public GiveOrder Giving { get; internal set; }
public GiveEnd? LastGiveEnd { get; internal set; }
// The beam's spun-up state: Pickup's, or 0 without one.
public float TractorPower => GetBehavior<Pickup>()?.Power ?? 0f;
@ -419,7 +427,7 @@ public abstract class Entity
// (clearing the aim) or a revealed item (FireControl.IsRevealed: this entity saw it act) belonging to the current
// Target. A fitted item the observer has only located is aimed as its mount, never as an item. Returns whether the
// write took effect; a rejected non-null selection leaves the aim unchanged.
public bool TrySelectTargetItem(EquippedItem item)
internal bool TrySelectTargetItem(EquippedItem item)
{
if (item == null)
{
@ -438,7 +446,7 @@ public abstract class Entity
// The aim slot's other writer: a mount of the hull this entity believes the Target to be (LayoutOf, by reference),
// unless this entity perceived that mount's item destroyed (SeenDestroyed). Reads only observer state, so whether a
// mount can be aimed at says nothing of the Target's truth (ruling cycle-skips-only-perceived-destroyed).
public bool TrySelectMount(HardpointData mount)
internal bool TrySelectMount(HardpointData mount)
{
if (!MountAimable(Target.Value.Entity, mount)) return false;
AimedAt = AimTarget.OfMount(mount);
@ -475,7 +483,7 @@ public abstract class Entity
// The player's cycle key: steps the aim to the next AimCycle entry after the resolved one and wraps; an entry its
// writer refuses (a revealed item since destroyed) is passed over; nothing to visit clears the aim.
public void CycleAim()
internal void CycleAim()
{
var subject = Target.Value.Entity;
if (subject == null) return;
@ -493,7 +501,7 @@ public abstract class Entity
// 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)
internal bool SetTarget(TargetRef target)
{
if (target.Chunk is ChunkId chunk && !ChunkVisible(chunk)) return false;
_target.Value = target;
@ -563,7 +571,7 @@ public abstract class Entity
// 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()
internal bool TargetUnderReticle()
{
var look = float2(Aim.x, Aim.z);
if (!PickTarget(new AngleKey(this, Position.xz, look), out var picked)) return false;
@ -571,7 +579,7 @@ public abstract class Entity
}
// Enemies only, as it drives weapon lock; the nearest by distance.
public bool TargetNearestEnemy()
internal bool TargetNearestEnemy()
{
Entity nearest = null;
var nearestDistance = float.PositiveInfinity;
@ -590,10 +598,10 @@ public abstract class Entity
// 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);
internal bool TargetNext() => CycleTarget(false);
// The reverse: the next one nearer, wrapping to the farthest; with nothing targeted, the farthest.
public bool TargetPrevious() => CycleTarget(true);
internal bool TargetPrevious() => CycleTarget(true);
private bool CycleTarget(bool reverse)
{
@ -1242,7 +1250,7 @@ public abstract class Entity
// Sets or clears a runtime-only IFF override deciding this entity's hostility toward another.
// Pass null to clear the override and restore the derived (faction-based) rule.
public void SetIff(Entity other, bool? hostile)
internal void SetIff(Entity other, bool? hostile)
{
if (hostile.HasValue) _iffOverrides[other] = hostile.Value;
else _iffOverrides.Remove(other);
@ -1307,7 +1315,7 @@ public abstract class Entity
public static bool IsDockingPermitted(FactionRelationship relationship, SecurityLevel securityLevel) =>
(int) relationship - (int) securityLevel > 1;
public void ActivateConsumable(ConsumableItem item)
internal void ActivateConsumable(ConsumableItem item)
{
var effect = new ConsumableItemEffect(item, this);
// The same hook Activate runs over Equipment: a behaviour that computes its targets there (StatModifier)
@ -1346,7 +1354,7 @@ public abstract class Entity
public bool HasActiveConsumable(Predicate<BehaviorData> kind) =>
_activeConsumables.Any(e => e.Data.Behaviors.Any(b => kind(b)));
public bool TryActivateConsumable(ConsumableItemData data)
internal bool TryActivateConsumable(ConsumableItemData data)
{
// Death and docking Remove and Deactivate the ship while its agent keeps ticking.
if (!_active) return false;
@ -1456,42 +1464,6 @@ public abstract class Entity
}
}
// Attempts to move a given number of items of the given type to the target Entity
// Returns the number of items successfully transferred
public int TryTransferItems(Entity target, CultRecordKey itemDataID, int quantity)
{
int quantityTransferred = 0;
while (quantityTransferred < quantity)
{
EquippedCargoBay originInventory = CargoBays.FirstOrDefault(c => c.ItemsOfType.ContainsKey(itemDataID));
if (originInventory == null) break;
var itemInstance = originInventory.ItemsOfType[itemDataID][0];
if (itemInstance is SimpleCommodity simpleCommodity)
{
var targetQuantity = min(simpleCommodity.Quantity, quantity - quantityTransferred);
if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, simpleCommodity, targetQuantity)))
{
quantityTransferred += targetQuantity - simpleCommodity.Quantity;
break;
}
quantityTransferred += targetQuantity;
}
else if (itemInstance is CraftedItemInstance craftedItemInstance)
{
if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, craftedItemInstance)))
break;
quantityTransferred++;
}
}
return quantityTransferred;
}
public EquippableItem TryUnequip(EquippedItem item)
{
// Don't allow unequipping when the entity is active
@ -1553,7 +1525,8 @@ public abstract class Entity
// Check whether the given item will fit when its origin is placed at the given coordinate. For hardpoint gear,
// hardpoint is the one hardpoint the item would occupy there (HardpointAt); Tool gear occupies none.
private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord, out HardpointData hardpoint)
private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord, out HardpointData hardpoint,
EquippedItem moving = null)
{
hardpoint = null;
// Items without specific hardpoints on the ship can be freely rotated and placed anywhere
@ -1566,13 +1539,13 @@ public abstract class Entity
// general gear may take it until hardpoint gear claims it, which is the same rule
// UnoccupiedSpace reports, so what generation is offered and what equipping accepts agree.
var itemCoord = hullCoord + itemData.Shape.Rotate(i, item.Rotation);
if (!hullData.InteriorCells[itemCoord] || GearOccupancy[itemCoord.x, itemCoord.y] != null)
if (!hullData.InteriorCells[itemCoord] || !IsFree(itemCoord.x, itemCoord.y, moving))
return false;
}
}
else
{
hardpoint = HardpointAt(itemData, hullData, hullCoord);
hardpoint = HardpointAt(itemData, hullData, hullCoord, moving);
if (hardpoint == null) return false;
item.Rotation = hardpoint.Rotation;
}
@ -1580,27 +1553,33 @@ public abstract class Entity
return true;
}
// A cell is free when no gear occupies it, or when the only occupant is the item being moved: a re-place of
// equipped gear is judged against the hull minus that item's own cells (the caller unequips, then equips).
private bool IsFree(int x, int y, EquippedItem moving) => GearOccupancy[x, y] == null || GearOccupancy[x, y] == moving;
// The one placement rule for hardpoint gear. At this origin the item lies within a hardpoint of its type, turned to
// that hardpoint's rotation (HardpointData.TakesAt, the fit rule); its own cells are free, since a hardpoint's
// leftover cells may hold general gear (operator, 2026-09-30: "saving slots in a hardpoint can be a valid tradeoff
// for crowded ships"); and that hardpoint holds no other hardpoint item, since it holds one at most.
private HardpointData HardpointAt(EquippableItemData itemData, HullData hullData, int2 hullCoord)
private HardpointData HardpointAt(EquippableItemData itemData, HullData hullData, int2 hullCoord, EquippedItem moving)
{
foreach (var hardpoint in hullData.Hardpoints)
{
if (!hardpoint.TakesAt(itemData, hullCoord - hardpoint.Position)) continue;
var ownCellsFree = itemData.Shape.Coordinates
.Select(v => hullCoord + itemData.Shape.Rotate(v, hardpoint.Rotation))
.All(c => GearOccupancy[c.x, c.y] == null);
.All(c => IsFree(c.x, c.y, moving));
var holdsAnother = hardpoint.Shape.Coordinates
.Select(v => GearOccupancy[hardpoint.Position.x + v.x, hardpoint.Position.y + v.y])
.Any(occupant => occupant != null && occupant.Data.HardpointType != HardpointType.Tool);
.Any(occupant => occupant != null && occupant != moving && occupant.Data.HardpointType != HardpointType.Tool);
if (ownCellsFree && !holdsAnother) return hardpoint;
}
return null;
}
// Check whether the given item will fit when its origin is placed at the given coordinate on the hull
// Check whether the given item will fit when its origin is placed at the given coordinate on the hull. An item that
// is already equipped here is judged without its own cells, so a gun can be moved within its own hardpoint;
// TryEquip stays strict (it never sees an item as its own occupant), so equipping an equipped item twice is refused.
public bool ItemFits(EquippableItem item, int2 hullCoord)
{
// Don't allow equipping while deployed
@ -1608,7 +1587,7 @@ public abstract class Entity
var itemData = ItemManager.GetData(item);
var hullData = ItemManager.GetData(Hull) as HullData;
return ItemFits(itemData, hullData, item, hullCoord, out _);
return ItemFits(itemData, hullData, item, hullCoord, out _, Equipment.FirstOrDefault(e => e.EquippableItem == item));
}
public bool TryFindSpace(EquippableItem item, out int2 hullCoord)
@ -1748,6 +1727,7 @@ public abstract class Entity
if (bay != null)
{
bay.DockedShip = ship;
CargoTransfer.End(ship, GiveEnd.GiverDocked);
ship.SetParent(this);
Zone.Remove(ship);
ship.Deactivate();
@ -1809,13 +1789,18 @@ public abstract class Entity
Parent = null;
}
// Indexed, not foreach: Equipment is a ReactiveCollection whose enumerator is heap-allocated, and every round a gun fires
// asks for the targeting system several times (rounds per step: hundreds of rounds a step must not allocate each).
public T GetBehavior<T>() where T : Behavior
{
foreach (var equippedItem in Equipment)
if(equippedItem.Behaviors != null)
foreach (var behavior in equippedItem.Behaviors)
if (behavior is T b)
return b;
for (var i = 0; i < Equipment.Count; i++)
{
var behaviors = Equipment[i].Behaviors;
if (behaviors == null) continue;
foreach (var behavior in behaviors)
if (behavior is T b)
return b;
}
return null;
}
@ -1843,6 +1828,13 @@ public abstract class Entity
public virtual void Update(float delta)
{
TurnRate = 0;
// A parented entity (a docked ship) sits on its parent, active or not: docking deactivates the ship, so this pin
// comes before the inactive return, or the ship keeps whatever pose its own physics last gave it.
if (Parent != null)
{
Position = Parent.Position;
Velocity = Parent.Velocity;
}
if (!_active) return;
var velocityBefore = Velocity;
@ -1959,12 +1951,7 @@ public abstract class Entity
foreach(var child in Children)
child.Update(delta);
if (Parent != null)
{
Position = Parent.Position;
Velocity = Parent.Velocity;
}
else Position.y = RideHeight(Position.xz);
if (Parent == null) Position.y = RideHeight(Position.xz);
Acceleration = delta > 0f ? (Velocity + LimitClamp - velocityBefore) / delta : float2(0, 0);
Manoeuvre.Observe(Acceleration, delta, ItemManager.GameplaySettings.SolutionWindow);
@ -2166,7 +2153,7 @@ public class EquippedItem : IStatContext
public ReactiveProperty<bool> ThermalOnline { get; } = new ReactiveProperty<bool>(false);
public ReactiveProperty<bool> DurabilityOnline { get; } = new ReactiveProperty<bool>(false);
public ReadOnlyReactiveProperty<bool> Online { get; }
public ReactiveProperty<bool> Enabled { get; } = new ReactiveProperty<bool>(true);
internal ReactiveProperty<bool> Enabled { get; } = new ReactiveProperty<bool>(true);
public ReadOnlyReactiveProperty<bool> Active { get; }
public ItemManager ItemManager { get; }
@ -2557,9 +2544,12 @@ public class EquippedCargoBay : EquippedItem
// Try to store the given commodity at the given position
// If there's a stack at the given position it will be added to
// Returns true only when ALL of the items are successfully stored
// The bay decides what quantity may sit in it: a stack below one unit or above its design's MaxStack is refused with
// the bay as it was, so no caller, loot, pickup or restore has to check for it.
public bool TryStore(SimpleCommodity item, int2 cargoCoord)
{
var itemData = ItemManager.GetData(item);
if (item.Quantity < 1 || item.Quantity > itemData.MaxStack) return false;
if (ItemFits(item, cargoCoord))
{
foreach (var p in itemData.Shape.Coordinates)
@ -2623,8 +2613,10 @@ public class EquippedCargoBay : EquippedItem
return true;
}
// The part removed: null, with the bay as it was, when the item is not here or the quantity is below one unit.
public SimpleCommodity Remove(SimpleCommodity item, int quantity)
{
if (quantity < 1) return null;
if (!Cargo.ContainsKey(item))
{
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
@ -2685,6 +2677,7 @@ public class EquippedCargoBay : EquippedItem
public bool TryTransferItem(EquippedCargoBay target, SimpleCommodity item, int quantity)
{
if (quantity < 1) return false;
if (!Cargo.ContainsKey(item))
{
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
@ -2693,10 +2686,11 @@ public class EquippedCargoBay : EquippedItem
var oldPos = Cargo[item];
var newItem = Remove(item, quantity);
if (target.TryStore(item)) return true;
// Failed to transfer full quantity, move the remaining items back to their old slot
// The part removed goes across, never the stack left behind.
if (target.TryStore(newItem)) return true;
// Whole or nothing: TryStore takes all of the part or none of it, so a refusal puts the part back where it came from.
TryStore(newItem, oldPos);
return false;
}

View file

@ -16,7 +16,8 @@ public static class EntitySerializer
{
Orbit = new CultRecordRef<OrbitData>(orbital.OrbitData),
SecurityLevel = orbital.SecurityLevel,
SecurityRadius = orbital.SecurityRadius
SecurityRadius = orbital.SecurityRadius,
Kind = orbital.Kind
};
else if (entity is Ship ship)
pack = new ShipPack
@ -47,6 +48,51 @@ public static class EntitySerializer
return pack;
}
// A saved stack goes back where it was. The bay refuses what cannot sit in it, and the refusal is logged, never
// silent: a stack below one unit (the typed -5 purchase of old saves) is dropped. A stack above its design's MaxStack
// (an ordinary purchase before the bay capped stacks, or a catalog MaxStack since lowered) is a legitimate save: its
// first MaxStack units go back where it was and the rest is returned to be placed by SpillOversize once every stack
// has its own cell, so a spill cannot take a cell a later stack was saved in.
private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item,
List<(EquippedCargoBay home, SimpleCommodity rest)> spill)
{
if (item is SimpleCommodity commodity && commodity.Quantity > itemManager.GetData(commodity).MaxStack
&& itemManager.GetData(commodity).MaxStack >= 1)
{
var maxStack = itemManager.GetData(commodity).MaxStack;
var first = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = maxStack };
var rest = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = commodity.Quantity - maxStack };
if (!bay.TryStore(first, position)) rest.Quantity += first.Quantity;
spill.Add((bay, rest));
return;
}
if (bay.TryStore(item, position)) return;
var quantity = item is SimpleCommodity c ? $" x{c.Quantity}" : "";
itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}");
}
// Places the units an oversize saved stack left over, in stacks of at most MaxStack: the stack's own bay first, then
// the entity's other bays. Only the units that fit nowhere are dropped, logged with their design and quantity.
private static void SpillOversize(ItemManager itemManager, IReadOnlyList<EquippedCargoBay> bays,
List<(EquippedCargoBay home, SimpleCommodity rest)> spill)
{
foreach (var (home, rest) in spill)
{
var maxStack = itemManager.GetData(rest).MaxStack;
var remaining = rest.Quantity;
while (remaining > 0)
{
var chunk = new SimpleCommodity { Data = rest.Data, Rotation = rest.Rotation, Quantity = Math.Min(remaining, maxStack) };
var placed = home.TryStore(chunk) || bays.Where(b => b != home).Any(b => b.TryStore(chunk));
// A refused store may still have topped up a stack; what is left in the chunk found no room.
remaining -= Math.Min(remaining, maxStack) - (placed ? 0 : chunk.Quantity);
if (placed) continue;
itemManager.Log($"Dropped saved units no bay has room for: {itemManager.GetData(rest).Name} x{remaining}");
break;
}
}
}
public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack)
{
return pack switch
@ -75,6 +121,7 @@ public static class EntitySerializer
Restore(itemManager, zone, pack, entity);
entity.SecurityLevel = pack.SecurityLevel;
entity.SecurityRadius = pack.SecurityRadius;
entity.Kind = pack.Kind;
if (pack.Story >= 0) entity.Story = zone.GalaxyZone.Locations[pack.Story];
return entity;
}
@ -100,15 +147,18 @@ public static class EntitySerializer
entity.DockingBays[i].DockedShip = entity.Children[pack.DockingBayAssignments[i]] as Ship;
}
var spill = new List<(EquippedCargoBay home, SimpleCommodity rest)>();
for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++)
if(entity.CargoBays.Count >= bayIndex + 1)
foreach (var (position, item) in pack.CargoContents[bayIndex])
entity.CargoBays[bayIndex].TryStore(item, position);
RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item, spill);
for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++)
if(entity.DockingBays.Count >= bayIndex + 1)
foreach (var (position, item) in pack.DockingBayContents[bayIndex])
entity.DockingBays[bayIndex].TryStore(item, position);
RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item, spill);
SpillOversize(itemManager, entity.CargoBays.Cast<EquippedCargoBay>().Concat(entity.DockingBays).ToList(), spill);
entity.Temperature = pack.Temperature;
@ -166,6 +216,7 @@ public class OrbitalEntityPack : EntityPack
[Key(18)] public int Story = -1;
[Key(19)] public SecurityLevel SecurityLevel;
[Key(20)] public float SecurityRadius;
[Key(22)] public CultRecordRef<StationKindData> Kind;
}
[MessagePackObject,
@ -189,7 +240,7 @@ public abstract class EntityPack
[Key(14)] public EntitySettings Settings;
[Key(15)] public CultRecordRef<Faction> Faction;
[Key(16)] public int[][] WeaponGroups;
// Keys 17-20 belong to the subclasses; the base's next free key is 21.
// Keys 17-20 belong to the subclasses; the base's next free key is 23 (Boss is 21, the orbital pack's Kind is 22).
[Key(21)] public bool Boss;
private int _price;
@ -243,4 +294,7 @@ public abstract class EntityPack
return _price;
}
// Element identity: minted by the cache on write; "" means unset.
[Key(23)] [CultElementId] public string Id = "";
}

View file

@ -152,27 +152,6 @@ public enum MegaPlacementType
Isolated = 4
}
public enum SimpleCommodityCategory
{
Minerals = 0,
Metals = 1,
Alloys = 2,
Compounds = 3,
Organics = 4,
Ammo = 5,
Consumer = 6
}
public enum CompoundCommodityCategory
{
Wearables = 0,
Consumables = 1,
Luxuries = 2,
Tools = 3,
Manufacturing = 4,
Assemblies = 5
}
public enum LocationType
{
Station,

View file

@ -72,6 +72,8 @@ public class NoiseSettings
[Union(0, typeof(PowerBrush)),
Union(1, typeof(SimplexBrush)),
Union(2, typeof(AnimatedSimplexBrush)),
Union(3, typeof(RadialWaveBrush)),
Serializable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public abstract class Brush
{

View file

@ -32,16 +32,13 @@ public static class Extensions
// Every loadable type assignable to this one. An assembly whose dependencies are missing (a Unity plugin
// referencing UnityEngine outside Unity) still yields the types that do load.
private static readonly Dictionary<Type, Type[]> ChildClasses = new Dictionary<Type, Type[]>();
public static Type[] GetAllChildClasses(this Type type)
// Read from parallel loaders (catalog validation, parallel tests), so the memo is concurrent.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Type, Type[]> ChildClasses = new System.Collections.Concurrent.ConcurrentDictionary<Type, Type[]>();
public static Type[] GetAllChildClasses(this Type type) => ChildClasses.GetOrAdd(type, t => AppDomain.CurrentDomain.GetAssemblies().SelectMany(assembly =>
{
if (ChildClasses.TryGetValue(type, out var children)) return children;
return ChildClasses[type] = AppDomain.CurrentDomain.GetAssemblies().SelectMany(assembly =>
{
try { return assembly.GetTypes(); }
catch (ReflectionTypeLoadException e) { return e.Types.Where(t => t != null); }
}).Where(type.IsAssignableFrom).ToArray();
}
try { return assembly.GetTypes(); }
catch (ReflectionTypeLoadException e) { return e.Types.Where(x => x != null); }
}).Where(t.IsAssignableFrom).ToArray());
public static string SplitCamelCase(this string str) =>
Regex.Replace(Regex.Replace(str, @"(\P{Ll})(\P{Ll}\p{Ll})", "$1 $2"), @"(\p{Ll})(\P{Ll})", "$1 $2");
@ -180,6 +177,21 @@ public static class Extensions
return mean + deviation * sqrt(-2 * log(u1)) * cos(2 * PI * u2);
}
// NextGaussian truncated below mean + floorDeviations * deviation: draws until one clears that cutoff, at most 64 times,
// then returns the cutoff itself. Exact for any reachable floor and bounded and deterministic per seed for an unreachable
// one. A binned product's fills and assembly (ProductBin). Callers clamp, as with NextGaussian.
public static float NextGaussianAbove(this ref Random random, float mean, float deviation, float floorDeviations)
{
var cutoff = mean + floorDeviations * deviation;
for (var tries = 0; tries < 64; tries++)
{
var draw = random.NextGaussian(mean, deviation);
if (draw >= cutoff) return draw;
}
return cutoff;
}
public static float NextUnbounded(this ref Random random) => 1 / (1 - random.NextFloat()) - 1;
public static float NextUnbounded(this ref Random random, float bias, float power, float ceiling) => 1 / (1 - pow(min(random.NextFloat(), ceiling), 1 - pow(clamp(bias,0,.99f), 1 / power))) - 1;

View file

@ -8,9 +8,9 @@ using GameCult.Caching;
using MessagePack;
using Newtonsoft.Json;
// A manufacturer's branded variant of a generic design: its name, its flavor text, and how it builds each of
// the design's roles. Which products exist is what a faction makes, so market segmentation and regional
// progression are authored here rather than as duplicate item entries.
// What a manufacturer sells of a generic design: its name, its flavor text, and either a line (how the maker builds each of the
// design's roles) or a bin of one of its own lines (maker-design-tiers-allowed). Which products exist is what a faction makes, so
// market segmentation and regional progression are authored here rather than as duplicate item entries.
[CultDocument("aetheria.factionproductdata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class FactionProductData
{
@ -29,16 +29,27 @@ public class FactionProductData
[Inspectable, JsonProperty("roles"), Key(5)]
public List<ProductRole> Roles = new List<ProductRole>();
// This line's own final test (maker-design-tiers-allowed): a binned or premium line is a better assembly as well as better
// parts. Null assembles as its maker does.
// This line's own final test (maker-design-tiers-allowed): a premium line is a better assembly as well as better parts.
// Null assembles as its maker does. A binned product leaves it null and reads its line's.
[Inspectable, JsonProperty("assembly"), Key(6)]
public AssemblyDistribution Assembly;
// Set on a binned product: the line it bins and the floor above which it takes that line's output. A bin authors no Roles and
// no Assembly; the mint derives both from the line.
[Inspectable, JsonProperty("bin"), Key(7)]
public ProductBin Bin;
// Off the market (ruling off-market-is-unoffered-product): the product keeps its maker, brand, quality and price, but no
// station stocks it, no generation or chooser draws it, and its hull gives no gear a home. It reaches a ship only through a
// Loadout that pins it. LoadoutGenerator.IsOffered is its one reader.
[Inspectable, JsonProperty("notOffered"), Key(8)]
public bool NotOffered;
}
// How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is
// the technology the manufacturer puts in and whose standard deviation is their quality control. A market
// segment is a second product with a role pumped up; what makes that part better is the flavor text's to tell,
// because a part here carries quality and nothing else.
// segment is a bin of the line (ProductBin), not a second product with a role pumped up; what makes a part better is the
// flavor text's to tell, because a part here carries quality and nothing else.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ProductRole
{
@ -50,6 +61,22 @@ public class ProductRole
[Inspectable, JsonProperty("standardDeviation"), Key(2)]
public float StandardDeviation = .15f;
// Element identity: minted by the cache on write; "" means unset.
[Key(3)] [CultElementId] public string Id = "";
}
// A bin of one of the maker's lines (ruling maker-design-tiers-allowed): the line it bins (the same design and maker, itself
// unbinned) and the floor, in standard deviations above the mean of each of the line's distributions, below which the bin
// takes nothing. 0 is the top half of the line, .67 the top quarter.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ProductBin
{
[Inspectable, JsonProperty("line"), Key(0)]
public CultRecordRef<FactionProductData> Line;
[Inspectable, JsonProperty("floor"), Key(1)]
public float Floor;
}
// How well a maker puts the thing together: the technology and quality control of final assembly. A lot's own quality is drawn

View file

@ -84,8 +84,11 @@ public static class FireControl
// 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) =>
!IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, shooter.Target.Value.Entity).Outcome == FireOutcome.Refused;
public static bool Refuses(Weapon weapon, Entity shooter) => Refuses(weapon, shooter, shooter.Target.Value.Entity);
// The same answer against a target the shooter's pilot has commanded but not yet applied.
public static bool Refuses(Weapon weapon, Entity shooter, Entity target) =>
!IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, target).Outcome == FireOutcome.Refused;
// A mine layer lays a body, not a round: it is never refused, never arc-gated, and Fire routes it to Lay.
public static bool IsMineLayer(Weapon weapon) => weapon.WeaponData is MineLayerData;
@ -96,12 +99,19 @@ public static class FireControl
// fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and
// never one Solve refuses: a refused weapon is not a weapon it can use.
// The hit-probability threshold is the shooter's doctrine (RoleDoctrine.MinHitProbability), or the turret's global.
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability)
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability) =>
AgentFires(weapon, shooter, target, minHitProbability, null);
// The same decision priced on the aim the pilot commands this step (Combat), not the shooter's applied aim slot.
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget aim) =>
AgentFires(weapon, shooter, target, minHitProbability, (AimTarget?) aim);
private static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget? aim)
{
// A mine layer lays only at a designated target in range, never at nothing and never on a hit probability.
if (IsMineLayer(weapon)) return Solve(weapon, weapon.Item, shooter, target).Designated;
if (FuseOf(weapon.Item, out _) == null)
return HitProbability(weapon, shooter, target) >= minHitProbability;
return HitProbability(weapon, shooter, target, aim) >= minHitProbability;
var solution = Solve(weapon, weapon.Item, shooter, target);
return solution.Designated && solution.Outcome != FireOutcome.Refused;
}
@ -330,7 +340,7 @@ public static class FireControl
// common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0).
// Evasion fix (evasion-term): the same call also prices how far the target can and does move off the line of
// fire, along the same travel direction, so the live price and the HUD read one evasion.
private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range)
private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range, AimTarget aim)
{
var travel = TravelDirection(weapon, source, target);
var evasion = Evasion(target, travel);
@ -340,7 +350,6 @@ public static class FireControl
Silhouette sil;
try
{
var aim = source.ResolvedAim;
sil = Silhouette(target, targetHull, aim.Item, bearing, precision, buffer, aim.Mount);
var settings = source.ItemManager.GameplaySettings;
pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize);
@ -355,13 +364,21 @@ public static class FireControl
return (sil, pSpread, evasion);
}
public static float HitProbability(Weapon weapon, Entity source, Entity target)
public static float HitProbability(Weapon weapon, Entity source, Entity target) =>
HitProbability(weapon, source, target, (AimTarget?) null);
// Priced on `aim` rather than the source's aim slot: the aim its pilot commands this step.
public static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget aim) =>
HitProbability(weapon, source, target, (AimTarget?) aim);
// A null aim reads source.ResolvedAim, after the gate, so a gated-out shot reads nothing more.
private static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget? aim)
{
var pFire = PFire(weapon, source, target, out var range);
if (pFire <= 0f) return 0f;
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range);
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range, aim ?? source.ResolvedAim);
return pFire * PMount(AngularVelocity(source, target), TrackingRate(weapon, source)) * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source));
}
@ -399,7 +416,7 @@ public static class FireControl
diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info);
diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor;
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range);
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range, source.ResolvedAim);
diagnostic.PSpread = pSpread;
diagnostic.POnHull = sil.POnHull;
diagnostic.Evasion = evasion;
@ -681,7 +698,11 @@ public static class FireControl
// Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's
// authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation,
// unchanged). Every other caller keeps reading the frozen weapon.Damage it always did.
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null)
// Rounds per step (ruling rounds-per-step): a gun fires every round its cooldown allows in a step, so a round is born
// `age` seconds before the step's end, where the shooter and its target were then (carried back along their velocities).
// FireTime, the origin, the target's frozen pose and the arrival and commit times all follow, so a presenter draws the round
// from where it left and the commit measures the target's stray from the line it was fired along.
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null, float age = 0f)
{
var zone = source.Zone;
if (IsMineLayer(weapon))
@ -691,7 +712,7 @@ public static class FireControl
}
// 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;
var now = zone.Time - age;
// R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time
// re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share --
@ -707,14 +728,14 @@ public static class FireControl
var burstPosition = solution.BurstPosition;
var fuse = solution.Fuse;
var blastRadius = solution.BlastRadius;
var origin = source.Position;
var origin = source.Position - float3(source.Velocity.x, 0, source.Velocity.y) * age;
var targetVelocity = float3.zero;
var targetPosition = origin;
if (engaged != null)
{
targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y);
targetPosition = engaged.Position;
targetPosition = engaged.Position - targetVelocity * age;
}
var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f;
@ -762,7 +783,12 @@ public static class FireControl
// point is decided at its commit (CommitBurst), which reads it there. Each shot reads it once.
if (!shot.DeliveredAtCommit) Deliver(ref shot, solution.BurstReach);
zone.PendingShots.Add(shot);
// The queue is in fire-time order, ties in shot-id order (ids rise as shots are made): Step walks it oldest first. Rounds
// of one step are born shooter by shooter, so a later shooter's earlier round goes in before an earlier shooter's later one.
var queue = zone.PendingShots;
var place = queue.Count;
while (place > 0 && queue[place - 1].FireTime > shot.FireTime) place--;
queue.Insert(place, shot);
return shot.ShotId;
}
@ -775,7 +801,7 @@ public static class FireControl
// Lays a mine for a mine layer: freezes the payload as evaluated now (stats, the item's blast radius, the
// layer and its faction) and hands the body to Zone.Lay, the one adder. It launches at the shooter's velocity
// plus the mount direction turned by a dice angle within +-Spread/2, times Velocity; the dice are the mine's
// own (zone seed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price:
// own (the zone's DropSeed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price:
// InstantWeapon has already paid. A layer whose item has no blast radius lays nothing.
public static void Lay(Weapon weapon, EquippedItem item, Entity source)
{
@ -789,7 +815,7 @@ public static class FireControl
var id = zone.NextBodyId();
var half = weapon.Spread / 2f;
var angle = radians(SimulationDice.For(zone.CombatSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half));
var angle = radians(SimulationDice.For(zone.DropSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half));
var s = sin(angle);
var c = cos(angle);
var mount = MountDirection(item).xz;
@ -828,7 +854,10 @@ public static class FireControl
if (shots.Count == 0) return;
var now = zone.Time;
for (var i = shots.Count - 1; i >= 0; i--)
// Commits walk the queue in fire-time order, oldest first (Fire keeps it in it). Shots queued while the walk runs are
// left to the next step.
List<(float Arrival, int Id)> arrived = null;
for (int i = 0, end = shots.Count; i < end;)
{
var shot = shots[i];
@ -842,6 +871,7 @@ public static class FireControl
// target is gone. Republishing the committed outcome here used to put a hit marker on a corpse.
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
shots.RemoveAt(i);
end--;
if (!shot.Committed) zone.ShotCommitted.OnNext(miss);
zone.ShotResolved.OnNext(miss);
continue;
@ -862,16 +892,41 @@ public static class FireControl
if (shot.Committed && now >= shot.ArrivalTime)
{
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
// the same way; only Apply decides what that means.
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
// that throws would resolve, and hurt, again on every later tick.
shots.RemoveAt(i);
Apply(zone, shot);
zone.ShotResolved.OnNext(shot.Outcome);
(arrived ??= new List<(float, int)>()).Add((shot.ArrivalTime, shot.ShotId));
}
i++;
}
if (arrived == null) return;
// The rounds that arrive in this step apply in arrival order, ties in shot-id order (ids rise as shots are made), so
// which of them breaks a shield or lands a kill follows when each hit, not the order the shooters were stepped in or the
// order their rounds left.
arrived.Sort((a, b) => a.Arrival != b.Arrival ? a.Arrival.CompareTo(b.Arrival) : a.Id.CompareTo(b.Id));
foreach (var (_, id) in arrived)
{
var i = shots.FindIndex(q => q.ShotId == id);
if (i < 0) continue;
var shot = shots[i];
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
if (targetGone)
{
// As in the commit walk: a shot whose target left the zone in this step resolves as a miss.
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
shots.RemoveAt(i);
zone.ShotResolved.OnNext(miss);
continue;
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
// the same way; only Apply decides what that means.
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
// that throws would resolve, and hurt, again on every later tick.
shots.RemoveAt(i);
Apply(zone, shot);
zone.ShotResolved.OnNext(shot.Outcome);
}
}

View file

@ -28,9 +28,13 @@ public class Galaxy
private Action<string> Log { get; }
public bool IsPrelude { get; }
// The seed this galaxy was generated at, which also seeds its run's item draws (RunStart); zero for a galaxy
// restored from a save.
// The run's seed, minted once by GalaxyStage.SeedFrom and carried by the save (SavedGame.Seed). It seeds
// generation, the run's item draws (RunStart) and the zone's drop dice (Zone.DropSeed).
public uint Seed { get; }
// The run's resource provinces, ordered by seed zone index; GalaxyZone.Province is each zone's. Built once, by the
// generation constructors or the save constructor (BuildProvinces), and never by PlaceGate, faction placement or naming.
public GalaxyProvince[] Provinces { get; private set; }
private GalaxyZone[] _exitPath;
private readonly CultCache _cache;
@ -51,12 +55,17 @@ public class Galaxy
_cache = cultCache;
Log = log;
Background = savedGame.Background;
Seed = savedGame.Seed;
Factions = savedGame.Factions.Select(faction => cultCache.Get(faction)).ToArray();
for (var i = 0; i < Factions.Length; i++)
{
FactionRelationships[Factions[i]] = savedGame.Relationships[i];
}
if (savedGame.Standings != null)
foreach (var standing in savedGame.Standings)
FactionRelationships[cultCache.Get(standing.Faction)] = standing.Relationship;
else
// A save before Standings wrote Relationships in an order it did not record; it is right only for a
// galaxy that never reordered, and is all that save holds.
for (var i = 0; i < Factions.Length; i++)
FactionRelationships[Factions[i]] = savedGame.Relationships[i];
var savedZones = savedGame.Zones.Select(zone => cultCache.Get(zone)).ToArray();
Zones = savedZones.Select(zone =>
@ -88,6 +97,9 @@ public class Galaxy
if(savedGame.Exit != -1)
Exit = Zones[savedGame.Exit];
// A save from before ore geography has no provinces: it gets them by the rule generation uses.
BuildProvinces(global::Provinces.DefaultZonesPerProvince, savedGame.Provinces == null ? null : savedZones.Select(zone => zone.Province).ToArray(), savedGame.Provinces);
CalculateDistanceMatrix();
}
@ -96,15 +108,15 @@ public class Galaxy
SectorBackgroundSettings background,
CultCache cache,
Action<string> log,
Action<string> progressCallback = null,
uint seed = 0)
Action<string> progressCallback,
uint seed)
{
_cache = cache;
IsPrelude = false;
Background = background;
Log = log;
var factions = cache.GetAll<Faction>();
Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed;
Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero");
var random = new Random(Seed);
Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray();
foreach (var f in Factions) FactionRelationships[f] = FactionRelationship.Neutral;
@ -126,6 +138,8 @@ public class Galaxy
PlaceFactionsMain(settings.BossCount, progressCallback);
BuildProvinces(settings.ProvinceZones);
CalculateFactionInfluence(progressCallback);
NamePlaces(ref random, progressCallback);
@ -134,6 +148,39 @@ public class Galaxy
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
}
// Each zone's index and the links as indices: the graph Provinces reads.
private (Dictionary<GalaxyZone, int> Index, int[][] Adjacency) ZoneGraph()
{
var index = Zones.Select((zone, i) => (zone, i)).ToDictionary(pair => pair.zone, pair => pair.i);
return (index, Zones.Select(zone => zone.AdjacentZones.Select(next => index[next]).ToArray()).ToArray());
}
// Regions, then each region's provinces and their types (Provinces.Regions, Partition, Deal); the one builder, called by
// the generation constructors and the save constructor. A save passes its membership and types: membership is kept, and a
// saved type that still resolves is kept, any other taking the card the region's Deal gives that province's place.
private void BuildProvinces(int zonesPerProvince, int[] savedSeedOf = null, List<SavedProvince> savedTypes = null)
{
var (index, adjacency) = ZoneGraph();
var regionOf = global::Provinces.Regions(adjacency, BossZones.Values.Select(zone => index[zone]).ToArray(), Exit == null ? -1 : index[Exit]);
var seedOf = savedSeedOf ?? global::Provinces.Partition(adjacency, regionOf, Seed, zonesPerProvince);
var types = savedTypes?.ToDictionary(saved => saved.Seed, saved => saved.Type);
var provinces = new List<GalaxyProvince>();
foreach (var region in Enumerable.Range(0, Zones.Length).GroupBy(zone => regionOf[zone]).OrderBy(group => group.Key))
{
var seeds = region.Select(zone => seedOf[zone]).Distinct().OrderBy(seed => seed).ToArray();
var hand = global::Provinces.Deal(region.Key, seeds.Length, Seed, _cache);
for (var place = 0; place < seeds.Length; place++)
{
var type = types != null && types.TryGetValue(seeds[place], out var saved) && _cache.Get(saved) != null ? saved : hand[place];
var province = new GalaxyProvince {Seed = Zones[seeds[place]], Type = type, Zones = region.Where(zone => seedOf[zone] == seeds[place]).Select(zone => Zones[zone]).ToArray()};
foreach (var zone in province.Zones) zone.Province = province;
provinces.Add(province);
}
}
Provinces = provinces.OrderBy(province => index[province.Seed]).ToArray();
}
public Faction ResolveFaction(string name)
{
return _cache.GetAll<Faction>().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
@ -163,15 +210,15 @@ public class Galaxy
PlayerSettings playerSettings,
DirectoryInfo narrativeDirectory,
Action<string> log,
Action<string> progressCallback = null,
uint seed = 0)
Action<string> progressCallback,
uint seed)
{
_cache = cache;
IsPrelude = true;
Background = background;
Log = log;
Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed;
Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero");
var random = new Random(Seed);
var factions = new List<Faction>();
@ -255,6 +302,9 @@ public class Galaxy
CalculateFactionInfluence(progressCallback);
// No boss zone and no Exit yet (PlaceGate comes after): the prelude is one region.
BuildProvinces(settings.ProvinceZones);
NamePlaces(ref random, progressCallback);
// progressCallback?.Invoke("Weaving Narrative");
@ -288,9 +338,9 @@ public class Galaxy
progressCallback?.Invoke("Placing Factions");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
// Choose some megas to have bosses placed based on whether a boss hull is assigned
// Choose some megas to have bosses placed: the factions whose boss Loadout resolves
var bossMegas = Factions
.Where(m => m.BossHull.IsSet())
.Where(m => Loadouts.BossOf(_cache, m, out _) != null)
.Take(bossCount)
.ToArray();
@ -302,6 +352,10 @@ public class Galaxy
BossZones.Values.Aggregate(1, (i, os) => i * os.Distance[z]));
}
// One exit per region (ruling one-exit-per-region): prune the boss zones' extra links, then home placement below reads
// the distances of the graph that is left.
OneExitPerRegion(progressCallback);
// Place boss mega headquarters such that their sphere of influence encompasses their boss zone
// While occupying as much territory as possible
foreach (var mega in bossMegas)
@ -323,6 +377,21 @@ public class Galaxy
}
}
// Each boss zone keeps one link toward the exit, the one on the exit path, so there is one exit to defend where its boss
// parks (Provinces.ExtraExits picks the rest; ruling one-exit-per-region). Links are removed from both zones, and the
// distance matrix follows. A galaxy built from a save never comes here: its saved links are the world.
private void OneExitPerRegion(Action<string> progressCallback)
{
var (index, adjacency) = ZoneGraph();
var exitPath = ExitPath.Select(zone => index[zone]).ToArray();
foreach (var (boss, far) in global::Provinces.ExtraExits(adjacency, BossZones.Values.Select(zone => index[zone]).ToArray(), index[Exit], exitPath))
{
Zones[boss].AdjacentZones.Remove(Zones[far]);
Zones[far].AdjacentZones.Remove(Zones[boss]);
}
CalculateDistanceMatrix(progressCallback);
}
private void CalculateFactionInfluence(Action<string> progressCallback = null)
{
progressCallback?.Invoke("Calculating Faction Influence");
@ -570,10 +639,20 @@ public class GalaxyZone
public Faction Owner;
public Zone Contents;
public ZonePack PackedContents;
public GalaxyProvince Province;
public bool NamedZone;
public List<LocationStory> Locations = new List<LocationStory>();
}
// A resource province (ruling provinces-within-regions): a contiguous group of zones inside one region, grown from Seed,
// whose Type (ProvinceData, unset when the catalog holds none) weights the kinds and ores of its belts.
public class GalaxyProvince
{
public GalaxyZone Seed;
public CultRecordRef<ProvinceData> Type;
public GalaxyZone[] Zones;
}
public class GalaxyQuest
{
public Story Story;

View file

@ -46,7 +46,8 @@ public sealed class GalaxyStage
_clock = clock ?? (() => (uint) (DateTime.Now.Ticks % uint.MaxValue));
}
// The seed this generation runs at, never zero: the scenario's, or the clock's when the scenario's is zero.
// The run's one minter: the scenario's seed, or the clock's when the scenario's is zero, never zero. Galaxy carries
// what it mints and SavedGame keeps it, so Continue rolls the same drops.
public uint Seed { get; private set; }
internal void SeedFrom(Scenario scenario)

View file

@ -13,6 +13,21 @@ public class HelmInput
public void Toggle() => Latched = !Latched;
public float Demand(Ship ship, float2 aim, float turnAxis) =>
FaceAim ? Steering.Toward(ship, aim) : clamp(turnAxis, -1f, 1f);
private float _turnAxis;
// The turn axis as the helm reads it. A digital input (keys) ramps up while held and back down while released, both at
// full scale per rampTime, so tapping at a rate holds a gentle turn; reversing cuts to zero and ramps the other way.
// An analog input passes straight through.
public float TurnAxis(float raw, bool digital, float dt, float rampTime)
{
if (!digital || rampTime <= 0) return _turnAxis = raw;
var step = dt / rampTime;
if (raw == 0) return _turnAxis = sign(_turnAxis) * max(0f, abs(_turnAxis) - step);
if (sign(raw) != sign(_turnAxis)) _turnAxis = 0;
return _turnAxis = sign(raw) * min(abs(raw), abs(_turnAxis) + step);
}
// The turn the player commands: the aim heading, resolved against the hull at each sim step, or the turn axis.
public TurnCommand Demand(float2 aim, float turnAxis) =>
FaceAim ? TurnCommand.Toward(aim) : TurnCommand.OfRate(clamp(turnAxis, -1f, 1f));
}

View file

@ -29,7 +29,10 @@ public class InputLayout
Union(0, typeof(InputLayoutRowSpacer)),
Union(1, typeof(InputLayoutKeyRow))
]
public abstract class InputLayoutRow { }
public abstract class InputLayoutRow {
// Element identity: minted by the cache on write; "" means unset.
[Key(1)] [CultElementId] public string Id = "";
}
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class InputLayoutRowSpacer : InputLayoutRow
@ -52,6 +55,9 @@ public class InputLayoutKeyRow : InputLayoutRow
public abstract class InputLayoutColumn
{
[Key(0), JsonProperty("width")] public float Width;
// Element identity: minted by the cache on write; "" means unset.
[Key(5)] [CultElementId] public string Id = "";
}
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]

View file

@ -322,13 +322,11 @@ public abstract class ItemData
[CultDocument("aetheria.simplecommoditydata", "1"), Inspectable, MessagePackObject]
public class SimpleCommodityData : ItemData
{
// MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields; do not reuse them.
// MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields, and key 10 to the removed stored Category; do not reuse them.
[Inspectable, JsonProperty("maxStackSize"), Key(9)]
public int MaxStack = 10;
[Inspectable, JsonProperty("category"), Key(10)]
public SimpleCommodityCategory Category;
}
[JsonObject(MemberSerialization.OptIn)]
@ -339,24 +337,26 @@ public abstract class CraftedItemData : ItemData
public List<ItemRole> Roles = new List<ItemRole>();
}
// One slot in a design: every laser has a focusing array. A role is a name and nothing else. What fills it is
// a quality, authored per role by each manufacturer's product; the part itself earns a record when crafting
// needs one to exist.
// One slot in a design: every laser has a focusing array. A role is a named slot with a count: the design takes Count units
// of a part, and any commodity whose recipe Fills the role can be that part. What fills it is a quality, authored per role
// by each manufacturer's product. Records written before Count existed read it as 1.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ItemRole
{
[Inspectable, JsonProperty("name"), Key(0)]
public string Name;
[Inspectable, JsonProperty("count"), Key(1)]
public int Count = 1;
// Element identity: minted by the cache on write; "" means unset.
[Key(2)] [CultElementId] public string Id = "";
}
[CultDocument("aetheria.compoundcommoditydata", "1"), Inspectable, MessagePackObject]
public class CompoundCommodityData : CraftedItemData
{
[Inspectable, CultReference(typeof(PersonalityAttribute), many: true), JsonProperty("demandProfile"), Key(10)]
public Dictionary<CultRecordRef<PersonalityAttribute>, float> DemandProfile = new Dictionary<CultRecordRef<PersonalityAttribute>, float>();
[Inspectable, JsonProperty("category"), Key(11)]
public CompoundCommodityCategory Category;
// MessagePack keys 10 and 11 belonged to the removed demand weights and classification; do not reuse them.
}
[CultDocument("aetheria.consumableitemdata", "1"), Inspectable, MessagePackObject]
@ -420,11 +420,7 @@ public abstract class EquippableItemData : CraftedItemData
[Inspectable, CultInspectorAssetGuid, JsonProperty("actionIcon"), Key(20)]
public string ActionBarIcon;
[Inspectable, JsonProperty("soundBank"), Key(21)]
public uint SoundBank;
[Inspectable, JsonProperty("audioStats"), Key(22)]
public List<AudioStat> AudioStats = new List<AudioStat>();
// Keys 21 (soundBank) and 22 (audioStats) held Wwise ids; audio is native Unity plus AquaSynth now. Retired; never reuse them.
[IgnoreMember]
public abstract HardpointType HardpointType { get; }
@ -458,16 +454,6 @@ public abstract class EquippableItemData : CraftedItemData
}
}
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class AudioStat
{
[Inspectable, JsonProperty("parameter"), Key(0)]
public uint Parameter;
[Inspectable, JsonProperty("stat"), Key(1)]
public PerformanceStat Stat = new PerformanceStat();
}
[CultDocument("aetheria.geardata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class GearData : EquippableItemData
{
@ -555,6 +541,12 @@ public class HullData : EquippableItemData
[Inspectable, JsonProperty("visual"), Key(32)]
public CultRecordRef<ShipAuthoring> Visual;
// What a station hull is built to be (StationKinds): set on every Station hull and on no other. The hull names its kind
// and the kind names no hull, so a package can add a faction's model of a kind without editing any kind record.
// Key 33 is the next free key.
[Inspectable, JsonProperty("kind"), Key(33)]
public CultRecordRef<StationKindData> Kind;
// The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world
// units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not,
// as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5.
@ -646,6 +638,9 @@ public class HardpointData
}
private static Dictionary<HardpointType, float3> _tintColors;
// Element identity: minted by the cache on write; "" means unset.
[Key(8)] [CultElementId] public string Id = "";
}
// What a PerformanceStat reads to resolve one number: the lot it prices quality against, and the per-context
@ -691,6 +686,9 @@ public class StatTerm
// The design role whose part quality this term reads; unset reads the item's effective quality (Lot.EffectiveQuality).
// Njordr states the same rule as a derivation over dimensions; this is that rule with one dimension.
[Inspectable, JsonProperty("role"), Key(2)] public string Role;
// Element identity: minted by the cache on write; "" means unset.
[Key(3)] [CultElementId] public string Id = "";
}
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]

View file

@ -17,7 +17,9 @@ using float4 = CultMath.float4;
public class ItemManager
{
public Random Random = new Random((uint) (DateTime.Now.Ticks%uint.MaxValue));
// The item stream: each lot's quality and each generated fit. Seeded by whoever constructs the manager and reseeded
// by RunStart at each step of a new run, never by the clock.
public Random Random;
private Action<string> _logger;
@ -25,8 +27,10 @@ public class ItemManager
public ProvenanceLedger Lots { get; }
public GameplaySettings GameplaySettings { get; }
public ItemManager(CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action<string> logger)
public ItemManager(uint seed, CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action<string> logger)
{
if (seed == 0) throw new ArgumentOutOfRangeException(nameof(seed), "an item stream needs a nonzero seed");
Random = new Random(seed);
ItemData = itemData;
Lots = lots;
GameplaySettings = settings;
@ -121,6 +125,9 @@ public class ItemManager
// Builds one of a manufacturer's branded products (quality-is-assembly-over-supply-chain): the unit's own quality is its
// final assembly, drawn from the product's assembly distribution, else its maker's, else AssemblyDistribution's defaults,
// and each of the design's roles is filled with a part whose quality is drawn from the product's distribution for it.
// A binned product (maker-design-tiers-allowed) has no distributions of its own: it is its line's, derived here on every
// mint, with each draw (the assembly and every fill) truncated below the line's mean + Bin.Floor deviations. The lot is
// still the bin's: its Product, its title and so its brand.
public int CreateLot(FactionProductData product) => Mint(product, null);
// The same lot at a stated quality: the assembly and every role fill are that quality and nothing is drawn, so a staged
@ -130,29 +137,37 @@ public class ItemManager
private int Mint(FactionProductData product, float? stated)
{
var design = ItemData.Get(product.Design);
var assembly = product.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution();
var line = product.Bin != null ? ItemData.Get(product.Bin.Line) : null;
if (product.Bin != null && line == null)
Log("A binned product names a line the catalog does not hold; it mints as a product with no roles of its own.");
var source = line ?? product;
var floor = line != null ? product.Bin.Floor : (float?) null;
var assembly = source.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution();
var lot = new Lot
{
Design = ItemData.RefOf<ItemData>(design),
Origin = new Attributed { Faction = product.Manufacturer },
Product = ItemData.RefOf(product),
Quality = stated ?? clamp(Random.NextGaussian(assembly.Mean, assembly.StandardDeviation), .01f, 1),
Quality = stated ?? clamp(Draw(assembly.Mean, assembly.StandardDeviation, floor), .01f, 1),
Roles = new List<RoleFill>()
};
if (design.Roles != null)
foreach (var role in design.Roles)
{
var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();
var build = source.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();
lot.Roles.Add(new RoleFill
{
Role = role.Name,
Quality = stated ?? clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)
Quality = stated ?? clamp(Draw(build.Mean, build.StandardDeviation, floor), .01f, 1)
});
}
return Lots.Add(lot);
}
private float Draw(float mean, float deviation, float? floorDeviations) =>
floorDeviations is { } floor ? Random.NextGaussianAbove(mean, deviation, floor) : Random.NextGaussian(mean, deviation);
// No branded product: a lot attributed to maker (which may be unset) with no role fills, e.g. console `give`.
public int CreateLot(CraftedItemData design, CultRecordRef<Faction> maker, float quality)
{

View file

@ -6,11 +6,11 @@ using GameCult.Caching;
using MessagePack;
using CultMath;
// A ship preset (a variant): the hull's design, and per hull cell a design and its rotation. A slot names a design and
// may name a product of it; a design-only slot is open on purpose, and whoever materializes the preset chooses its
// product from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which keeps a preset
// portable across galaxies. A preset that needs a brand names it. Authored catalog data: written in CultCache Studio or by the editor capture
// command, read at runtime.
// A Loadout: a hull together with item designs or products equipped to specific cells (ruling boss-gear-is-a-loadout).
// A slot names a design, and names a product only where the loadout demands one; a design-only slot's product is chosen
// by whoever materializes it from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which
// keeps a loadout portable across galaxies. A faction's boss flies one (Faction.BossLoadout). Authored catalog data:
// written in CultCache Studio or by the AetherDb and editor capture commands, read at runtime.
[CultDocument("aetheria.loadout", "1"), MessagePackObject]
public class Loadout
{
@ -28,8 +28,14 @@ public class LoadoutSlot
[Key(1)] public ItemRotation Rotation;
[Key(2)] public CultRecordRef<EquippableItemData> Design;
[Key(3)] public CultRecordRef<FactionProductData> Product; // unset: design-only, chosen at materialization
// Element identity: minted by the cache on write; "" means unset.
[Key(4)] [CultElementId] public string Id = "";
}
// Why a faction has no boss Loadout to build; see Loadouts.BossOf.
public enum BossRefusal { None, Unset, Missing }
// The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout.
public static class Loadouts
{
@ -66,61 +72,109 @@ public static class Loadouts
private static CultRecordRef<FactionProductData> ProductRef(ItemManager itemManager, CraftedItemInstance unit) =>
itemManager.Brand(unit).Product is { } product ? itemManager.ItemData.RefOf(product) : default;
// A preset's key derives from its name, so capturing the same name again addresses the same record.
// A loadout's key derives from its name, so capturing the same name again addresses the same record.
public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name);
// The only writer of presets. It opens its own short-lived cache over the catalog file, writable, and disposes it
// after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the
// file; a cache that already has the catalog open sees the preset only once it reloads.
// Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a preset of this name
// exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional
// on the record at KeyOf(Name), so a preset changed on disk since this open is not clobbered: returns false then.
public static bool Commit(string catalogPath, Loadout loadout, bool replace)
// The one resolver of a faction's boss reference (ruling faction-boss-is-a-loadout-reference): the Loadout
// Faction.BossLoadout names. The boss is never generated, so this reads nothing but the reference and the catalog. Null with
// Unset when the faction sets none, and null with Missing when it sets one the catalog does not hold.
public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal)
{
if (!faction.BossLoadout.IsSet())
{
refusal = BossRefusal.Unset;
return null;
}
var loadout = items.Get(faction.BossLoadout);
refusal = loadout == null ? BossRefusal.Missing : BossRefusal.None;
return loadout;
}
// Names the faction's field, and echoes no key or name of the input.
public static string Describe(BossRefusal refusal) => refusal switch
{
BossRefusal.Unset => "sets no Faction.BossLoadout",
BossRefusal.Missing => "names a Faction.BossLoadout that is not in the catalog",
_ => ""
};
// The only writer of Loadout records, and of the Faction.BossLoadout that names one. It opens its own short-lived cache
// over the catalog file, writable, and disposes it after the one commit. No other cache is touched, so no catalog
// instance play holds (or has mutated) can reach the file; a cache that already has the catalog open sees the loadout
// only once it reloads. The catalog is a stack of packages, the shipped content included (ruling
// catalog-is-a-package-stack), so a loadout may name any design the stack holds; one whose package is later removed fails
// closed where it is built, with its reason logged. With bossOf set, the same batch makes the loadout that faction's boss
// (Faction.BossLoadout), so the loadout and the reference land together or not at all.
// Throws and writes nothing when the file is missing (capture never creates a catalog), when bossOf names no faction in
// it, when a loadout of this name exists under another key, or when one exists under KeyOf(Name) and replace is not set.
// The commit is conditional on the record at KeyOf(Name) and on the faction, so a record changed on disk since this open
// is not clobbered: returns false then, and neither is written.
public static bool Commit(string catalogPath, Loadout loadout, bool replace, CultRecordRef<Faction> bossOf = default)
{
var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray();
if (modDesigns.Length > 0)
throw new InvalidOperationException($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset.");
if (!File.Exists(catalogPath))
throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a preset capture never creates one.");
throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one.");
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
return Commit(cache, loadout, replace, bossOf);
}
// The one batch Commit(path, ...) makes, over a writable catalog cache the caller opened.
internal static bool Commit(CultCache cache, Loadout loadout, bool replace, CultRecordRef<Faction> bossOf)
{
var key = KeyOf(loadout.Name);
var namesake = cache.GetAll<Loadout>().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key));
if (namesake != null)
throw new InvalidOperationException($"Preset '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first.");
throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first.");
var existing = cache.Get<Loadout>(key);
if (existing != null && !replace)
throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly.");
throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly.");
var boss = bossOf.IsSet()
? cache.Get(bossOf) ?? throw new InvalidOperationException($"Loadout '{loadout.Name}': the boss faction is not in the catalog.")
: null;
return cache.Commit(batch =>
{
batch.Expect(key, existing);
batch.Upsert(typeof(Loadout), loadout, key);
if (boss == null) return;
batch.Expect(bossOf.Key, boss);
boss.BossLoadout = new CultRecordRef<Loadout>(key);
batch.Upsert(boss.GetType(), boss, bossOf.Key);
});
}
// The quality a design no manufacturer makes is built at: the console give's.
public const float UnbrandedQuality = .95f;
// The seed of the scratch item manager a fit is tried on. It draws nothing, so any nonzero constant does.
private const uint ScratchSeed = 1u;
// All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure
// nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from
// itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game
// spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure of a stage is listed; on any
// failure nothing is returned and the zone and cache are unchanged. The stages run in the order that puts refusal
// first: what the catalog alone decides (designs, pinned products, hull kind, weapon groups), then whether the cells
// fit, tried on a throwaway item manager with its own ledger and stream, and only then the choice of a design-only
// slot's product (choose may draw) and the minting of lots. So a loadout that cannot be built mints no lot in the live
// ledger and draws nothing from itemManager.Random or from choose. The one exception is a design-only slot with no
// available product, which only choose can tell: it is found after an earlier slot's choice, and it too mints nothing.
// How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game
// spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the
// boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type
// picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is
// put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
// padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots,
// so the next commit drops them. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states
// put; a station hull is refused. The entity is stocked with the crates its weapons draw (StockAmmunition) unless the caller
// authors the cargo itself and passes stockAmmunition false. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
// padded with empty groups. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states
// one for a fit its checks rest on).
public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
Func<CraftedItemData, FactionProductData> choose, List<string> failures, float? quality = null)
Func<CraftedItemData, FactionProductData> choose, List<string> failures, float? quality = null, bool stockAmmunition = true)
{
var cache = itemManager.ItemData;
var reported = failures.Count;
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality);
var hullSettled = Settle(itemManager, loadout.Hull, loadout.HullProduct, "hull", failures);
var hullData = cache.Get(loadout.Hull);
if (hullData is { HullType: HullType.Station })
failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret");
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray();
failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret");
var slotsSettled = loadout.Slots.Select(slot => Settle(itemManager, slot.Design, slot.Product, Cell(slot), failures)).ToArray();
var groups = loadout.WeaponGroups ?? new int[0][];
var groupCount = itemManager.GameplaySettings.WeaponGroupCount;
if (groups.Length > groupCount) failures.Add($"weapon groups: {groups.Length} groups, at most {groupCount}");
@ -132,12 +186,58 @@ public static class Loadouts
}
if (failures.Count > reported) return null;
// The cells fit or they do not whatever brand a design-only slot takes: the shape is the design's. Tried on its own
// item manager, so nothing is minted into the live ledger and nothing is drawn.
var scratch = new ItemManager(ScratchSeed, cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { });
Assemble(scratch, null, hullData, loadout, Unbranded(scratch, hullSettled), slotsSettled.Select(settled => Unbranded(scratch, settled)).ToArray(), failures, out _);
if (failures.Count > reported) return null;
var hullBuild = Build(itemManager, hullSettled, choose, quality, failures);
var slotBuilds = slotsSettled.Select(settled => Build(itemManager, settled, choose, quality, failures)).ToArray();
if (failures.Count > reported) return null;
var entity = Assemble(itemManager, liveZone, hullData, loadout, hullBuild, slotBuilds, failures, out var equipped);
if (entity == null) return null;
var built = new (List<Weapon> weapons, List<EquippedItem> items)[groupCount];
for (var g = 0; g < groupCount; g++)
{
var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List<EquippedItem>();
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
}
entity.WeaponGroups = built;
if (stockAmmunition) StockAmmunition(itemManager, entity);
return entity;
}
// The crates an entity's weapons reload from: LoadoutGenerator.AmmunitionCrates of each distinct AmmoType, stopping when no hold takes
// another. The one owner of ammunition stock: every path that fits weapons onto a new entity (the generator, a Loadout's Materialize,
// so a scenario's Fit, a boss, a console-built ship) ends here. A saved or packed entity is never stocked: its cargo is its own.
// Draws nothing from the random stream, so adding it moves no other roll.
public static void StockAmmunition(ItemManager itemManager, Entity entity)
{
foreach (var key in entity.Weapons.Select(weapon => weapon.WeaponData.AmmoType).Where(type => type.IsSet()).Select(type => type.Key).Distinct())
{
var crates = new SimpleCommodity { Data = new CultRecordRef<ItemData>(key), Quantity = LoadoutGenerator.AmmunitionCrates };
if (!entity.CargoBays.Any(bay => bay.TryStore(crates)))
itemManager.Log($"{entity.Name}: no room for {LoadoutGenerator.AmmunitionCrates} crates of {key.Value}.");
}
}
// The hull, then each slot's gear, made by the given builders and equipped to the loadout's cells: the one placement of
// a loadout, run once on the throwaway manager to decide the fit and once on the live one to build. Null with every
// misfit listed when any cell does not take its gear.
private static Entity Assemble(ItemManager itemManager, Zone liveZone, HullData hullData, Loadout loadout,
Func<CraftedItemInstance> hullBuild, Func<CraftedItemInstance>[] slotBuilds, List<string> failures, out EquippedItem[] equipped)
{
var reported = failures.Count;
var cache = itemManager.ItemData;
var hull = (EquippableItem) hullBuild();
var settings = itemManager.GameplaySettings.DefaultEntitySettings;
Entity entity = hullData.HullType == HullType.Turret
? new OrbitalEntity(itemManager, liveZone, hull, default, settings)
: new Ship(itemManager, liveZone, hull, settings);
var equipped = new EquippedItem[loadout.Slots.Count];
equipped = new EquippedItem[loadout.Slots.Count];
for (var i = 0; i < loadout.Slots.Count; i++)
{
var slot = loadout.Slots[i];
@ -154,26 +254,22 @@ public static class Loadouts
.Concat(entity.DockingBays)
.First(item => item.EquippableItem == unit);
}
if (failures.Count > reported) return null;
var built = new (List<Weapon> weapons, List<EquippedItem> items)[groupCount];
for (var g = 0; g < groupCount; g++)
{
var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List<EquippedItem>();
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
}
entity.WeaponGroups = built;
return entity;
return failures.Count > reported ? null : entity;
}
// How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A
// pinned product is built as named, whatever choose says; it must be a product of the design. A design with no
// product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through
// products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and
// null (nothing available) or a product of another design is a failure.
public static Func<CraftedItemInstance> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
CultRecordRef<FactionProductData> product, Func<CraftedItemData, FactionProductData> choose, string where,
List<string> failures, float? quality = null) where T : CraftedItemData
// What the catalog alone says about one design of a loadout: the design, and whether it pins a product, has no product
// at all, or needs a chooser to name one. Nothing is minted and nothing drawn.
private sealed class Settled
{
public CraftedItemData Data;
public FactionProductData Pinned; // the product the loadout names, if it does
public bool Unbranded; // a design no product makes
public string Where;
}
// Null with the failure listed.
private static Settled Settle<T>(ItemManager itemManager, CultRecordRef<T> design, CultRecordRef<FactionProductData> product,
string where, List<string> failures) where T : CraftedItemData
{
var cache = itemManager.ItemData;
var data = cache.Get(design);
@ -183,35 +279,69 @@ public static class Loadouts
return null;
}
FactionProductData made;
if (product.IsSet())
{
made = cache.Get(product);
var made = cache.Get(product);
if (made == null)
{
failures.Add($"{where}: product {product.Key} is not in the catalog");
return null;
}
}
else
{
if (!cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(design.Key)))
return () => itemManager.CreateInstance(itemManager.CreateLot(data, default, quality ?? UnbrandedQuality));
made = choose(data);
if (made == null)
if (!made.Design.Key.Equals(design.Key))
{
failures.Add($"{where}: no available product of {data.Name}");
failures.Add($"{where}: product {made.Name} is not a product of {data.Name}");
return null;
}
return new Settled { Data = data, Pinned = made, Where = where };
}
if (!made.Design.Key.Equals(design.Key))
return new Settled
{
failures.Add($"{where}: product {made.Name} is not a product of {data.Name}");
return null;
Data = data, Where = where,
Unbranded = !cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(design.Key))
};
}
// A design made with no product, at UnbrandedQuality: no maker, no draw. It is also the stand-in the fit trial builds
// every design as, since a brand never changes a shape. A settled design that failed is never built.
private static Func<CraftedItemInstance> Unbranded(ItemManager itemManager, Settled settled) =>
() => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, UnbrandedQuality));
// How a settled design is built, or null with the failure listed; nothing is minted until the returned builder runs.
// A design-only slot is where choose is asked, and may draw.
private static Func<CraftedItemInstance> Build(ItemManager itemManager, Settled settled,
Func<CraftedItemData, FactionProductData> choose, float? quality, List<string> failures)
{
if (settled == null) return null;
if (settled.Unbranded)
return () => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, quality ?? UnbrandedQuality));
var made = settled.Pinned;
if (made == null)
{
made = choose(settled.Data);
if (made == null)
{
failures.Add($"{settled.Where}: no available product of {settled.Data.Name}");
return null;
}
if (!made.Design.Key.Equals(itemManager.ItemData.RefOf(settled.Data).Key))
{
failures.Add($"{settled.Where}: product {made.Name} is not a product of {settled.Data.Name}");
return null;
}
}
return () => itemManager.CreateInstance(quality is { } q ? itemManager.CreateLot(made, q) : itemManager.CreateLot(made));
}
// How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A
// pinned product is built as named, whatever choose says; it must be a product of the design. A design with no
// product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through
// products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and
// null (nothing available) or a product of another design is a failure.
public static Func<CraftedItemInstance> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
CultRecordRef<FactionProductData> product, Func<CraftedItemData, FactionProductData> choose, string where,
List<string> failures, float? quality = null) where T : CraftedItemData =>
Build(itemManager, Settle(itemManager, design, product, where, failures), choose, quality, failures);
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";
}

View file

@ -24,6 +24,10 @@ public class LoadoutGenerator
public const float ShipConsumableChance = .1f;
public const int ShipConsumableUnits = 1;
// Ammunition-fed weapons reload from cargo (ruling ballistics-real-ammo), so a generated ship carries this many crates of every
// distinct AmmoType its weapons draw: enough to fight on past the first magazine, few enough to leave the rest of the hold.
public const int AmmunitionCrates = 3;
public LoadoutGenerator(
ref Random random,
ItemManager itemManager,
@ -56,6 +60,7 @@ public class LoadoutGenerator
OutfitEntity(entity);
EquipPickup(entity);
StockConsumables(entity, ShipConsumableChance, ShipConsumableUnits);
Loadouts.StockAmmunition(ItemManager, entity);
return EntitySerializer.Pack(entity);
}
@ -71,12 +76,16 @@ public class LoadoutGenerator
var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings);
entity.Faction = Faction;
OutfitEntity(entity);
Loadouts.StockAmmunition(ItemManager, entity);
return EntitySerializer.Pack(entity) as OrbitalEntityPack;
}
public OrbitalEntityPack GenerateStationLoadout()
// A station of one kind: the hull is drawn among the station hulls the kind calls for in this faction's hands
// (StationKinds.HullKindFor), so a faction with no model of the kind builds its stand-in's hull and keeps the kind.
public OrbitalEntityPack GenerateStationLoadout(CultRecordRef<StationKindData> kind)
{
var (hullProduct, hullData) = RandomHull(HullType.Station);
var hullKind = StationKinds.HullKindFor(ItemManager.ItemData, Faction, kind);
var (hullProduct, hullData) = RandomHull(HullType.Station, hull => hull.Kind.Key.Equals(hullKind.Key));
if(hullData==null)
{
ItemManager.Log("Unable to generate station loadout: no compatible hull found!");
@ -85,6 +94,9 @@ public class LoadoutGenerator
var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings);
entity.Faction = Faction;
entity.Kind = kind;
// A stand-in hull keeps its own model but not its own name: the station is what its kind says it is.
if (!hullData.Kind.Key.Equals(kind.Key) && ItemManager.ItemData.Get(kind) is { } kindData) entity.Name = kindData.Name;
// Hardpoint gear goes in first so the docking bay can only take space that gear left free
EquipHardpoints(entity);
@ -176,13 +188,16 @@ public class LoadoutGenerator
public (FactionProductData product, T design)[] RandomProducts<T>(int count, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : CraftedItemData
{
var (available, preferManufacturers) = AvailableProducts(filter, required);
var shares = BinShares();
return available.WeightedRandomElements(ref Random, entry =>
(preferManufacturers ? ProductWeight(entry.product) : 1) *
Weight(entry.product, shares, preferManufacturers) *
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
count);
}
// An item is on offer only through a product, so a design no product makes is never drawn, stocked or chosen; it reaches a ship
// only through a Loadout (ruling off-market-is-general).
// The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection.
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts<T>(Predicate<T> filter, bool required) where T : CraftedItemData
@ -191,9 +206,8 @@ public class LoadoutGenerator
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
.Select(product => (product, design: ItemManager.ItemData.Get(product.Design) as T))
.Where(entry =>
entry.design != null &&
entry.design.Price > 0 &&
entry.product.Manufacturer.IsSet() &&
// A not-offered product is never a candidate, so the any-manufacturer fallback below cannot reach it either.
IsDrawable(ItemManager.ItemData, entry.product, entry.design) &&
(!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) &&
(filter?.Invoke(entry.design) ?? true))
.ToArray();
@ -209,33 +223,14 @@ public class LoadoutGenerator
return (available, preferManufacturers);
}
// A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull
// the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all.
public EntityPack GenerateBossLoadout()
{
var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null;
if (bossHull != null)
{
var boss = GenerateShipLoadout(hull => hull == bossHull);
if (boss != null) return boss;
ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead.");
}
else
ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead.");
var (available, _) = AvailableProducts<HullData>(hull => hull.HullType == HullType.Ship, required: true);
if (available.Length == 0) return null;
var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design;
return GenerateShipLoadout(hull => hull == priciest);
}
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a
// bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station
// stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior.
// Only a hull some product sells gives gear a home: a hull no faction builds (the Smoke Test Hull) is a test fixture, so its
// hardpoints never put gear on a shelf. The one definition of the hulls HasHome considers.
// hardpoints never put gear on a shelf. An off-market hull gives no gear a home either: only its pinned Loadout flies it.
// The one definition of the hulls HasHome considers.
public static HullData[] SoldHulls(CultCache items) =>
items.GetAll<FactionProductData>().Select(product => items.Get(product.Design)).OfType<HullData>().Distinct().ToArray();
items.GetAll<FactionProductData>().Where(product => IsOffered(items, product)).Select(product => items.Get(product.Design)).OfType<HullData>().Distinct().ToArray();
public static bool HasHome(EquippableItemData design, HullData[] hulls) =>
design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull ||
@ -248,16 +243,52 @@ public class LoadoutGenerator
// always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names
// (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy
// does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available).
// A not-offered product is available to nobody, a stage with no faction included.
public bool IsAvailable(FactionProductData product) =>
Galaxy == null || Faction == null ||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer);
IsOffered(ItemManager.ItemData, product) && (
Galaxy == null || Faction == null ||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer));
// How much this outfitter favors a product: its maker's preference. The one weight of generation and of ChooseProduct.
public float ProductWeight(FactionProductData product) => ManufacturerPreference(product.Manufacturer);
// Whether a product can be drawn for its design at all: the design resolves, has a price, the product names a maker and is
// offered. The one definition of 'a maker offers this design'; AvailableProducts selects by it and StationKinds decides which
// station kinds a hull can be drawn for by it, so a kind is kept exactly when a hull of it can be drawn.
public static bool IsDrawable(CultCache items, FactionProductData product, CraftedItemData design) =>
design != null && design.Price > 0 && product.Manufacturer.IsSet() && IsOffered(items, product);
// Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. A bin is
// offered only if both it and its line are (ruling bin-inherits-line-offered); the catalog read guarantees a bin's line exists.
public static bool IsOffered(CultCache items, FactionProductData product) =>
!product.NotOffered && (product.Bin == null || !items.Get(product.Bin.Line).NotOffered);
// How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of
// generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes
// the rest (ruling bin-share-by-floor), so binning never changes a maker's presence for the design.
public float ProductWeight(FactionProductData product) => Weight(product, BinShares(), true);
// The one weight, with the share of the stock computed from shares (BinShares, once per weighting pass). A draw that has
// fallen back to any manufacturer weighs by share alone: the maker's preference no longer applies, its bins' shares still do.
private float Weight(FactionProductData product, Dictionary<string, float> shares, bool preferManufacturers) =>
(preferManufacturers ? ManufacturerPreference(product.Manufacturer) : 1) * Share(product, shares);
// 1 - Phi(floor): the fraction of a normal draw that lies at least floor deviations above its mean.
private static float PassFraction(float floor) => .5f * (1 - erf(floor / sqrt(2f)));
// Each line's key to the sum of its offered bins' pass fractions: read from the catalog once per weighting pass, never kept. An
// off-market bin takes no share, so its line keeps the whole weight.
private Dictionary<string, float> BinShares() => ItemManager.ItemData.GetAll<FactionProductData>()
.Where(product => product.Bin != null && IsOffered(ItemManager.ItemData, product))
.GroupBy(product => product.Bin.Line.Key.Value)
.ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor)));
private float Share(FactionProductData product, Dictionary<string, float> shares)
{
if (product.Bin != null) return PassFraction(product.Bin.Floor);
return max(0f, 1 - shares.GetValueOrDefault(ItemManager.ItemData.RefOf(product).Key.Value));
}
// The product a design named without one is built as (ruling loadout-product-optional-context-chooses): among the
// products of the design this outfitter can get, one draw from the run's seeded Random weighted by ProductWeight;
// null when none, and uniform over them when every weight is 0. Candidates are ordered by name so the draw's
// null when none, and by share alone when every weight is 0. Candidates are ordered by name so the draw's
// enumeration, and so its result, never depends on record keys. All of the policy is this body.
public FactionProductData ChooseProduct(CraftedItemData design)
{
@ -267,8 +298,9 @@ public class LoadoutGenerator
.OrderBy(product => product.Name, StringComparer.Ordinal)
.ToArray();
if (candidates.Length == 0) return null;
var uniform = candidates.All(product => ProductWeight(product) <= 0);
return candidates.WeightedRandomElements(ref Random, product => uniform ? 1 : ProductWeight(product), 1)[0];
var shares = BinShares();
var uniform = candidates.All(product => Weight(product, shares, true) <= 0);
return candidates.WeightedRandomElements(ref Random, product => Weight(product, shares, !uniform), 1)[0];
}
// Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters.

View file

@ -17,6 +17,8 @@ public class OrbitalEntity : Entity
public float SecurityRadius;
public LocationStory Story;
public bool CanTow;
// What this station is for (StationKinds): its own fact, which under a stand-in hull differs from the hull's kind.
public CultRecordRef<StationKindData> Kind;
public OrbitalEntity(ItemManager itemManager, Zone zone, EquippableItem hull, CultRecordKey orbit, EntitySettings settings) : base(itemManager, zone, hull, settings)
{

View file

@ -0,0 +1,176 @@
/* 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;
// A turn demand as a pilot commands it: a rate, or a heading resolved against the hull's facing when it is applied, so a
// held heading keeps steering as the hull turns.
public readonly struct TurnCommand
{
private readonly bool _toward;
private readonly float _rate;
private readonly float2 _heading;
private TurnCommand(bool toward, float rate, float2 heading)
{
_toward = toward;
_rate = rate;
_heading = heading;
}
public static TurnCommand OfRate(float rate) => new TurnCommand(false, rate, default);
public static TurnCommand Toward(float2 heading) => new TurnCommand(true, 0, heading);
public float Resolve(Ship ship) => _toward ? Steering.Toward(ship, _heading) : _rate;
}
// The player's target presses, each answered by the entity's own chooser (Entity.TargetNext and the rest) when applied.
public enum TargetVerb
{
None,
UnderReticle,
NearestEnemy,
Next,
Previous
}
// The one input an entity's pilot gives it (its agent, the player, a scripted pilot): only own-ship vectors and handles the
// pilot was given (ruling observation-sensors-only). Commands are per entity, never per Flight or side: a side may be one
// ship (ruling brain-one-per-side-r2). PilotApplier.Apply is the only writer of the entity's fields from it; the pilot
// also reads back its own pending decisions this step (Designated, Pulls), because the entity still holds last step's.
// Held fields persist until the pilot replaces them, so a pilot deciding below the sim rate keeps flying (ruling
// lower-game-sim-rate); null leaves the field to whoever wrote it last. One-shots apply once, in order, and are cleared.
public sealed class PilotCommand
{
public TurnCommand? Turn;
public float2? Movement;
public float3? Aim;
public float? Tractor;
internal readonly List<TargetRef> Designations = new List<TargetRef>();
internal readonly List<EquippedItem> Selections = new List<EquippedItem>();
internal readonly List<string> Hails = new List<string>();
internal readonly List<ConsumableItemData> Uses = new List<ConsumableItemData>();
internal readonly List<(ActivatedBehavior Behavior, bool Pull)> Triggers = new List<(ActivatedBehavior, bool)>();
internal readonly List<(Entity Other, bool? Hostile, bool Toggle)> Iffs = new List<(Entity, bool?, bool)>();
internal TargetVerb TargetVerb;
// The step's give verb: a Give, or a stop when its Receiver is null. The last one in a step wins.
internal (Entity Receiver, ItemInstance Item, int Quantity)? GiveVerb;
internal int AimCycles;
internal int ShutdownOverrideToggles, HeatsinkToggles, ShieldToggles, Pings;
public void Designate(TargetRef target) => Designations.Add(target);
public void SelectItem(EquippedItem item) => Selections.Add(item);
public void Hail(string message) => Hails.Add(message);
public void Use(ConsumableItemData consumable) => Uses.Add(consumable);
public void Pull(ActivatedBehavior behavior) => Triggers.Add((behavior, true));
public void Release(ActivatedBehavior behavior) => Triggers.Add((behavior, false));
// The last verb in a step wins.
public void Verb(TargetVerb verb) => TargetVerb = verb;
public void CycleAim() => AimCycles++;
public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile, false));
// A press that flips the stance on the entity's target as both stand when applied, after the step's target verbs and in
// order with Iff, so two in a step cancel. With no target then, it does nothing.
public void ToggleStance() => Iffs.Add((null, null, true));
// The entity's own switches, pressed: each press flips its switch when applied, so two in a step cancel.
public void ToggleOverrideShutdown() => ShutdownOverrideToggles++;
public void ToggleHeatsinks() => HeatsinkToggles++;
public void ToggleShield() => ShieldToggles++;
public void Ping() => Pings++;
// Offers goods from this entity's hold to a ship alongside (ruling transfer-is-give-only): the quantity of a commodity,
// or the one crafted instance. It replaces any give under way. A give of nothing, or of less than one, is ignored, and
// so is one to oneself when applied. The last give or stop in a step wins.
public void Give(Entity receiver, ItemInstance item, int quantity)
{
if (receiver == null || item == null) return;
var count = item is CraftedItemInstance ? 1 : quantity;
if (count < 1) return;
GiveVerb = (receiver, item, count);
}
public void StopGiving() => GiveVerb = (null, null, 0);
// The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state).
public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null;
// Whether this pilot has commanded a trigger, pull or release, for `behavior` this step.
public bool Commands(ActivatedBehavior behavior)
{
foreach (var trigger in Triggers)
if (ReferenceEquals(trigger.Behavior, behavior)) return true;
return false;
}
// Whether the last trigger this pilot commanded for `behavior` this step is a pull.
public bool Pulls(ActivatedBehavior behavior)
{
for (var i = Triggers.Count - 1; i >= 0; i--)
if (ReferenceEquals(Triggers[i].Behavior, behavior)) return Triggers[i].Pull;
return false;
}
internal void ClearOneShots()
{
Designations.Clear();
Selections.Clear();
Hails.Clear();
Uses.Clear();
Triggers.Clear();
Iffs.Clear();
TargetVerb = TargetVerb.None;
GiveVerb = null;
AimCycles = 0;
ShutdownOverrideToggles = HeatsinkToggles = ShieldToggles = Pings = 0;
}
}
// The one writer of an entity's pilot-controlled fields, called only from Zone.Update. The order is a rule: the target
// lands (a designation, then a chooser verb) before its item is selected or the aim cycled, and the aim before the
// triggers, so a pilot fires along the aim it commands this step.
public static class PilotApplier
{
public static void Apply(Entity entity)
{
var command = entity.Command;
foreach (var target in command.Designations)
if (!entity.Target.Value.Equals(target)) entity.SetTarget(target);
switch (command.TargetVerb)
{
case TargetVerb.UnderReticle: entity.TargetUnderReticle(); break;
case TargetVerb.NearestEnemy: entity.TargetNearestEnemy(); break;
case TargetVerb.Next: entity.TargetNext(); break;
case TargetVerb.Previous: entity.TargetPrevious(); break;
}
foreach (var item in command.Selections) entity.TrySelectTargetItem(item);
for (var i = 0; i < command.AimCycles; i++) entity.CycleAim();
foreach (var (other, hostile, toggle) in command.Iffs)
{
if (!toggle) entity.SetIff(other, hostile);
else if (entity.Target.Value.Entity is Entity target) entity.SetIff(target, !entity.IsHostileTo(target));
}
for (var i = 0; i < command.ShutdownOverrideToggles; i++) entity.OverrideShutdown = !entity.OverrideShutdown;
for (var i = 0; i < command.HeatsinkToggles; i++) entity.HeatsinksEnabled = !entity.HeatsinksEnabled;
if (entity.Shield != null)
for (var i = 0; i < command.ShieldToggles; i++) entity.Shield.Item.Enabled.Value = !entity.Shield.Item.Enabled.Value;
for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping();
foreach (var hail in command.Hails) entity.SetMessage(hail);
foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable);
if (command.GiveVerb is { } give)
{
if (give.Receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped);
else if (!ReferenceEquals(give.Receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(give.Receiver, give.Item, give.Quantity));
}
if (command.Aim is float3 aim) entity.Aim = aim;
foreach (var (behavior, pull) in command.Triggers)
if (pull) behavior.Activate();
else behavior.Deactivate();
if (entity is Ship ship)
{
if (command.Turn is TurnCommand turn) ship.Turn = turn.Resolve(ship);
if (command.Movement is float2 movement) ship.MovementDirection = movement;
}
if (command.Tractor is float tractor) entity.TractorCommand = tractor;
command.ClearOneShots();
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ae6251a7a32402cb7f7db7ff1fc5084

View file

@ -92,6 +92,9 @@ public class Lot
foreach (var fill in Roles) sum += Math.Min(fill.Quality, Quality);
return sum / Roles.Count;
}
// Element identity: minted by the cache on write; "" means unset.
[Key(5)] [CultElementId] public string Id = "";
}
// One filled slot: the design's role name and how good the part that went into it turned out to be. Stats read it through
@ -101,6 +104,9 @@ public class RoleFill
{
[Key(0)] public string Role;
[Key(1)] public float Quality;
// Element identity: minted by the cache on write; "" means unset.
[Key(2)] [CultElementId] public string Id = "";
}
// Who built a lot, where, and from what. Terminus mints Attributed placeholders; Produced and Extracted are the

View file

@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
using MessagePack;
using Newtonsoft.Json;
// How a good is made: one authored record per output, keyed recipe:<output key>, so a second recipe for one output replaces the
// first. A commodity's recipe takes named commodities (Inputs) and roles (RoleInputs), each role filled by any commodity whose
// recipe Fills it, and says which roles it can fill. A role recipe (the output is a design with roles, a ship hull, a piece of
// gear, a consumer good) restates nothing of the design: its role inputs are the design's Roles and their counts. Station and
// Turret hulls take no recipe.
[CultDocument("aetheria.recipedata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class RecipeData
{
[JsonProperty("output"), Key(0)]
public CultRecordRef<ItemData> Output;
[JsonProperty("outputCount"), Key(1)]
public int OutputCount = 1;
// Commodities one cycle consumes. Empty on a raw good and on a role recipe.
[JsonProperty("inputs"), Key(2)]
public List<CommodityCount> Inputs = new List<CommodityCount>();
// Seconds of sim time for one cycle.
[JsonProperty("cycle"), Key(3)]
public float Cycle = 1;
// Crew per running facility; no kind until workforce kinds exist.
[JsonProperty("workforce"), Key(4)]
public int Workforce;
// Commodities one cycle uses up on the side (power, coolant), per cycle.
[JsonProperty("upkeep"), Key(5)]
public List<CommodityCount> Upkeep = new List<CommodityCount>();
// The kind of station that runs this recipe.
[JsonProperty("stationKind"), Key(6)]
public CultRecordRef<StationKindData> StationKind;
// Why this good exists in the world, in the operator's reading. Presence is checked, never its meaning.
[JsonProperty("loreHook"), Key(7)]
public string LoreHook;
// The roles of a design this commodity can fill. Only a commodity's recipe has any.
[JsonProperty("fills"), Key(8)]
public List<string> Fills = new List<string>();
// Roles one cycle consumes, any filler will do and the facility picks (ruling ores-fill-metal-roles). Empty on a role recipe;
// records written before key 9 read it as empty.
[JsonProperty("roleInputs"), Key(9)]
public List<ItemRole> RoleInputs = new List<ItemRole>();
public static CultRecordKey KeyOf(CultRecordKey outputKey) => new CultRecordKey("recipe:" + outputKey.Value);
}
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class CommodityCount
{
[JsonProperty("commodity"), Key(0)]
public CultRecordRef<ItemData> Commodity;
[JsonProperty("count"), Key(1)]
public int Count = 1;
// Element identity: minted by the cache on write; "" means unset.
[Key(2)] [CultElementId] public string Id = "";
}
// A region gate's trade with the world beyond it: what the outside buys and sells, at what price and at most how fast.
// The union of all records is the outside; keyed outside-market:<id>.
[CultDocument("aetheria.outsidemarketdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class OutsideMarketData
{
[CultName, JsonProperty("name"), Key(0)]
public string Name;
[JsonProperty("rows"), Key(1)]
public List<OutsideMarketRow> Rows = new List<OutsideMarketRow>();
public static CultRecordKey KeyOf(string id) => new CultRecordKey("outside-market:" + id);
}
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class OutsideMarketRow
{
[JsonProperty("commodity"), Key(0)]
public CultRecordRef<ItemData> Commodity;
[JsonProperty("price"), Key(1)]
public int Price;
[JsonProperty("buys"), Key(2)]
public bool Buys;
[JsonProperty("sells"), Key(3)]
public bool Sells;
[JsonProperty("ratePerHour"), Key(4)]
public float RatePerHour;
// Element identity: minted by the cache on write; "" means unset.
[Key(5)] [CultElementId] public string Id = "";
}
// The one reader of recipes: which recipe makes a good, a recipe's role slots (RoleInputsOf), which commodities fill a role, and
// what a good is by its recipe.
public static class Recipes
{
public static RecipeData RecipeOf(CultCache catalog, ItemData item) =>
item == null ? null : catalog.Get<RecipeData>(RecipeData.KeyOf(catalog.RefOf(item).Key));
public static bool IsCommodity(ItemData item) => item is SimpleCommodityData || item is CompoundCommodityData;
public static IReadOnlyList<ItemRole> RolesOf(ItemData item) =>
(item as CraftedItemData)?.Roles ?? new List<ItemRole>();
// A recipe whose output is a design with roles: a part list rather than a list of inputs.
public static bool IsRoleRecipe(CultCache catalog, RecipeData recipe)
{
var output = catalog.Get(recipe.Output);
return output is CraftedItemData && RolesOf(output).Count > 0;
}
// The one reader of a recipe's role slots: the design's Roles for a role recipe, else the recipe's RoleInputs.
public static IReadOnlyList<ItemRole> RoleInputsOf(CultCache catalog, RecipeData recipe) =>
IsRoleRecipe(catalog, recipe) ? RolesOf(catalog.Get(recipe.Output)) : (IReadOnlyList<ItemRole>) recipe.RoleInputs ?? new List<ItemRole>();
// Raw: takes no commodity and no role. Everything else is processed. Derived, never stored.
public static bool IsRaw(CultCache catalog, RecipeData recipe) => recipe.Inputs.Count == 0 && RoleInputsOf(catalog, recipe).Count == 0;
// A commodity made by a role recipe: it is assembled from the same components gear is.
public static bool IsConsumerGood(CultCache catalog, RecipeData recipe) =>
IsCommodity(catalog.Get(recipe.Output)) && IsRoleRecipe(catalog, recipe);
// The commodities whose recipes Fill the role, ordered by key so a choice never depends on store order.
public static CultRecordKey[] Fillers(CultCache catalog, string role) =>
catalog.GetAll<RecipeData>()
.Where(recipe => recipe.Fills != null && recipe.Fills.Contains(role) && IsCommodity(catalog.Get(recipe.Output)))
.Select(recipe => recipe.Output.Key)
.OrderBy(key => key.Value, StringComparer.Ordinal)
.ToArray();
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3c9a51d7e0b24f68a1e47d92b5c08f31
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -25,7 +25,7 @@ public static class RunStart
public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, PlanetSettings planetSettings, Galaxy galaxy, Scenario scenario)
{
SeedItems(itemManager, galaxy, 1);
SeedItems(itemManager, galaxy, ArenaStep);
return galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone(
itemManager, zoneSettings, planetSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient);
}
@ -36,7 +36,7 @@ public static class RunStart
public static Staged Stage(ItemManager itemManager, Zone arena, Scenario scenario, string startingHull,
TutorialGenerationSettings tutorialGenerationSettings, List<string> failures)
{
SeedItems(itemManager, arena.Galaxy, 2);
SeedItems(itemManager, arena.Galaxy, StageStep);
var stage = new ScenarioStage(itemManager, arena, startingHull, tutorialGenerationSettings);
scenario.Stage(stage);
var reported = failures.Count;
@ -44,6 +44,7 @@ public static class RunStart
if (!stage.PlayerPlaced)
failures.Add("player: the scenario places no player");
if (failures.Count > reported) return null;
stage.StockUnauthoredAmmunition();
var player = stage.PlayerShip;
player.IsPlayerShip = true;
@ -55,6 +56,12 @@ public static class RunStart
player.SetIff(placed.Entity, hostile);
arena.Admit(placed.Entity, placed.Piloted, placed.Task);
}
// A start state includes what a pilot knows (ruling stage-states-pilot-knowledge): its anchor is tracked from the first
// step, by the sensors' own path (info gathered past the detection threshold), so they can lose it like any track.
foreach (var placed in stage.Placed)
if (placed.Knows != null && placed.Piloted)
placed.Entity.EntityInfoGathered[placed.Knows] = (itemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f;
return new Staged { Player = player, Credits = stage.Credits, Entities = stage.Placed.Select(placed => placed.Entity).ToList() };
}
@ -62,5 +69,22 @@ public static class RunStart
// so a fixed seed fixes the arena's gear and the player's fit. Each step draws its own stream, so whatever runs
// between them (populating the level) cannot shift it.
private static void SeedItems(ItemManager itemManager, Galaxy galaxy, uint step) =>
itemManager.Random = new Random(galaxy.Seed * 0x9E3779B1u + step);
itemManager.Random = new Random(ItemSeed(galaxy, step));
// The steps of a run that draw items: generating its arena, staging its scenario, and continuing a saved run.
public const uint ArenaStep = 1;
public const uint StageStep = 2;
public const uint ContinueStep = 3;
// The item seed of an ActionGameManager scene opened with no galaxy. No run is loaded, so nothing a run decides
// draws from it.
public const uint NoRunSeed = 0x5EED0001u;
// The item stream's seed for one step of a run: the run's seed and the step. Never zero, which no stream accepts.
public static uint ItemSeed(Galaxy galaxy, uint step)
{
var seed = galaxy.Seed * 0x9E3779B1u + step;
if (seed == 0) throw new InvalidOperationException($"Item seed is zero for galaxy seed {galaxy.Seed} at step {step}.");
return seed;
}
}

View file

@ -45,11 +45,55 @@ public class SavedGame
[Key(11)]
public bool IsTutorial;
// Read only by a save written before Standings: its entries follow galaxy.Factions order, not Factions order, so
// Galaxy trusts it only when the two orders cannot differ (see Galaxy's save constructor).
[Key(12)]
public FactionRelationship[] Relationships;
[Key(13)]
public int Credits;
// The run's seed (Galaxy.Seed), minted once by GalaxyStage.SeedFrom. Written only by RunSave.Capture and read only
// by Galaxy's save constructor; 0 in a save written before run-seed, which then rolls name-only drops.
[Key(14)]
public uint Seed;
// The run's resource provinces (Galaxy.Provinces): each province's seed zone index and its type. Written only by
// RunSave.Capture and read only by Galaxy's save constructor; null in a save written before ore geography, which then
// gets Regions, Partition and Deal from its saved graph, boss zones, Exit and seed.
[Key(15)]
public List<SavedProvince> Provinces;
// Each faction's standing, keyed by the faction itself so no list order can misplace it. Written by RunSave.Capture;
// null in a save written before it, which then reads Relationships.
[Key(16)]
public List<SavedStanding> Standings;
}
[MessagePackObject]
public class SavedStanding
{
[Key(0)]
public CultRecordRef<Faction> Faction;
[Key(1)]
public FactionRelationship Relationship;
// Element identity: minted by the cache on write; "" means unset.
[Key(2)] [CultElementId] public string Id = "";
}
[MessagePackObject]
public class SavedProvince
{
[Key(0)]
public int Seed;
[Key(1)]
public CultRecordRef<ProvinceData> Type;
// Element identity: minted by the cache on write; "" means unset.
[Key(2)] [CultElementId] public string Id = "";
}
// The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records;
@ -66,7 +110,8 @@ public static class RunSave
DiscoveredZones = galaxy.DiscoveredZones.Select(dz => Array.IndexOf(galaxy.Zones, dz)).ToArray(),
Background = galaxy.Background,
Factions = factions.Select(f => cache.RefOf(f)).ToArray(),
Relationships = galaxy.Factions.Select(f => galaxy.FactionRelationships[f]).ToArray(),
Standings = galaxy.Factions.Select(f => new SavedStanding
{Faction = cache.RefOf(f), Relationship = galaxy.FactionRelationships[f]}).ToList(),
HomeZones = galaxy.HomeZones.ToDictionary(
x => Array.IndexOf(factions, x.Key),
x => Array.IndexOf(galaxy.Zones, x.Value)),
@ -79,7 +124,8 @@ public static class RunSave
Exit = Array.IndexOf(galaxy.Zones, galaxy.Exit),
IsTutorial = galaxy.IsPrelude,
ActionBarBindings = actionBar,
Credits = credits
Credits = credits,
Seed = galaxy.Seed
};
// A zone generated earlier but not loaded this session has no live Contents; its packed contents carry over.
@ -90,8 +136,11 @@ public static class RunSave
AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(),
Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(),
Contents = zone.Contents?.PackZone() ?? zone.PackedContents,
Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner)
Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner),
Province = Array.IndexOf(galaxy.Zones, zone.Province.Seed)
}).ToArray();
game.Provinces = galaxy.Provinces.Select(province => new SavedProvince
{Seed = Array.IndexOf(galaxy.Zones, province.Seed), Type = province.Type}).ToList();
return (game, zones);
}
@ -115,7 +164,7 @@ public static class RunSave
var named = designs.Where(key => key.IsSet()).Distinct()
.Where(key => cache.Get<ItemData>(key) == null)
.Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray();
if (named.Length == 0) return;
if (named.Length == 0) { RequireLayouts(cache, game); return; }
const string modHull = "mod-hull:";
var mods = named.Where(key => key.StartsWith(modHull, StringComparison.Ordinal)).Select(key => key.Substring(modHull.Length)).ToArray();
var others = named.Where(key => !key.StartsWith(modHull, StringComparison.Ordinal)).ToArray();
@ -125,6 +174,35 @@ public static class RunSave
". Reinstall them, or start a new game.");
}
// Continue's second gate: a hull's cells or hardpoints edited under a saved run must still hold what the run
// equipped on it (a package edited in place keeps its key, so the design check above passes). Each saved piece of
// gear must sit where equipping would accept it on the hull as the catalog holds it now, by the same rules
// (HardpointData.TakesAt for hardpoint gear, interior cells for tools); occupancy is not rechecked, the save
// carries only what equipping admitted.
private static void RequireLayouts(CultCache cache, SavedGame game)
{
var moved = new SortedSet<string>(StringComparer.Ordinal);
void Check(EntityPack pack)
{
if (pack.Hull.Data.Key.IsSet() && cache.Get<ItemData>(pack.Hull.Data.Key) is HullData hull)
foreach (var (position, item) in pack.Equipment.Concat(pack.CargoBays).Concat(pack.DockingBays))
{
if (!item.Data.Key.IsSet() || !(cache.Get<ItemData>(item.Data.Key) is EquippableItemData design)) continue;
var fits = design.HardpointType == HardpointType.Tool
? design.Shape.Coordinates.All(cell => hull.InteriorCells[position + design.Shape.Rotate(cell, item.Rotation)])
: hull.Hardpoints.Any(hardpoint => hardpoint.TakesAt(design, position - hardpoint.Position));
if (!fits) moved.Add(hull.Name);
}
foreach (var child in pack.Children) Check(child);
}
foreach (var zoneRef in game.Zones ?? Array.Empty<CultRecordRef<SavedZone>>())
foreach (var pack in cache.Get(zoneRef).Contents?.Entities ?? new List<EntityPack>())
Check(pack);
if (moved.Count > 0)
throw new InvalidOperationException("This run's equipment no longer fits its hulls' layouts; changed hulls: " +
string.Join(", ", moved) + ". Restore the earlier version of the mod, or start a new game.");
}
private static InvalidOperationException MalformedSave(string where) =>
new InvalidOperationException($"This run's save is malformed: {where}. Start a new game.");
@ -215,6 +293,11 @@ public class SavedZone
[Key(5)]
public ZonePack Contents;
// The seed zone index of the zone's province. Written only by RunSave.Capture and read only by Galaxy's save
// constructor; meaningful only when SavedGame.Provinces is present.
[Key(6)]
public int Province;
}
[MessagePackObject,
@ -224,6 +307,9 @@ public class SavedZone
]
public abstract class SavedActionBarBinding
{
// Element identity: minted by the cache on write; "" means unset.
[Key(2)] [CultElementId] public string Id = "";
}
[MessagePackObject]

View file

@ -6,6 +6,10 @@ public abstract class Scenario
{
public abstract string Name { get; }
// The scenario's stable identity: kebab-case, unique across Scenarios, never shown to the player, recorded by
// matches and replays. Name is display only.
public abstract string Id { get; }
// The conditions and what to verify in them, shown under the scenario's button.
public abstract string Brief { get; }

View file

@ -18,6 +18,10 @@ public sealed class ScenarioStage
private readonly List<string> _failures = new List<string>();
private readonly List<Placement> _placed = new List<Placement>();
private int _placements;
// The entities this stage built from a fit (a packed fit is a saved ship, whose cargo is its own) and those whose cargo the scenario
// authored with Cargo: the rest are stocked with the crates their weapons draw when staging ends (StockUnauthoredAmmunition).
private readonly HashSet<Entity> _fitted = new HashSet<Entity>();
private readonly HashSet<Entity> _cargoAuthored = new HashSet<Entity>();
internal ScenarioStage(ItemManager items, Zone arena, string startingHull, TutorialGenerationSettings tutorialGenerationSettings)
{
@ -40,6 +44,14 @@ public sealed class ScenarioStage
public int Credits { get; set; }
internal IReadOnlyList<string> Failures => _failures;
// Staging's last step: a fitted entity whose cargo the scenario did not author gets the crates its weapons draw, like every other
// newly built armed entity (Loadouts.StockAmmunition). Draws nothing, so it moves no item roll.
internal void StockUnauthoredAmmunition()
{
foreach (var entity in _fitted.Where(entity => !_cargoAuthored.Contains(entity)))
Loadouts.StockAmmunition(_items, entity);
}
internal bool PlayerPlaced { get; private set; }
internal Ship PlayerShip { get; private set; }
internal IReadOnlyList<Placement> Placed => _placed;
@ -50,6 +62,9 @@ public sealed class ScenarioStage
public ScenarioStance Stance;
public bool Piloted;
public AgentTask Task;
// What the pilot already knows at the start: an entity its sensors hold a track of (Approach). Seeded through the
// sensors' own state at admission, so they may lose it honestly.
public Entity Knows;
}
// A hull fitted slot by slot: each slot a design or a product, the hull cell it goes in, and its rotation. A name is
@ -137,7 +152,7 @@ public sealed class ScenarioStage
}
// The task a piloted entity starts on: it closes on the target and holds there (the flight's follow task, standoff
// nothing), in place of the patrol circuit the zone would draw for it. A check that rests on where a pilot goes states
// nothing), in place of the patrol circuit the zone would draw for it. The pilot starts knowing it (RunStart seeds a sensor track). A check that rests on where a pilot goes states
// it here, so the pilot's course is the scenario's and not a draw. An entity that is not placed and piloted, or a
// target that is not placed, is a failure.
public void Approach(Entity entity, Entity target)
@ -145,18 +160,31 @@ public sealed class ScenarioStage
var placement = _placed.FirstOrDefault(p => ReferenceEquals(p.Entity, entity));
if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; }
if (target == null) { _failures.Add("approach: no target to close on"); return; }
placement.Task = new FollowTask { Anchor = target, Standoff = 0f };
// The one production handle minted from truth: the stage states the pilot's knowledge of its anchor (ruling
// stage-states-pilot-knowledge), and RunStart seeds the matching sensor track. The view still answers about the
// anchor only while the pilot's sensors hold it.
placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f };
placement.Knows = target;
}
// Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage
// chooses its product) or a product of one (pinned); a name that is neither is a failure.
// chooses its product) or a product of one (pinned), or a commodity (one unit: the crates an AmmoType weapon reloads
// from); a name that is none of these is a failure.
public void Cargo(Entity entity, params string[] names)
{
if (entity == null) return;
_cargoAuthored.Add(entity);
var where = $"cargo of {entity.Name}";
var bay = entity.CargoBays.FirstOrDefault();
foreach (var name in names)
{
var commodity = _items.ItemData.GetAll<SimpleCommodityData>().FirstOrDefault(data => data.Name == name);
if (commodity != null)
{
if (bay == null || !bay.TryStore(new SimpleCommodity { Data = new CultRecordRef<ItemData>(_items.ItemData.RefOf<ItemData>(commodity).Key), Quantity = 1 }))
_failures.Add($"{where}: no room for {name}");
continue;
}
var (data, product) = NamedOrProduct<CraftedItemData>(name, where, found => found is EquippableItemData || found is ConsumableItemData);
if (data == null) continue;
var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data),
@ -176,7 +204,8 @@ public sealed class ScenarioStage
else
{
var failures = new List<string>();
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures, fit.Quality);
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures, fit.Quality, stockAmmunition: false);
if (entity != null) _fitted.Add(entity);
_failures.AddRange(failures.Select(failure => $"{where} ({fit.Loadout.Name}): {failure}"));
if (entity == null) return null;
}

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class AiFusedDiscipline : Scenario
{
public override string Name => "AI Fused Discipline";
public override string Id => "ai-fused-discipline";
public override string Brief => "A Djinni; a piloted neutral Longinus carrying a Smoke Proximity and a piloted hostile Longinus whose only weapon is " +
"Smoke Refused. Verify: the hostile closes on you and never fires its refused weapon; the neutral never fires its " +
"fused one.";

View file

@ -1,9 +1,11 @@
using System.Linq;
using static CultMath.math;
using static ItemRotation;
public sealed class Arcs : Scenario
{
public override string Name => "Arcs";
public override string Id => "arcs";
public override string Brief => "The Smoke Test Hull with forward mounts and a side mount on each flank; a bare hull off the bow, one off " +
"the stern, one abeam to port, one abeam to starboard, and a hostile turret. Verify: the hull keeps its " +
"heading while you aim at the stern hull, and the forward guns hold. Designate the bow hull and fire with the " +
@ -19,6 +21,11 @@ public sealed class Arcs : Scenario
public static readonly CultMath.float2 Bow = float2(0, 400), Stern = float2(0, -400), Turret = float2(600, 300),
Port = float2(-400, 0), Starboard = float2(400, 0);
// Crates of ammunition each shooter's hold is stocked with: the measured draw over a run (ScenarioAmmunitionRunTests: at most ten minutes of play, and in
// practice the ~260 s a shooter holding every gun down lasts before it dies of heat; a pilot who vents heat is unmeasured) plus half again and one. The turret drew 14 Ammo with the player in its range; the player drew 27 Ammo
// holding every gun down. The guided launchers fire only on a lock, which that run cannot give them, so they carry the default three crates.
public const int TurretAmmoCrates = 22, PlayerAmmoCrates = 42, PlayerMissileCrates = 3;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
@ -39,7 +46,8 @@ public sealed class Arcs : Scenario
("not if i see you first", int2(5, 10), None), // Compact Sensor
("Panopticon Prime", int2(3, 6), Clockwise), // Fire Control Array
("🫳🔫", int2(8, 4), Clockwise), // Autocannon
("ChargeBlast SG", int2(0, 4), CounterClockwise)); // Charged Scatter Blaster
("ChargeBlast SG", int2(0, 4), CounterClockwise), // Charged Scatter Blaster
("Store-All Plus", int2(4, 8), None)); // Cargo Bay: the only 2x2 the hull has free
var turret = stage.Fit("Turret",
("ClearPath", int2(1, 2), None), // Light Machine Gun
("ClearPath", int2(5, 2), None), // Light Machine Gun
@ -48,12 +56,15 @@ public sealed class Arcs : Scenario
("Compact Reactor", int2(3, 3), None),
("Turret Control Module", int2(3, 5), None),
("not if i see you first", int2(4, 5), None), // Compact Sensor
("Panopticon Prime", int2(3, 6), None)); // Fire Control Array
("Panopticon Prime", int2(3, 6), None), // Fire Control Array
("Store-All Plus", int2(3, 1), None)); // Cargo Bay: the only 2x2 the turret hull has free
stage.Player(fighter, float2(0, 0), facing: float2(0, 1));
var player = stage.Player(fighter, float2(0, 0), facing: float2(0, 1));
stage.Cargo(player, Enumerable.Repeat("Ammo", PlayerAmmoCrates).Concat(Enumerable.Repeat("Missiles", PlayerMissileCrates)).ToArray());
stage.Place(stage.Bare("Longinus"), Bow);
stage.Place(stage.Bare("Longinus"), Stern);
stage.Place(turret, Turret, stance: ScenarioStance.Hostile);
// The turret's guns reload from its hold, a crate of Ammo per magazine.
stage.Cargo(stage.Place(turret, Turret, stance: ScenarioStance.Hostile), Enumerable.Repeat("Ammo", TurretAmmoCrates).ToArray());
stage.Place(stage.Bare("Longinus"), Port);
stage.Place(stage.Bare("Longinus"), Starboard);
}

View file

@ -5,6 +5,7 @@ using static ItemRotation;
public sealed class Belt : Scenario
{
public override string Name => "Belt";
public override string Id => "belt";
public override string Brief => "The biggest asteroid belt the generator made, with the Longinus parked 100 m outside its ring, facing along it, " +
"and nothing else in the arena. Verify: next and previous target step through the rocks and the reticle sweeps " +
"across the belt without a hitch; Previous with nothing targeted takes the farthest rock; the target indicator " +

View file

@ -4,9 +4,10 @@ using static ItemRotation;
public sealed class Crossing : Scenario
{
public override string Name => "Crossing";
public override string Id => "crossing";
public override string Brief => "A Lightning Projector at the starboard gun and a Cell Blaster at the port gun, an unpiloted Djinni coasting across the bow " +
"close ahead and an unpiloted one waiting at range. The crosser is launched to port at 50 m/s and only coasts: nothing steers it, " +
"its hull drag slows it to about 25 m/s at the bow line, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " +
"close ahead and an unpiloted one waiting at range. The crosser is launched to port at 25 m/s and only coasts: nothing steers it, " +
"a hull coasts at its launch speed below its top speed, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " +
"(thrusting from the start carries you past it). Verify: as the Djinni crosses close, FastBlast lands some and projector " +
"almost none; against the Djinni at range both land. Fly it again without the Fire Control Array: " +
"with it the small gun lands more.";
@ -15,13 +16,15 @@ public sealed class Crossing : Scenario
// Where the Djinni sit: the player is at Origin facing +z. Origin is 250 m or more from every body, where the zone's
// pull is weakest (about 1.4 u/s^2; the sum of the wells never drops to nothing, and at the old origin the player sat on the star).
// The crosser is unpiloted and placed moving at the Djinni's 50 m/s cap, so its pass is ballistic: bent by the field and slowed
// by hull drag (it reaches the bow line at about 25 m/s relative to the player, 3.6 s in), and nothing steers it.
// The crosser is unpiloted and placed moving at 25 m/s, well under the Djinni's 50 m/s top speed, so its pass is ballistic: a hull
// coasts below its top speed (aetheria-gravity:ruling:no-drag-below-top-speed) and only the field bends it, so it reaches the
// bow line at 25 m/s relative to the player, 3.6 s in, and nothing steers it. (Launched at the cap with drag slowing it to 25 m/s,
// as this scenario was first tuned, it would now hold 50 m/s and cross in 2.6 s.)
// CrosserAt is the centre of the start window in which the pass holds: shifting it by 10 m along either axis keeps the pass
// inside the test's time and distance windows (CrossingHoldsAcrossTheStartWindow).
// The waiting Djinni sits inside the Lightning Projector's arc at 80% of its Range.
public static readonly CultMath.float2 Origin = float2(724, 124);
public static readonly CultMath.float2 CrosserAt = Origin + float2(-128, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(50, 0);
public static readonly CultMath.float2 CrosserAt = Origin + float2(-90, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(25, 0);
// The fighter is staged at this quality, so its gear and the range below do not ride the item stream.
public const float StagedQuality = .6f;
// The Lightning Projector's Range in metres at the staged fit: 996.99 after the three seconds the test settles for

View file

@ -17,6 +17,7 @@ public sealed class DemoTerminus : Scenario
};
public override string Name => "Terminus (Demo)";
public override string Id => "demo-terminus";
public override string Brief => "One gate, one region, one boss. Allied: the Pirates. Opposed: Zhestokost. " +
"Neutral: Lucent Media and Aeronautics Unlimited.";

View file

@ -3,6 +3,7 @@ using float2 = CultMath.float2;
public sealed class DjinniShakedown : Scenario
{
public override string Name => "Djinni Shakedown";
public override string Id => "djinni-shakedown";
public override string Brief => "A fitted Djinni in empty space, facing +z, with a bare hull dead ahead and one abeam. Verify: its thrusters " +
"move and turn it; with the hull offline and thrusters firing, its speed does not run away. Its " +
"articulated ballistic barrel points at the hull ahead when it bears, and rests at the arc edge while you " +

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class Duel : Scenario
{
public override string Name => "Duel";
public override string Id => "duel";
public override string Brief => "One piloted, hostile Longinus with slow guided missiles and a Light Laser in its hold. Verify: rolled " +
"hits and near-misses, subsystem aim, beam reads above bow, armour takes the missile, loot keeps its brand.";
public override uint Seed => 104;

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class FusedRounds : Scenario
{
public override string Name => "Fused Rounds";
public override string Id => "fused-rounds";
public override string Brief => "The Smoke Test Hull with Smoke Proximity, Smoke Delayed and a Cluster Cannon; a hostile bare Longinus at mid range, a bare " +
"hull dead ahead, a fragile one to starboard, a close one inside the proximity blast radius, and one astern. " +
"Verify with nothing selected: aim past every hull and the round bursts at max range along the aim; aim at the hull " +

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class LauncherAngles : Scenario
{
public override string Name => "Launcher Angles";
public override string Id => "launcher-angles";
public override string Brief => "A Guided Missile Launcher, a Swarm Rocket Pod and two Cell Blasters; a hostile bare Longinus broadside on, another beside it. " +
"Verify: hits pulse only the facing edge; a Swarm Rocket Pod round whose target dies fades unexploded.";
public override uint Seed => 105;

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class LockOn : Scenario
{
public override string Name => "Lock On";
public override string Id => "lock-on";
public override string Brief => "A hostile closes from out of sight, finds you and locks. Verify: no warning at first; once it locks, one " +
"indicator appears toward it and tightens as the lock grows; break line of sight or range and it vanishes; " +
"your own ping shows no incoming paint on its side, only the lock does.";

View file

@ -3,6 +3,7 @@ using float2 = CultMath.float2;
public sealed class LongHaul : Scenario
{
public override string Name => "Long Haul";
public override string Id => "long-haul";
public override string Brief => "A standard ship in a populated galaxy. Verify: memory stays flat across zones with kills; " +
"warp, quit and Continue keep items, tier and brand; death disables Continue.";
public override uint Seed => 106;

View file

@ -4,6 +4,7 @@ using float2 = CultMath.float2;
public sealed class MainGalaxy : Scenario
{
public override string Name => "Main Galaxy";
public override string Id => "main-galaxy";
public override string Brief => "The full galaxy: factions drawn at random, bosses guarding the road to the exit.";
public override Galaxy Generate(GalaxyStage stage) => stage.Main();

View file

@ -1,3 +1,4 @@
using System.Linq;
using static CultMath.math;
using static ItemRotation;
@ -7,8 +8,14 @@ public sealed class Pursuit : Scenario
// cloud breaking a sensor's lock, and well outside the 40 at which AStrongCloseSensorBurnsThrough shows a close
// sensor burning through.
private const float Astern = 300;
// Crates each shooter's hold is stocked with: the measured draw over a run (ScenarioAmmunitionRunTests: at most ten minutes of play, and in
// practice the ~250-270 s a shooter holding every gun down lasts before it dies of heat; a pilot who vents heat is unmeasured) plus half again and one where the hold has room. The player laid four crates of Mines and drew
// 33 Ammo; its hold takes 54 crates after five consumables and Mines, so its Ammo stops at 39. The pursuer drew 31 Ammo and one Missiles. A launcher
// that drew under a reload carries the default three crates.
public const int MineCrates = 7, PlayerAmmoCrates = 39, PlayerMissileCrates = 3, PursuerAmmoCrates = 47, PursuerMissileCrates = 3;
public override string Name => "Pursuit";
public override string Id => "pursuit";
public override string Brief => "A hostile Longinus on your tail; a Mine Launcher fitted, three Coolant Vents and two Thruster " +
"Overdrives aboard. Verify: lay mines in its path, the mine pulses slowly after 2 s, fast when " +
"the hostile is near, explodes 2 s later; vent and coast to lose it; overdrive away.";
@ -54,7 +61,9 @@ public sealed class Pursuit : Scenario
("Store-All Plus", int2(2, 8), None)); // Cargo Bay
var player = stage.Player(layer, float2(0, 0), facing: float2(0, 1));
stage.Cargo(player, "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it");
stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true);
stage.Cargo(player, new[] { "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it" }
.Concat(Enumerable.Repeat("Mines", MineCrates)).Concat(Enumerable.Repeat("Ammo", PlayerAmmoCrates)).Concat(Enumerable.Repeat("Missiles", PlayerMissileCrates)).ToArray());
var hostile = stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true);
stage.Cargo(hostile, Enumerable.Repeat("Ammo", PursuerAmmoCrates).Concat(Enumerable.Repeat("Missiles", PursuerMissileCrates)).ToArray());
}
}

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class RefusedRounds : Scenario
{
public override string Name => "Refused Rounds";
public override string Id => "refused-rounds";
public override string Brief => "The Smoke Test Hull with Smoke Refused, Smoke Refused Beam, Smoke Refused Charge and an Autocannon; a bare Longinus " +
"dead ahead. Each smoke weapon's blast radius is wider than its range, so it can never be fired. Verify: pull each " +
"trigger and a refused shot costs no ammo, energy, heat, sound, visibility or cooldown, the beam never starts and " +

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class StarvedReactor : Scenario
{
public override string Name => "Starved Reactor";
public override string Id => "starved-reactor";
public override string Brief => "Two Light Lasers on a Muon Fusion Reactor, which can fall to a quarter of the Compact Reactor's charge, and a bare hull " +
"to shoot. Verify: starvation reads as starvation, brownout degrades, refills stutter.";
public override uint Seed => 102;

View file

@ -4,6 +4,7 @@ using float2 = CultMath.float2;
public sealed class TutorialGalaxy : Scenario
{
public override string Name => "Tutorial Galaxy";
public override string Id => "tutorial-galaxy";
public override string Brief => "A small galaxy of fixed factions, entered in a ship of the protagonist faction.";
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();

View file

@ -4,6 +4,7 @@ using static ItemRotation;
public sealed class WeaponFeel : Scenario
{
public override string Name => "Weapon Feel";
public override string Id => "weapon-feel";
public override string Brief => "The Smoke Test Hull with a Charged Scatter Blaster, an Autocannon and a Cell Blaster burst weapon; a bare Longinus dead ahead. " +
"Verify: Charged Scatter Blaster and Autocannon fire with effects, particles and action-bar icons; the burst weapon's " +
"cadence is as before; a fast release-and-press on the Charged Scatter Blaster starts no second charge.";

View file

@ -83,6 +83,8 @@ public class SectorGenerationSettings
{
public float LinkDensity = .5f;
public int ZoneCount = 128;
// Zones per resource province (Provinces.Partition): calibration, not a gate.
public int ProvinceZones = Provinces.DefaultZonesPerProvince;
public int MegaCount;
public int BossCount;
}
@ -97,6 +99,8 @@ public class TutorialGenerationSettings
public string QuestFaction;
public float LinkDensity = .5f;
public int ZoneCount = 32;
// Zones per resource province (Provinces.Partition): calibration, not a gate.
public int ProvinceZones = Provinces.DefaultZonesPerProvince;
}
[Serializable, MessagePackObject(keyAsPropertyName:true), JsonObject]
@ -126,6 +130,20 @@ public class ZoneGenerationSettings
public ExponentialCurve AsteroidCount;
public ExponentialLerp AsteroidRotationSpeed;
// Trojan swarms (ruling trojans-ride-host-period-with-stations): the chance a planet or gas giant with a body for a parent
// carries one, each swarm's length along the orbit (turns) and radial half-width (a fraction of the host's distance), and
// the rocks round each of L4 and L5 by host distance. 0 here: a settings object built in code draws none.
public float TrojanProbability = 0f;
public float TrojanArc = .07f;
public float TrojanHalfWidth = .06f;
public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f };
// Laned belts (rulings lanes-two-sided-visual-margin, lane-narrow): the chance a belt candidate that kept its body and has
// room on both sides carries a laned belt, and the lane's half-width in the host's body radii (1.5 by ruling). LaneProbability
// is 0 here: a settings object built in code draws no lane.
public float LaneProbability = 0f;
public float LaneBodyRadii = 1.5f;
public float ResourceDensityMinimum = .1f;
public float ResourceDensityMaximum = 1.5f;
@ -169,6 +187,11 @@ public class GameplaySettings
public float ThermalQualityMin = 2;
public float ThermalQualityMax = .25f;
public float DefaultShutdownPerformance = .25f;
// Global flight tuning over every thruster's authored push (not its turning) and every hull's authored Drag. Push is a tenth, the
// ratio gravity fell by in the honest-wells cut (aetheria-gravity:field-strength-ruled, 5 to 0.5 u/s^2): most ships
// cannot climb straight out of a well and use orbits instead; an overspecced hull still can.
public float ThrustScale = .1f;
public float DragScale = 1f;
// Free-floating bodies (floating items, mines): drag on a launch velocity, drag on the force-driven drift, and
// how strongly the zone force accelerates it.
public float FloatingBodyDrag = .05f;
@ -191,6 +214,8 @@ public class GameplaySettings
public int WeaponGroupCount = 6;
public float WarpDistance = 25;
public float DockingDistance = 25;
// Units per second one pilot gives to a ship within DockingDistance; a commodity unit or one crafted item.
public float TransferRate = 2;
public float ProductionPersonalityLerp = .05f;
public float MessageDuration = 4f;
public float TargetPersistenceDuration = 3;

View file

@ -1,4 +1,4 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
/* 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/. */
@ -25,10 +25,11 @@ public class Ship : Entity
// [IgnoreMember] public Dictionary<Targetable, float> Contacts = new Dictionary<Targetable, float>();
// [IgnoreMember] public Targetable Target;
public Entity HomeEntity;
public float2 MovementDirection;
// The one facing command, -1..1, positive clockwise. Written each tick by the pilot (player input or the
// ship's agent); a heading becomes a demand only through Steering.Toward. Runtime only, never packed.
public float Turn;
// The pilot-controlled hull fields. PilotApplier, in the zone step, is their pilot-side writer, from the command the
// pilot (player input or the ship's agent) gave the entity; no Unity script can set them. Runtime only, never packed.
public float2 MovementDirection { get; internal set; }
// The one facing command, -1..1, positive clockwise; a heading becomes a demand only through Steering.Toward.
public float Turn { get; internal set; }
public bool IsPlayerShip;
private Thruster[] _allThrusters;
@ -111,6 +112,43 @@ public class Ship : Entity
#endregion
// The player's turn axis as a fraction of the hull's yaw extreme, banded so each thruster joins over an equal share of
// the input. The allocator serves a turn by the thrusters with the least translation per unit of yaw first and brings in
// the next as they saturate; here the thrusters yawing the asked way are ranked by that ratio, equal ratios form one band,
// and band i fills over input [i/k, (i+1)/k]. A hull whose best-ratio thrusters hold a small share of its yaw then gets
// half the travel of a two-band turn before its drives join, instead of the first tenth. Full input is still full yaw.
public float BandedTurn(float axis)
{
axis = clamp(axis, -1f, 1f);
if (axis == 0 || _allThrusters == null) return axis;
var direction = axis > 0 ? 1f : -1f;
var thrusters = new List<(float ratio, float yaw)>();
var total = 0f;
foreach (var thruster in _allThrusters)
{
var column = thruster.Column(thruster.NominalThrust);
var yaw = column.z * direction;
if (!(yaw > 0)) continue;
thrusters.Add((length(float2(column.x, column.y)) / yaw, yaw));
total += yaw;
}
if (!(total > 0)) return axis;
thrusters.Sort((a, b) => a.ratio.CompareTo(b.ratio));
var bands = new List<float>();
var bandRatio = float.NaN;
foreach (var (ratio, yaw) in thrusters)
{
if (bands.Count > 0 && abs(ratio - bandRatio) <= .02f * max(bandRatio, 1e-6f)) bands[bands.Count - 1] += yaw;
else { bands.Add(yaw); bandRatio = ratio; }
}
var travel = abs(axis) * bands.Count;
var band = min((int)travel, bands.Count - 1);
var served = 0f;
for (var i = 0; i < band; i++) served += bands[i];
served += (travel - band) * bands[band];
return direction * min(served / total, 1f);
}
// Called by Entity.Update once the items' performance is current and before the power bus is stepped and the items
// execute: liveness, thrust and the throttles they decide are one phase, the one Execute acts on. The lock is a
// bound on the allocator's solve; intent reaches the allocator here and nowhere else.
@ -126,13 +164,22 @@ public class Ship : Entity
public override void Update(float delta)
{
var velocityMagnitude = length(Velocity);
if(velocityMagnitude > .01f)
Velocity = normalize(Velocity) * decay(velocityMagnitude, HullData.Drag, delta);
Position.xz += Velocity * delta;
Velocity += Zone.GetForce(Position.xz) * delta;
// No drag below top speed: ships coast and orbits do not decay; drag bleeds off only the excess over the hull's top
// speed (aetheria-gravity:ruling:no-drag-below-top-speed). The VelocityLimit clamp still caps it, so the excess is
// what this step's forces added; a soft cap, where surfing the beat is a temporary boost, is the ruling's other half.
// A docked ship has no flight of its own: its parent owns its pose (Entity.Update).
if (Parent == null)
{
var velocityMagnitude = length(Velocity);
var topSpeed = float.PositiveInfinity;
foreach (var limit in GetBehaviors<VelocityLimit>()) topSpeed = min(topSpeed, limit.Limit);
if (velocityMagnitude > topSpeed)
Velocity = normalize(Velocity) * (topSpeed + decay(velocityMagnitude - topSpeed, HullData.Drag * ItemManager.GameplaySettings.DragScale, delta));
Position.xz += Velocity * delta;
Velocity += Zone.GetForce(Position.xz) * delta;
}
var normal = Zone.GetNormal(Position.xz);
var shipRight = Direction.Rotate(ItemRotation.Clockwise);
var forward = cross(float3(shipRight.x, 0, shipRight.y), normal);

Some files were not shown because too many files have changed in this diff Show more