From 263ac421fbda8489af3de43d6ccefd1857f235fe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 13 Sep 2026 14:02:49 +0200 Subject: [PATCH 001/116] REC-095: delete confirmed-dead narrative test fixtures and zero-byte scratch file Deletes only the two candidate groups that survived independent re-verification and had zero collision with the live codex/aetheria-state-rebuild branch: - GameData/Narrative/HeroOrZero.json, GameData/Narrative/Locations/TestA.json, GameData/Narrative/Quests/TestQuest.json: StoryProcessor.cs only ever calls DirectoryInfo.EnumerateFiles("*.ink"/"*ink") against these directories; no .cs, .csproj Content/None glob, or other file in the tree references these basenames. Confirmed via rg across the whole tree and voidbot search_sources (no hits either way). - Vault/Aetheria/scratch.md: zero-byte Obsidian vault scratch file, no references anywhere in the tree, no voidbot search_sources hits. Kill-attempts that came up empty (recorded for the record, not evidence of absence beyond what's stated): - rg for basenames HeroOrZero.json/TestA.json/TestQuest.json/scratch.md across the whole tree excluding Library/Temp/obj/bin/.git: no hits. - Economy.Server.csproj and Economy.Shared.csproj inspected for wildcard Content/None includes that might implicitly sweep in the JSON files: none found (Economy.Shared only globs ServerShared/**/*.cs as Compile). - mcp__voidbot__search_sources queried for both candidate groups: no consumer hits, only unrelated fuzzy matches. NOT deleted here despite being in REC-095's candidate list (see coordinator report for full detail): StrategyGameManager.csbak, CultClient.cs, CultCache.cs backing-store classes, Economy.Server/appsettings.json, and Economy.Shared/bin+obj all collide with commits already on origin/codex/aetheria-state-rebuild (which has independently already deleted or heavily rewritten those exact paths) and are therefore BLOCKED per the no-touch-live-branch-paths rule, not executed from this worktree. REC-095. Co-Authored-By: Claude Sonnet 5 --- GameData/Narrative/HeroOrZero.json | 1 - GameData/Narrative/Locations/TestA.json | 1 - GameData/Narrative/Quests/TestQuest.json | 1 - Vault/Aetheria/scratch.md | 0 4 files changed, 3 deletions(-) delete mode 100644 GameData/Narrative/HeroOrZero.json delete mode 100644 GameData/Narrative/Locations/TestA.json delete mode 100644 GameData/Narrative/Quests/TestQuest.json delete mode 100644 Vault/Aetheria/scratch.md diff --git a/GameData/Narrative/HeroOrZero.json b/GameData/Narrative/HeroOrZero.json deleted file mode 100644 index e63bd875c..000000000 --- a/GameData/Narrative/HeroOrZero.json +++ /dev/null @@ -1 +0,0 @@ -{"inkVersion":20,"root":[["^As you wander the docking area, swaying a little as you readjust to the inertial compensation aboard the station, a shout stops you in your tracks, and a quick about face reveals a frantic young man wearing what appears to be a dazzling, star-splattered cape.","\n","^“Oh, there you are,” he says, grabbing your hand and pulling you into the shadow of a rather dilapidated looking ship. “Thank credits I’ve found you. I’ve been looking all over for you.”","\n",["ev",{"^->":"0.4.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":"0.c-0","flg":18},{"s":["^You blink. “Uh…have we met? And can you let go of me? You’re kind of hurting my arm.”",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":"0.4.s"},[{"#n":"$r2"}],"\n","^The man seems surprised to hear this.","\n","^“Oh, come on, old buddy,” he says. “It’s Bertie! From Potoo The gala? I was bartending for extra credits, and we were just chatting, and then everything was all, ‘boom! Bang!’ and everyone was running around all ‘oh, no, we’re going to die!”’ and you were all ‘just part of the human condition, am I right?’ and just straight up did a death drop into the carnivorous sand dunes after that thing with the spines! Bam! Just like that. And we all thought you were going to get eaten but you totally didn’t and I said, I said, ‘that was amazing,’ and you said ‘I know!’ And then you ordered a Calpraxian moonshine popper with ‘extra moons, please’ and a fondant umbrella and drank the whole thing? The clip got like, ten thousand hits. I’m your biggest fan!”","\n","^You…don’t remember any of that. But you do really love Calpraxian moonshine poppers, and doubly so when they’re doubled. These two things are probably related. “Oh,” you say. “Thanks, I think.”","\n","^“Oh, this is the best!” Bertie says. “I’ve been looking all over the galaxy for you, and here you are! Come on, come on, I want to show you something.”","\n","^“Show me what?” you say, not entirely sure where this is headed.","\n","^“This,” he says, whipping out a phaser and firing a stun beam directly between your eyes.","\n",{"->":"0.g-0"},{"#f":5}],"g-0":[{"->":"wakeywakeyeggsandfakey"},["ev",{"^->":"0.g-0.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"Really? Why?\"",{"->":"$r","var":true},null]}],["ev",{"^->":"0.g-0.2.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"Oh, hey Dave! Nice ship.\" ",{"->":"$r","var":true},null]}],"ev","str","^-kick him in the shins and run-","/str","/ev",{"*":".^.c-3","flg":20},{"c-1":["ev",{"^->":"0.g-0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.1.s"},[{"#n":"$r2"}],"\n","^“Why?” the man says. “Why? I can’t believe you’re asking me that. And me your biggest fan, too.”","\n","^“Really?” you say. “You grab me out of nowhere, say you’ve been looking all over for me, start dragging me to your junky, rat-trap ship, and are somehow surprised when I want to know your motives?”","\n",[[{"->":"ship"},{"->":"0.g-1"},{"#f":5,"#n":"g-0"}],null],{"#f":5}],"c-2":["ev",{"^->":"0.g-0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.2.s"},[{"#n":"$r2"}],"\n","^He stops.","\n","^“Dave?” he says, looking deeply offended. “Who’s Dave? Dave is a debtor’s name. I’m Humblebert Maxibillion Goiterwold. The Twenty-Second. I’d think you of all people would know that.”","\n","^“Not good enough to be Humblebrag Monstermillion Goiterwold the first, eh?” you say. “Well, we’ve all been there.”","\n","^“Humblebert Maxibillion Goiterwold,” he insists, cape swirling around him and catching the light with an incredible sense of narrative drama.","\n","^You’ve never killed a man for his clothes before, but you’re definitely starting to consider it.","\n","^“And you, ,” he continues, oblivious to your thoughts, “Have been chosen. By me! Out of all the best fighters in the galaxy—oh, yes, I’ve been watching you, . The sand dunes on Potoo 6—the thing with the jelly—the battle on the Moons of Alcorion! Amazing! I need your heroic skills. You see,” he says, gesturing at the half falling apart ship behind him, “my manor has been infested with Iltorian venom beetles. Oh, horrible things; twice the size of a man and three times as deadly! I need you to exterminate them.”","\n",[["ev",{"^->":"0.g-0.c-2.19.0.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^“Fighting gigantic killer insects with my pal Dave? It’s just like old times! Consider me hired.” ",{"->":"$r","var":true},null]}],["ev",{"^->":"0.g-0.c-2.19.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^“Uh…what manor? That’s a spaceship.” ",{"->":"$r","var":true},null]}],["ev",{"^->":"0.g-0.c-2.19.2.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^“Well, this is obviously a trap. Bye!” ",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"0.g-0.c-2.19.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.0.s"},[{"#n":"$r2"}],"\n","^“Humblebert,” the young man says, and with a swirl of his cape motions you up the steps to his ‘manor’. You, long since ready for an adventure, follow. As the door slides shut behind you with a loud screech and a faint scent of scorched rubber, you turn to look for Humblebert, only to find him gone and your vision slowly going black. As you slip into unconsciousness, you swear you can hear someone whisper, slowly, incredulously, “Who the debt is Dave?”","\n",{"->":"wakeywakeyeggsandfakey"},{"->":"0.g-1"},{"#f":5}],"c-1":["ev",{"^->":"0.g-0.c-2.19.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.1.s"},[{"#n":"$r2"}],"\n",{"->":"ship"},{"->":"0.g-1"},{"#f":5}],"c-2":["ev",{"^->":"0.g-0.c-2.19.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.2.s"},[{"#n":"$r2"}],"\n",{"->":"Exitone_thepeaceout"},{"->":"0.g-1"},{"#f":5}]}],{"#f":5}],"c-3":["^ ",{"->":"Exitone_thepeaceout"},"\n",{"->":"0.g-1"},{"#f":5}],"#f":5}],"g-1":["done",{"#f":5}]}],"done",{"ship":["^“It’s not a ship,” he snaps, turning slightly red with outrage. You seriously begin to debate the merits of just knocking him out and running for it. “It’s a battleground! An arena. A castle of unspeakable horrors, to be conquered by only the bravest of heroes! It is your destiny.”","\n","^You look at the grav-dynamic, be-thrustered, battered but still very obviously ship-like space-faring transport behind him.","\n","^“No,” you say. “I’m pretty sure it’s a ship.”","\n","^You turn to leave the madman to his destiny, and then, with a sudden, sharp crackle of electricity along the labyrinth of your nerves, darkness closes in.","\n",[[{"->":"wakeywakeyeggsandfakey"},{"#f":5,"#n":"g-0"}],null],{"#f":1}],"wakeywakeyeggsandfakey":[["^You awaken, gear missing and a faint prickle between your eyes, to a cascade of brilliant light, which eventually clears to reveal the ominously decorated interior of a ship, and the staticky blare of a catchy jingle played over monitors that are not built for quite that level of *peppy*.","\n","^This is nothing, of course, to the voice which follows it. It’s the voice of a being who has somehow attained their designated 8 hours of restful sleep, and followed it up with a heaping bowl of sunbeams and three different kinds of protein. It is, you recognize resentfully, the voice of someone who proudly identifies as a morning person.","\n","^You make a mental note to strangle them should the opportunity ever arise.","\n","^“Gooooood morning, Lucent Employees and Copyrighted Genotype Creations!” the voice cheers. “It’s 5am studio time, and you know what that means! Oh yes. It’s time for the number one reality show hit of the season, the reason you all wake up bright eyed and bushy tailed these workday mornings--and not just for my dashing good looks, either! It’s that wonderful time of day we all gather by the X-CAff bot to watch one randomly chosen citizen, plucked out of their daily life by yours truly, strive to prove they have the mettle to survive the Gauntlet…and become our new Hero! I’m your host, Humblebert Maxibillion, and this is…HERO or ZERO!”","\n","^Whoops and cheers suddenly surround you. Whether real or manufactured, you can’t tell, and you don’t know which would be more comforting.","\n","^You take a closer look around. For a supposed number one hit, the scenic design is a bit…lackluster. The broad outlines are obviously there—the idea of an old Terran castle traced in stonework and lanternlight—but you can clearly see through the facade the sleek metallic gleam of a spacefaring world.","\n","^You don’t have much time to consider, because the voice is speaking again, loud and tinny:","\n","^“Today’s would-be hero is a rough and tumble contract pilot pulled straight outta X station! (under license agreement with Finch Cybernetics, of course!) [he/she/it select pilot’s gender]’s just awoken here in our ancient Castle Victory, hand hewn from the stone by bold warriors in the halcyon days of yore--”","\n",["ev",{"^->":"wakeywakeyeggsandfakey.0.16.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^He takes a breath, and you take the opportunity to cut in. “Hey! Hey! I think there’s been a mistake. I didn’t sign up for this.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.17.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^”Oh, for—it’s not a castle; it’s a spaceship! Look—I can see the wing struts—” ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.18.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^The curiousity is too much. You burst into the continuous torrent of his speech with an excited, “Oh, this sounds fun! Is there a prize?” ",{"->":"$r","var":true},null]}],"ev","str","^-Search for an exit-","/str","/ev",{"*":".^.c-3","flg":20},"ev","str","^-Do nothing-","/str","/ev",{"*":".^.c-4","flg":20},{"c-0":["ev",{"^->":"wakeywakeyeggsandfakey.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.16.s"},[{"#n":"$r2"}],{"->":".^.^.deathandorglory"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"c-1":["ev",{"^->":"wakeywakeyeggsandfakey.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.17.s"},[{"#n":"$r2"}],"\n","^The voice—Humblebert Maxabillion?—sounds annoyed as he continues over you. “The, ah, ancient and very real Castle Victory, home of…the GAUNTLET!”","\n","^“I don’t need any more armour, thanks,” you say, feeling around for the escape hatch you know from extensive perusal of the fine print of all your ship purchases that Lucent Industries safety regulations requires to be no more than nine metres away at all given times. The ‘stonework’ gives slightly beneath your fingers. “Besides, I’m pretty sure this is just papier mache—”","\n","^“Papier mache is an ancient and respected art form,” Humblebert snaps, before gathering himself and continuing cheerily in his role of host.","\n","^At your feet, the ‘stones’ begin to glow, illuminating a path into the bowels of the ship. \"I want to state that at no point did I sign up for, agree to, or otherwise consent to any of this,\" you say, because there are limits and, more importantly, liabilities.","\n",{"->":".^.^.deathandorglory"},{"->":".^.^.deathandorglory"},{"#f":5}],"c-2":["ev",{"^->":"wakeywakeyeggsandfakey.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.18.s"},[{"#n":"$r2"}],"\n","^“Oh, the prize is greater than you could ever imagine,” Humblebert says. “And it looks like our contestant is raring to fight for it! So without further ado…”","\n","^The stones beneath your feet kindle with some soft white light.","\n","^“March forth, you fount of potential,” Humblebert says. “Glory awaits.”","\n","^You march. ",{"->":"TheGauntlet"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"c-3":["^ ","\n","^Despite your best efforts, no exit is to be found. The only way, it seems, is forward. As if to underscore this inevitability, the stones beneath your feet begin to glow, leading you on to whatever strangeness awaits. ",{"->":"TheGauntlet"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"c-4":["^ ",{"->":"TheGauntlet"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"deathandorglory":["^“Oh, nobody signs up,” your host—Humblebert?—says. “This is Hero or Zero! A challenge of daring—of thinking on your feet—of courage in the face of unexpected adversity! There’s no glory in preparation.”","\n","^“This is kidnapping and I’m leaving,” you say.","\n","^Humblebert Maxabillion laughs. It is…not a nice laugh. “Oh, no, my friend. No one leaves. Not til they’ve either passed through the trials—or perished. You can opt for immediate death, of course, but it won’t make you popular with the viewers.”","\n","^“Yeah,” you say. “That’s incredibly illegal.”","\n","^“Not in Castle Victory!” Humblebert trills. The stones at your feet begin to glow softly, forming a path leading into the darkness ahead. His voice becomes stentorian. “Now, contestant. Choose. Death or glory.”","\n","^Slowly, familiarly, and then gaining frantic speed in a contagious mob, the studio audience picks up the chant.","\n","^Death or glory.","\n","^Death or glory.","\n","^Death or glory.","\n","^Death or glory. Death or glory. Deathorglory.Deathorglory.deathorglorydeathorglorydeathorglorydeathorglordeathor","\n","^“Well?” Humblebert says.","\n",["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.22.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-5","flg":18},{"s":["^Uh, glory, I guess. ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.23.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-6","flg":18},{"s":["^GLORY! RAH! ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.24.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-7","flg":18},{"s":["^Death. I pick death. ",{"->":"$r","var":true},null]}],{"c-5":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-5.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.22.s"},[{"#n":"$r2"}],{"->":"TheGauntlet"},"\n",{"#f":5}],"c-6":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-6.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.23.s"},[{"#n":"$r2"}],{"->":"TheGauntlet"},"\n",{"#f":5}],"c-7":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.24.s"},[{"#n":"$r2"}],"\n","^The crowd howls.","\n","^“Uh, really?” Humblebert says. “No one’s ever picked death before.”","\n","^“What can I say, I’m a trendsetter,” you say. “I’d be obliged if you could deliver my remains to my employers; they’re going to want to know who to sue for property damage.”","\n","^The crowd screams.","\n","^Humblebert’s voice drops from manic announcer to annoyed show-biz veteran, dropping off air and wrangling the talent. “Look, Captain Fatalism, this is a reality show. We don’t actually…I don’t come into work to kill people, okay? It’s all smoke and mirrors, right? And we have ratings to think about! So if you don’t mind just trying the first trial and then taking a dive there, if you really feel like you have to, it’d make my life a lot easier. And yours too. You don’t want to upset the channel execs. You really don’t.”","\n",[["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.0.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^You smile. \"They'll live. And so will I. I'm leaving.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^You sigh. \"Well, alright. But if you’re going to go around kidnapping people you may as well really commit to the whole ‘death or glory’ thing. Make the stakes matter. Otherwise your audience won’t care what happens.\"",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.0.s"},[{"#n":"$r2"}],{"->":"Exitone_thepeaceout"},"\n",{"#f":5}],"c-1":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.1.s"},[{"#n":"$r2"}],"\n","^“Great, great. Wonderful!” Humblebert says. “Now if you could just say “Glory” on camera, that’d be…that’d be perfect.","\n","^“Uh, glory?” you say.","\n","^Humblebert sighs. “Good enough.” ",{"->":"TheGauntlet"},"\n",{"#f":5}]}],{"#f":5}],"#f":5}]}],{"#f":1}],"TheGauntlet":[["^Humblebert Maxibillion’s voice echoes sonorously as you follow the gently glowing path. “Our hero-to-be must face the Gauntlet, and its three onerous challenges: first, the Trial of Wit! Quick thought and cleverness will lead to victory—no brainless barbarians will be *our* heroes.","\n","^“Secondly, they will face the Trial of Daring! Courage and nerves of synthetic sapphire are required for this task—only the bravest survive.”","\n","^“And third, the most perilous of all, and the perennial favourite of our loyal fans—the Trial of Heroically-Avoiding-Being-Agonizingly-Disintegrated-While-Dodging-Plasma-Bombs! A very necessary quality for anyone who endeavors to attain the coveted title of Official Lucent Hero (tm).”","\n","^“And, needless to say, it’s hero or zero, so any failure on the part of our adventurer means—that’s right! THE OUBLIETTE!”","\n","^Excited cheers ring through the speakers.","\n","^“Oubliette,” you whisper to yourself. It’s fun to say. You doubt it’ll be fun to experience.","\n","^“So, contestant,” Maxibillion roars. “Are you ready to brave the Gauntlet and prove yourself…our HERO?”","\n",["ev",{"^->":"TheGauntlet.0.14.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^\"Bring it on!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"TheGauntlet.0.15.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"Fine. I guess. If I have to.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"TheGauntlet.0.16.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"...Don't I get a weapon or anything?\"",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"TheGauntlet.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.14.s"},[{"#n":"$r2"}],{"->":"Trial_of_Wit"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"TheGauntlet.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.15.s"},[{"#n":"$r2"}],{"->":"Trial_of_Wit"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["ev",{"^->":"TheGauntlet.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.16.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^“Nope!” Humblebert says. “A true hero requires no weapon but their heart.”","\n","^“I’d rather be a live coward with a mini railgun, thanks,” you say, but it’s clear that you don’t really have the choice.","\n",{"->":"Trial_of_Wit"},{"#f":5}]}],{"#f":1}],"Trial_of_Wit":[["^The path opens out into a large room, which would be empty if it weren’t mostly taken up by the enormous cybernetic beast blocking the clearly marked exit forward. Two giant paws, folded with incongruous daintiness below a human face, one clearly printed from an artist’s collection, flex, catching the light like knife blades.","\n","^“Welcome,” she says, voice low and purring, “to the Trial of Wit. I am the Snynx, Mistress of Riddles.”","\n","^“Don’t you mean the sphynx?” you say, before you can stop yourself.","\n","^The beast sighs loudly. “No. That name is a Miss Terri’s copyright. I’m off brand. Stop asking.”","\n","^You nod understandingly.","\n","^“I am the arbiter of the Trial of Wit,” the Snynx continues, long, rapier like claws tapping on the stone. “And you, my nosy hero to be, must answer me these riddles three, if you another day would see.”","\n","^“That’s not a riddle, is it?” you ask.","\n","^“No,” she says. “But this is:","\n","^I eat and eat,","\n","^But never sated,","\n","^A hunger here","\n","^From death created,","\n","^I have no mind,","\n","^I cannot feel,","\n","^Still, spaghetti is","\n","^my favourite meal.","\n","^What am I?”","\n","^“Ooh, that’s a tough one!” Humblebert crows. “Let’s hope it doesn’t make spaghetti out of them. It’s done in plenty in the past-a!”","\n","^An audible groan can be heard from the studio audience.","\n","^You shake your head, scrunch up your eyes, and blurt out an answer.","\n",["ev",{"^->":"Trial_of_Wit.0.40.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^\"Uh, um…a puppy? No! Wait…\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.41.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"A black hole.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.42.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"A fungus.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.43.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-3","flg":18},{"s":["^\"Oh, that's easy. It's me!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.44.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-4","flg":18},{"s":["^\"A virus!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.45.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-5","flg":18},{"s":["^\"I don't know that!\" ",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"Trial_of_Wit.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.40.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"Trial_of_Wit.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.41.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["ev",{"^->":"Trial_of_Wit.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.42.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-3":["ev",{"^->":"Trial_of_Wit.0.c-3.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.43.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-4":["ev",{"^->":"Trial_of_Wit.0.c-4.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.44.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-5":["ev",{"^->":"Trial_of_Wit.0.c-5.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.45.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^The snynx smiles.","\n","^“You are…correct.”","\n","^“And that’s one riddle down, two to go. Will our intrepid contestant survive this test of wits? Tune in after the break and find out! I’m your host, Humblebert Maximillion, and this is…HERO or ZERO!”","\n","^“So…can I go get an xcaff or something, or…” you begin.","\n","^Laughter rings out of over the speakers. “No break for you, contestant! No, it’s life or death in Castle Victory! And you’ve got two and a half more trials to complete.”","\n","ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"!","/ev",{"*":".^.c-6","flg":9},"ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-7","flg":9},{"c-6":[{"->":".^.^.^.^.Riddletwo"},"\n",{"->":".^.^.^.bit"},{"#f":5}],"c-7":[{"->":".^.^.^.bit"},"\n",{"->":".^.^.^.bit"},{"#f":5}],"#f":5}],"bit":["^You grin. \"Oh, come on. If you’re not going to kill me you can at least get me a drink.\"","\n","^\"Fine,\" Humblebert hisses. \"But only if you stop being a pain in my--\"","\n",{"->":".^.^.^.Riddletwo"},{"#f":5}]}],{"Riddletwo":[["^“The second riddle,” the snynx says imperturbably, “is as follows:","\n","^An atom? NAh.","\n","^It’s not my all,","\n","^Though crystalline,","\n","^And white, and small,","\n","^But sight’s not the sense,","\n","^Of who I am,","\n","^My taste—unmatched,","\n","^My match—a CLam!","\n","^What am I?”","\n",["ev",{"^->":"Trial_of_Wit.Riddletwo.0.20.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^\"Now that has to be cocaine.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.21.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"Bacon!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.22.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"Salt?\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.23.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-3","flg":18},{"s":["^\"Star dust.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.24.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-4","flg":18},{"s":["^\"Elbruxian sticking powder?\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.25.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-5","flg":18},{"s":["^\"...can I take a mulligan?\"",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.20.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.21.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.22.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"c-3":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-3.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.23.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-4":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-4.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.24.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-5":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-5.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.25.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^“Amazing! Our contestant has beat the first odds kindly provided by Diceroll Statistics Inc., proving their cleverness two for nil against the devious snynx! Will their wit be enough to see them through to the next challenge, or will our brave pilot perish in ignominy? Tune in after these words from our sponsors to find out!”","\n","ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"!","/ev",{"*":".^.c-6","flg":9},"ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-7","flg":9},{"c-6":[{"->":".^.^.^.^.^.RiddleThree"},"\n",{"->":".^.^.^.bitsy"},{"#f":5}],"c-7":[{"->":".^.^.^.bitsy"},"\n",{"->":".^.^.^.bitsy"},{"#f":5}],"#f":5}],"bitsy":["^“Honestly, I’m a little offended that my potential death is being televised and you’re not even showing it live.”","\n","^“Don't be ridiculous. Real death is never cinematic enough for TV. That’s why we have editors.” ",{"->":".^.^.^.^.RiddleThree"},"\n",{"#f":5}]}],{"#f":1}],"RiddleThree":[[["^“So, who exactly are these sponsors?” you ask, and are, unsurprisingly, ignored.","\n","^“The third riddle is as follows,” the snynx says.","\n","^“Without a doubt, you’ve challenged me,","\n","^You’ve got a wit, or two, or three,","\n","^You think you’re smart—you’ve got it made,","\n","^Your thoughts are quick; your mind’s a blade,","\n","^You think this test will be a rout—","\n","^But answer this:","\n","^Which way is out?”","\n","^And as the snynx speaks, the room spins disorientingly, rolls and whirls, walls and floor and ceiling switching place like crossed roulette wheels, the clearly marked exit now decidely unclear.","\n","^“Oh, they look puzzled now! What’ll it be, contestant? Only one route leads forward. The rest are just a sudden sharp drop down five stories, to the very end of the story.”","\n","^You sit down, on the cold stone (ish) floor, and come up with a plan.","\n","ev","str","^-guess at random-","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^-take one last sip before splashing your XCAFF on the walls to see where it seeps through the gaps around the door-","/str",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-1","flg":21},"ev","str","^-redefine the idea of ‘inside’ as being everything contained beyond the walls. You are now outside. The way ‘out’ is now...here.-","/str","/ev",{"*":".^.c-2","flg":20},"ev","str","^-Give up-","/str","/ev",{"*":".^.c-3","flg":20},{"c-0":["^ ","\n",["ev","visit",3,"seq","/ev","ev","du",0,"==","/ev",{"->":".^.s0","c":true},"ev","du",1,"==","/ev",{"->":".^.s1","c":true},"ev","du",2,"==","/ev",{"->":".^.s2","c":true},"nop",{"s0":["pop","^ ",{"->":"ExitThree_oublietteroulette"},{"->":".^.^.23"},null],"s1":["pop",{"->":"Trial_of_Wit.forth"},{"->":".^.^.23"},null],"s2":["pop",{"->":"ExitThree_oublietteroulette"},{"->":".^.^.23"},null],"#f":5}],"\n",{"#f":5}],"c-1":["^ ",{"->":".^.^.^.^.^.forth"},"\n",{"#f":5}],"c-2":["^ ",{"->":".^.^.^.^.^.forth"},"\n",{"#f":5}],"c-3":["^ ",{"->":"ExitThree_oublietteroulette"},"\n",{"#f":5}],"#f":5,"#n":"g-0"}],null],{"#f":1}],"forth":[["^To your eternal shock, the snynx listens to you answer…and nods.","\n","^“Congratulations,” she says, and removes two of her claws. She offers them to you. “You have chosen…wisely. Good luck, hero.”","\n","^The audience cheers.","\n","^“You’ve passed the Trial of Wit. You may continue.”","\n","^The door opens. You continue, snynx claws in hand.","\n","^“Incredible! Look at that! Our hero to be continues to defy all bets on their grizzly demise. I’ve got to say, I didn’t see that one coming. We’ve got a crafty one on our hands, eh? Alright. But cunning will only get you so far. Now, contestant, gird your loins and line your girdles, and get ready to face…the Trial of Daring!”","\n","^You enter a new chamber, a long, high hall lit only by the gently swaying illumination of very atmospheric biolanterns. You step forward gingerly, only to jump back as your foot meets nothing but empty air. Something whistles by in the space it had just been.","\n","^In the shifting dimness, you can just make out the dark lines of rope hanging above the pit.","\n","^“Daring. Courage. Bravery. Nerve,” Humblebert says, voice hushed and almost reverent. “The very essence of a hero. Now, contestant, your task lies before you. Cross the Chasm. Brave the void. If you dare.”","\n","^Someone in the studio audience begins a low chant, and it catches like brushfire in a parched forest.","\n","^Jump. Jump. Jump. Jump.","\n","^Jump.","\n","ev","str","^Attempt to cross using the available system of ropes and handholds","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^if variable geckogirl is true, as yet undefined:","/str","/ev",{"*":".^.c-1","flg":20},"ev","str","^Attempt to cross using the snynx claws as climbing tools.","/str","/ev",{"*":".^.c-2","flg":20},["ev",{"^->":"Trial_of_Wit.forth.0.42.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-3","flg":18},{"s":["^\"Brave the void,\" you whisper, square your shoulders, and leap straight off the edge into the darkness below. ",{"->":"$r","var":true},null]}],{"c-0":["\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["^ You look down at your fingers, splayed and not quite human, remembering high walls and falling and genomes in beautiful sequence and the kind of hope that kills just as fast as it saves, and smile. ",{"->":".^.^.c-3.8.geckotime"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["\n",{"->":".^.^.g-0"},{"#f":5}],"c-3":["ev",{"^->":"Trial_of_Wit.forth.0.c-3.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.42.s"},[{"#n":"$r2"}],{"->":"ExitFour_bouncehousestageleft"},"\n",[["^“I love you, gecko hands,” you whisper, as you crawl easily along the wall to the gasps and cheers of the studio audience.","\n","^“And our contestant has…lizard genes! Apparently!” Humblebert chirps. “Oh, that’s a twist I didn’t see coming. I guess you could say it’s…the anolest trick in the book! Well, I don’t suppose they’re going to have any problem with this challenge. But there’s still one more trial to go—and the most dangerous of all. Like they say in the pleasure houses of Anthrax 9—the best is yet to come!”","\n",{"->":".^.^.^.^.g-0"},{"#f":5,"#n":"geckotime"}],null],{"#f":5}],"g-0":["^It’s a struggle, but it works. Sweaty, scratched, and exhausted, you survive the Trial of Daring and make it to the other side.","\n","^“Oohh, we nearly lost ‘em a couple of times there, but look at that! They made it. What a heartstopping scene that was. But, of course, the show’s not over yet! One trial remains.”","\n","^Tentatively, you follow the glowing path as it leads you once more forward, into another large room, likewise decked out like the main hall of a castle.","\n","^Humblebert’s voice rings out once more.","\n","^“So far our would be hero has proved their wit, and their courage—but now it’s time for them to prove…their resistance to superheated plasma!”","\n","^An earthshattering scream of excitement erupts from the speakers. You look around for an exit, but it’s too late, the doors have sealed behind you and there’s no way forward.","\n","ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"!","/ev",{"*":".^.c-4","flg":9},"ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-5","flg":9},{"c-4":[{"->":".^.^.^.bob.kill"},"\n",{"->":".^.^.^.bob"},{"#f":5}],"c-5":[{"->":".^.^.^.bob"},"\n",{"->":".^.^.^.bob"},{"#f":5}],"#f":5}],"bob":["^\"I thought you said you weren’t in the business of killing people!”","\n","^“I mean, we’re not in the business of killing people before they’ve provided full entertainment value,” Humblebert says. “I thought that was implied? Now don’t be a baby and a take a blast of superheated plasma to the face like true hero of the people. And don’t forget to smile!”","\n",{"->":".^.kill"},["^Slots in the walls open up, revealing the death machines within, and suddenly you’re running and dodging for your life.","\n","ev","str","^Keep dodging. Surely they can’t keep firing forever?","/str","/ev",{"*":".^.c-6","flg":20},"ev","str","^Find shelter","/str","/ev",{"*":".^.c-7","flg":20},["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.14.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str",{"CNT?":".^.^.^.^.c-3.8.geckotime"},"/ev",{"*":".^.^.c-8","flg":19},{"s":["^Forward...is on fire. Left is also on fire. What was once right--extremely on fire. But up...up is safe. And you know you can make it up there, you mutant wonder, you. ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.15.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str",{"CNT?":"ship"},"/ev",{"*":".^.^.c-9","flg":19},{"s":["^This, as you quite firmly told Humblebarf-Maximurderous-Soon-To-Be-Maximurdered on your abduction, is not a castle. It's a debting spaceship. It's designed to protect your frail meatball body. You grin, and activate the personnel shields. ",{"->":"$r","var":true},null]}],{"c-6":["^ ",{"->":"Exit_DeathMKI"},"\n",{"#f":5}],"c-7":["^ ",{"->":"Exit_DeathMKII"},"\n",{"#f":5}],"c-8":["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.c-8.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.14.s"},[{"#n":"$r2"}],"\n","^“I love you, gecko hands,” you whisper, as you hang by your mutant grip cells from the high ceilings, safely away from any stray plasma blasts. Below you, everything burns. ",{"->":"ExitFive_Victory"},"\n",{"#f":5}],"c-9":["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.c-9.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.15.s"},[{"#n":"$r2"}],{"->":"ExitFive_Victory"},"\n",{"#f":5}],"#f":5,"#n":"kill"}],{"#f":5}]}],{"#f":1}],"#f":1}],"Exitone_thepeaceout":["^By dint of your swift feet, the complete lack of pursuit, and a loud warning cry of ‘stranger danger!’, you escape the odd young man, and go on with your adventurer’s life.","\n","^The only lasting impact of the encounter is the occasional passing thought on a sleepless night: “I wonder where the weirdo got that amazing cape?”","\n","^Just another little mystery for the filing cabinet. ","done","\n",{"#f":1}],"ExitTwo_givemedeathorgivemedeath":["^Humblebert pauses. “Well, I can’t really—oh, forget it. Just leave! You’re terrible on camera anyway. I’ve got better prospects three spreadsheets deep!”","\n","^A door opens to your left, leading back out into the busy spaceport you were abducted from. As you duck through it and head down the ladder, you can hear Humblebert yelling after you, “And don’t think I’m not showing this tape to the execs, you, you…aggressively independent person! I will! And they’re not going to be happy…”","\n","^You don’t think much of it. You have *real* trials to face, out there in the galactic maelstrom. And you really should be getting to them. ","done","\n",{"#f":1}],"ExitThree_oublietteroulette":["^“You are…incorrect,” the snynx says.","\n","^“INCORRECT,” Humblebert half-screams. The speakers pop and crackle. “You know what that means, folks! It’s time for…THE OUBLIETTE!”","\n","^And with that, the floor opens beneath you and all of a sudden you’re falling, all your life saving gadgets long gone and years of your past flashing past before your eyes, heart pounding to break your ribs and lungs gasping for the last air you’ll ever get before—","\n","^Everything stops. You stare, amazed to find yourself here, now, and wonderfully, gloriously alive. Even the scruffy, dark clothed man standing over you looks beautiful to your miraculously saved eyes.","\n","^“The snynx stumped you, eh?” he says, scratching his chin. “Well, it’s alright. She gets most of you. I told HB that he should make the riddles easier, you know, for the show, but who listens to me? I’m just a techie.” He looks at you. “Don’t look so scared, kid. The big bad Oubliette is just an airlock with a holofilter to make it feel like you’re falling fifty feet instead of two. [laughs] Got you good, didn’t it? Bit rough on the ol’ equilibrium, but the screaming always goes over well with the fans. I mean, for all they know, you’re bleeding out on a pit of spikes right now. Bloodthirsty, the public is. Absolutely bloodthirsty. But there it is. Good try. Oh, and if you want your compensation, you need to go visit the Unexpected Employee Liason office on Durant 3. Fill out forms 2a, 6x, and they’ll need a blood sample or three.”","\n","^“Thanks?” you manage, and scramble up and out into the spaceport before anyone else can decide you need to be part of their show. ","done","\n",{"#f":1}],"ExitFour_bouncehousestageleft":["^After falling for what seems like lightyears, you hit the ground with a light thump and nothing bruised but your pride.","\n","^“That was incredibly stupid,” a grizzled man in a dusty black shirt says, helping you to your feet. “I’ve never seen anything that stupid before. Really, I’m actually kind of impressed. Anyway, you lost! Too bad, very sad, you can claim your compensation at the Reality Conscription Offices on Datrik 6. Have a fantastic day!”","\n","^You wander off, slightly dazed, into the bustling spaceport from whence you began, not entirely sure what just happened and determined not to think too much about it.","\n","^You really, really, really need a drink. ","done","\n",{"#f":1}],"ExitFive_Victory":["^“Brilliant! Marvelous! Wonderful! Let’s have a hand for our new HERO, [Pilot’s name],” Humblebert Maxibillion’s voice announces, and then the man himself appears at last, dropping from above like an enormous bejeweled bat.","\n","^He beams, the expression so dazzling you can’t help but blink, and whips off his cloak. It flutters there, in a nonexistent breeze, catching the light like diamonds.","\n","^“Here,” he says. “I present to you, the Cape of Heroism, and the Official Irrevocable Title of Genuine Lucent Hero (tm). Use it wisely. And congratulations.”","\n","^As you tie the cape around your shoulders, the sound of cheering reaches you from worlds away, the people of Lucent’s empire sharing their adulation.","\n","^“Congratulations,” Humblebert says once more, shaking your hand. “And now…we send our hero back to [pronoun] life, secure in the knowledge that if we are ever in need…our tried and tested saviour will be ready.”","\n","^You open your mouth to speak, only to find yourself standing back in the spaceport from whence you began, your own trusty ship beside you and no Humblebert Maxibillion or dressed up castle ship in sight.","\n","^You turn, and as you do, your new cape catches the light like a swatch full of stars.","\n","^Hero, you think. Yeah. Yeah, I am. obtainspacecape ","done","\n",{"#f":1}],"Exit_DeathMKI":["^As it turns out, they can keep firing forever. Or, at least, much longer than you can keep running.","\n","^The last thing you remember is a strange sense of peace as your nerves burn away and then the world becomes just...nothing.","\n","^You wake up three days later in a Finch Medical regen-pod, all limbs and nerves attached and really wishing you didn’t remember how you ended up there.","\n","^You make a note in your journal. If the opportunity ever arises, Humblebert Maxibillion is toast. ","done","\n",{"#f":1}],"Exit_DeathMKII":["^Shockingly enough, reality game show sets don’t involve a lot of hiding places that are resistant to plasma arcing around at 28000K. You are positively incandescent to discover this. Luckily, the surprise doesn’t last long.","\n","^You wake up three days later in a Finch Medical regen-pod, all limbs and nerves attached and really wishing you didn’t remember how you ended up there.","\n","^You make a note in your journal. If the opportunity ever arises, Humblebert Maxibillion is toast. ","done","\n",{"#f":1}],"#f":1}],"listDefs":{}} \ No newline at end of file diff --git a/GameData/Narrative/Locations/TestA.json b/GameData/Narrative/Locations/TestA.json deleted file mode 100644 index dd822d57f..000000000 --- a/GameData/Narrative/Locations/TestA.json +++ /dev/null @@ -1 +0,0 @@ -{"inkVersion":20,"root":[[{"#":"constraint: DistanceFrom start = 0"},{"#":"type: Station"},{"#":"security: Open"},{"#":"faction: Miss Terri"},{"#":"name: Test Location 1"},{"#":"nameZone: Start Zone"},{"->":"start"},["done",{"#f":5,"#n":"g-0"}],null],"done",{"start":[["^This is the start of the location story.","\n","ev","str","^Go to location A1","/str","/ev",{"*":".^.c-0","flg":4},["ev",{"^->":"start.0.8.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^This will end the story.",{"->":"$r","var":true},null]}],{"c-0":["^ ",{"->":"A1"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"start.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.8.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^They lived happily ever after.","\n","end",{"#f":5}]}],{"#f":1}],"A1":[["^This content is in location A1","\n","ev","str","^Return to start","/str","/ev",{"*":".^.c-0","flg":4},["ev",{"^->":"A1.0.8.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":"A1.0.c-1","flg":2},{"s":["^Choice in location A1 ",{"->":"$r","var":true},null]}],{"c-0":["^ ",{"->":"start"},"\n",{"#f":5}],"c-1":["ev",{"^->":"A1.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":"A1.0.8.s"},[{"#n":"$r2"}],"end","\n",{"#f":5}]}],{"#f":1}],"#f":1}],"listDefs":{}} \ No newline at end of file diff --git a/GameData/Narrative/Quests/TestQuest.json b/GameData/Narrative/Quests/TestQuest.json deleted file mode 100644 index 41095e5f9..000000000 --- a/GameData/Narrative/Quests/TestQuest.json +++ /dev/null @@ -1 +0,0 @@ -{"inkVersion":20,"root":[[["done",{"#f":5,"#n":"g-0"}],null],"done",{"A1":[[{"#":"location: TestA"},{"#":"required"},"ev","str","^This choice is being injected from TestQuest","/str","/ev",{"*":".^.c-0","flg":4},{"c-0":["^ ",{"->":"A1A"},"\n",{"#f":5}]}],{"#f":1}],"A1A":[["^This is a branch thread being injected by TestQuest","\n","ev","str","^Exit the injected thread","/str","/ev",{"*":".^.c-0","flg":20},{"c-0":["^ ","end","\n",{"#f":5}]}],{"#f":1}],"#f":1}],"listDefs":{}} \ No newline at end of file diff --git a/Vault/Aetheria/scratch.md b/Vault/Aetheria/scratch.md deleted file mode 100644 index e69de29bb..000000000 From 75607bfd61b1743d2f3e071158922685ef4f0aaa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 19:03:30 +0200 Subject: [PATCH 002/116] Locomotion campaign: target and cut map (control allocation, capability-aware AI) Co-Authored-By: Claude Opus 5 --- docs/locomotion-cut.md | 638 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 638 insertions(+) create mode 100644 docs/locomotion-cut.md diff --git a/docs/locomotion-cut.md b/docs/locomotion-cut.md new file mode 100644 index 000000000..47d47d872 --- /dev/null +++ b/docs/locomotion-cut.md @@ -0,0 +1,638 @@ +# Locomotion: Control Allocation and Capability-Aware AI + +Date: 2026-09-22 + +Status: target and cut map, from an Imagination pass. None of it has landed. Every anchor is against +`origin/codex/item-provenance` at `cd846916`; the working tree on `codex/fire-control-10` (`e8fbb6a6`) has an +identical tree for every file named below. The first half of this document is the **target** (the ends). The second +half is the **cut map** (the means). When the campaign closes, split the target out as +`docs/locomotion-target.md` and keep this file as history. + +Claims marked **(probe)** come from running code: scratch builds of `Aetheria.Shared` from `cd846916` against +CultLib `45c2f40`, run in the session scratchpad. That covers catalog dumps, a legacy-msgpack decode, base-commit +flight measurements and a solver comparison. None of the probe sources are kept. Each result below says what was +run. The base suite passes 244/244 in that scratch copy. + +## Rulings + +Operator, 2026-09-22: + +- **Problem statement.** "We're gonna need much smarter locomotion AI if they're to survive getting a thruster + shot out. Longinus (non X) had one big thruster in the back and two attitude thrusters in the front. Losing an + attitude thruster means you can't rotate that way anymore. Losing a thruster on the Djinn usually means some form + of crab walking just became optimal. Naively trying to use a thruster that's been shot out makes them real easy + to cheese." +- **R1, two layers.** (1) **Control allocation owns which thrusters fire.** Each live actuator contributes a + body-frame wrench (Fx, Fy, τ) per unit throttle, with throttle in [0,1]. A bounded least-squares solve finds the + throttles for the desired wrench (Johansen & Fossen 2013 control-allocation survey). This replaces the rotation + buckets and the strafe torque compensation. Thrusters that are destroyed, unpowered, disabled or degraded drop + out of the matrix or scale down in it. (2) **The AI plans with the allocator's achievable set.** For a desired + Δv it scores candidate headings by achievable acceleration along Δv plus turn time in the directions the ship can + still rotate. Crab walking and the long way round emerge from that scoring; neither is special-cased. +- **R2, the same-inputs invariant.** "Player and AI are controlling the same ship with the same inputs, this is an + Aetheria invariant." The player's controls go through the same allocator, through the same input surface the AI + writes. Any second path that bypasses it is a forbidden writer. +- **R3.** A capability-aware **combat facing/tactics** layer is wanted as its own later cut. Example: a one-way + turner keeps its target drifting the way it can turn and leans on its turret arcs. This document maps it at + target-shape level only. +- **R4.** If CultMath lacks a bounded least-squares solver, CultMath gets one, as a CultLib cut. Aetheria does not + grow a local helper. CultMath has none (probe: `packages/cultmath/src/CultMath` at `45c2f40` holds vector types, + `math`, `BatchMath`, `Voronoi` and `Random`, with no linear solver of any kind). + +Self, relaying an operator answer, 2026-09-22. This is the layer split, stated precisely: + +- **Below the inputs**, shared by player and AI, is the ship's own controller plus the allocator. It turns toward + `LookDirection` and realises `MovementDirection` with whatever actuators are live. **Choosing a feasible turn + direction belongs here**, including the long way round when one rotation direction is dead. If it lived in the + AI planner, a player ship with a dead attitude thruster would still stick when looking toward its dead side. + Holding heading against torque coupling (strafing with an off-centre thruster) also belongs here. +- **Above the inputs**, AI only, is the heading planner. It decides what to write into + `LookDirection`/`MovementDirection`, for example to turn sideways and crab-walk. The player makes that choice by + hand. +- The long-way test therefore belongs to the shared-controller cut and drives `LookDirection` directly, as the + player would. The crab-walk test belongs to the planner cut. + +--- + +# Part I: Target + +## Objective + +A ship that loses actuators keeps flying as well as its remaining actuators allow, whether a player or an AI is +flying it. The AI picks headings and thrust directions from what the ship can still do, so shooting out a thruster +creates a real handicap and not an exploit. + +## Invariants + +1. **Same inputs.** A ship is commanded only through `Ship.MovementDirection` (body frame, each component a + fraction of the ship's capacity along that half-axis) and `Entity.LookDirection` (world-frame heading command; + the controller reads only its `xz`). The player writes both (`Gameplay/ActionGameManager.cs:1254`, `:1263`). The + AI writes the same two fields. Nothing else commands a ship's actuators. Identical inputs to identical ships give + identical actuator throttles, whoever wrote them. +2. **One owner for actuator commands.** The ship controller (new, `ShipControl`, below) is the only writer of + `Thruster` and `AetherDrive` throttles. `Ship.cs`'s mixer, its rotation buckets and its strafe compensation stop + existing, and do not survive as a mode or a fallback. +3. **The allocator reads live actuator state every tick.** An actuator's column is derived each tick from its live + stats and liveness: `EquippedItem.Active` (enabled, thermally online, durability online), a power grant above the + same epsilon `Thruster.Execute` uses, and the current resolved `Thrust` (or the drive's own stats) with its + durability, heat and power curves applied. No bucket, cache or event-pruned set decides whether an actuator + counts. A repaired or re-powered actuator rejoins by the same read that dropped it. +4. **Manual and programmatic inputs share one commit path.** A player keypress, an AI state write, and any future + scripted or network input all land in the same two fields, and they reach the actuators by the same + `ShipControl` step inside `Ship.Update`. No input source writes `Thruster.Axis`, `AetherDrive.Axis` or + `Entity.Direction` directly. +5. **Heading changes only through actuators.** `Entity.Direction` is written by actuator execution + (`Thruster.Execute`, `AetherDrive.Execute`), by wormhole scripting (`Ship.ExitWormhole`), by spawn placement + (`ActionGameManager.cs:848`) and by save unpack (`EntitySerializer.cs:66`). The controller does not write it. + Today's snap-to-look lerp (`Ship.cs:280-284`) is a free, thrusterless rotation, and it goes. +6. **Kinematic rotation is retained.** Rotation stays kinematic: yaw rate is proportional to live throttle + (`Thruster.cs:106-107`, `AetherDrive.cs:154-155`), with no angular momentum. Nothing found here argues for + raising it. It keeps turn time exact (angle divided by rate), which the planner relies on. It also matches the + operator's model ("losing an attitude thruster means you can't rotate that way anymore"): with no momentum, + there is no coast. +7. **A dead actuator never receives throttle.** A destroyed, disabled or unpowered actuator gets zero throttle, + not a saturated request it cannot honour. +8. **Capability is one model.** Turn time, turn direction, rotation capacity and translation capacity are computed + in one place (`ShipControl`), from the same effectiveness matrix the allocator solves with. The AI queries them + and never recomputes a private estimate. + +## Canonical implementations + +- **Solver:** CultMath (`F:\Projects\CultLib\packages\cultmath`), a box-constrained linear least-squares solver, + added by Cut 1. It is the only numerical authority. Aetheria composes the problem and calls it. +- **Allocation reference:** T. A. Johansen and T. I. Fossen, "Control allocation: A survey," *Automatica* 49(5), + 2013. Its weighted-least-squares form with priorities is what `ShipControl` states. The active-set method follows + O. Härkegård, "Efficient active set algorithms for solving constrained least squares problems in aircraft control + allocation," CDC 2002, which is bounded-variable least squares in Stark and Parker's sense. + +## Out of scope + +- **Fire-control Cut 12** (edge-on hit placement in `FireControl.cs`, `Entity.ApplyHit`, `DamageSchematic`) is + being mapped concurrently by another Imagination pass. Cuts 1 to 3 here touch none of those files. The allocator + is a read-only downstream consumer of item destruction (`ItemDamage`, `Entity.cs:488`). **Overlap:** Cut 4 (combat + facing) will rewrite `Agents/States/Combat.cs:88-127`, and presenting armour edge-on is exactly what Cut 12's hit + placement rewards. Cut 4 must be mapped after Cut 12 lands. +- **The old AetheriaEve/CultMesh/daemon rebuild** is taxidermy. It is not precedent and not a consumer. +- Thruster geometry (the normalized moment-arm `Thruster.Torque`, `Thruster.cs:64-69`, which ignores lever length + and uses unweighted shape cells for centre of mass) is the game's model and stays as authored. Arrival braking in + `MoveToState`, docking approach and formation flight are also out. + +## Not consumers + +- `Assets/Scripts/Gameplay/*` presentation (`ShipInstance` particles, audio parameters) reads throttles and never + writes them. +- `TurretController` writes `Entity.LookDirection` for turret entities. That is the AI above the inputs for a + non-ship, and it does not drive ship actuators. See the audit. + +## 0b: identity, lifecycle, authority + +Nothing persistent changes. Actuator wrench columns, capacities, the chosen turn direction and planner state are +runtime-derived per tick, or per planner cadence, and are never saved. + +| Kind | Named by | Lifecycle | Decides | +|---|---|---|---| +| Actuator column (runtime) | the `Thruster`/`AetherDrive` behaviour instance on an `EquippedItem` | rebuilt every tick from live stats; appears and disappears with liveness | `ShipControl` derives; item state (durability, heat, power, `Enabled`) is owned by `EquippedItem`/`PowerBus` as today | +| `MovementDirection`, `LookDirection` (runtime) | fields on `Ship`/`Entity` | overwritten each frame by whoever flies the ship | player (`ActionGameManager`) or AI (`Agent` layer), never both on one ship (`Zone.cs:111` gives agents only to `!IsPlayerShip`) | +| Committed turn direction, planner plan (runtime) | fields on `ShipControl` / `Agent` | reset on wormhole, dock, spawn | `ShipControl` / `Agent` | +| `ThrusterData`, `AetherDriveData`, `HullData.Hardpoints/Shape` (persisted, catalog) | catalog record key | authored | unchanged; read only | +| `GameplaySettings.TorqueFloor`, `TorqueMultiplier`, `AetherTorqueMultiplier` (authored, `Settings.cs:205-207`; values in `Assets/Resources/Settings.asset`: 0.5, 0.1, 0.1) | field name | authored | unchanged values. `TorqueFloor` changes role; see Cut 2 | + +This campaign adds no authored field. If Hands finds it needs a tunable, that is an operator question, and any new +field on a persisted CultCache document must be nullable (MessagePack nil rule). + +--- + +# Part II: Body map (what the probes established) + +## The mixer today + +`Ship.Update` (`Ship.cs:251-292`) runs before `base.Update` executes behaviours. The agents' writes come earlier +still, because `Zone.cs:170-173` updates agents before entities. The steps: + +1. `RecalculateThrust` (`Ship.cs:147-247`) sums per-bucket thrust over thrusters whose item is `Active`. +2. It zeroes every thruster's `Axis` (`:256`). +3. Strafe: the right and left buckets get `±MovementDirection.x`, minus a torque compensation for the thrusters + whose torque sign matches the bucket total (`:257-274`). +4. Forward and reverse buckets add `±MovementDirection.y` (`:275-276`). +5. Rotation: `deltaRot = sqrt(|dot(look, right)|)·sign` (`:278-285`) is added to the clockwise bucket and subtracted + from the counter-clockwise bucket (`:287-288`). +6. `AetherDrive.Axis = (MovementDirection.y, MovementDirection.x, deltaRot)` (`:291`). There is no allocation for + drives at all. + +Buckets are built once in `Activate` from mount rotation and from `Thruster.Torque` against +`TorqueFloor` (`:102-118`). `RemoveThruster` prunes them permanently on `ItemDestroyed` (`:123`, `:133-143`). + +`Thruster.Axis` saturates to [0,1] (`Thruster.cs:45-49`). `Execute` (`:94-115`) applies `Velocity -= facing·input· +Thrust/Mass·dt`, then rotates `Direction` kinematically by `input·Torque·Thrust·TorqueMultiplier/Mass·dt`. Its gate +is `input > .01 && PowerSupply > 1e-4`. + +## Findings (probe) + +Fixtures here are built from the legacy catalog (next section): hull shape, hardpoints, thruster items with +constant `Thrust` (Large Drive 250000, Medium Drive 200000; mass 100 each), `TorqueFloor` 0.5, `TorqueMultiplier` +0.1. Each ship sits at (37, −12) facing `normalize(0.8, 0.6)`, runs 3 warm-up ticks, is reset, and is measured over +one tick at dt 0.02. Body-frame acceleration is (r, f) in m/s². Heading rate is compass degrees per second, +clockwise positive. + +**Torque factors** (`Thruster.Torque`, the sine of the moment arm): Longinus mains ±0.159 (below the floor, so +never used for rotation), attitude thrusters ±0.998. Djinni stern 0 / ±0.161, laterals ±0.727 (aft) and ±0.867 +(fore), bow 0. + +**Base flight table** (`cd846916`, after warm-up). These are the Cut 2 pins: + +| Input | Longinus (r, f, °/s) | Djinni (r, f, °/s) | +|---|---|---| +| forward `(0,1)` | 2.37, 172.38, 0 | 0.11, 55.56, 0 | +| reverse `(0,−1)` | 0, 0, 0 | 0, −18.52, 0 | +| strafe right `(1,0)` | 0, 0, 0 (compensation zeroes the lone attitude thruster) | 34.03, 0.42, 0 (axes 1.00 / 0.84) | +| strafe left `(−1,0)` | 0, 0, 0 | −34.03, 0.42, 0 | +| look 90° right | 68.97, 0, +394.33 | 0.01, 0.59, +169.13 | +| look 90° left | −68.97, 0, −394.33 | 0.01, −0.50, −169.13 | +| look 30° right | 48.77, 0, +278.84 | 0, 0.30, +119.59 | +| look directly behind | 0, 0, 0, **and `|Direction|` falls to 0.96** | same | +| forward+strafe `(.707,.707)` | 1.18, 121.89, 0 | 24.12, 39.49, 0 | + +The small cross terms (2.37, 0.42) are real. `Execute` rotates `Direction` thruster by thruster within one tick, so +later thrusters push along an already-rotated nose. The allocator cannot see this, so pins need a tolerance for it. + +**Defects the table exposes:** + +- **Dead-side stick.** On a Longinus with `Th.CCW` destroyed, looking 90° left gives a heading error of 90.0° + after 30 s. It never turns. Looking right, it reaches within 2° in 0.32 s, the same as intact. +- **Main-thruster cheese.** On a Djinni with all three stern thrusters destroyed, `Agent` plus `MoveToState` toward + a point 300 m to port leaves the ship at speed 0 after 120 s. `Agent.Accelerate` points at Δv and burns forward + thrusters only (`Agent.cs:64-68`), and it never reaches the strafe branch. Intact, the same run arrives in + 4.34 s. With only Stern 2 lost it arrives in 4.98 s; with only Port Aft lost, 4.40 s. +- **Snap-to-look.** Inside `|sin error| < .01`, `Ship.cs:283` lerps `Direction` toward the look vector, which is a + rotation no thruster pays for. When the look is directly behind, the lerp goes through the origin and shrinks + `|Direction|`, and thruster force scales with that length (`Thruster.cs:105` uses the unnormalized item + direction). Base also turns *slower* as error passes 90°: `deltaRot` follows `sin`, so it is zero at 180°. +- **First tick.** Items are not yet `Active` on the first `Update` after `Activate`, so the first-tick strafe + compensation divides by a zero count (NaN, unused) and the strafe fires uncompensated. +- **`TurnTime` is wrong and unused.** `Ship.cs:61-66` divides angle by the sum of torque *factors* over mass, which + is unitless nonsense: 261535 s for a Longinus 90° turn. It has no consumer. +- **Permanent pruning.** `RemoveThruster` never restores a thruster whose durability comes back, and `Recalculate*` + reads `Active` while the mixer still commands inactive thrusters. +- **The live catalog's only ship flies on a drive.** `GameData/Aetheria.cc` holds one ship hull, **LonginusX** + (mass 2500, drag 0.1, 6×17), with an `AetherDrive` hardpoint and **no `Thruster` hardpoint**. It moves on + "Traction" (rotor mass (30,20,5), λ (0.5,1,1), MaxRpm 40000..80000). Two thruster designs exist ("deep space + burnout", "Large Drive"), but no live ship can mount them (`docs/content-batch-one.md` §1 says the same). + **AetherDrive is therefore the load-bearing actuator for the player today**, and the allocator must reproduce its + base behaviour exactly. +- **Existing tests sit on an off-centre thruster.** `BrownoutTests`/`ConditionRatioTests` equip a one-cell thruster + by `TryFindSpace` onto a 5×5 hull. It lands at (1,1) with `Torque` −0.707, and `MovementDirection (0,−1)` fires it + while it spins the ship. Under an allocator that holds heading, a lone off-centre thruster cannot fire without + rotating, so those fixtures must be re-seated (Cut 2). + +## Legacy hull layouts (probe) + +The live catalog is the post-breach minimal rebuild, so absence there proves nothing. The layouts come from the +git-LFS objects of `GameData/Legacy/AetherDB.2021-03-05.msgpack` (`142998fd…`) and `AetherDB.2021-04-14.msgpack` +(`21ce7bd2…`), both present in `.git/lfs/objects`, decoded with MessagePack `ConvertToJson`. Shape cells are +`bool[w,h]`, flattened as `x*h + y`, with y = 0 at the stern. + +**Longinus** (2021-04-14: mass 2500, drag 0.1, 6×17; the 2021-03-05 copy has mass 5000). The shape matches the +live LonginusX's. Rows from y = 0: `.####.` ×4, `######` ×5, `.####.` ×2, `..##..` ×6. + +| Hardpoint | Pos | Shape | Rotation | +|---|---|---|---| +| Th.L | (1,0) | 2×2 | Reversed (pushes forward) | +| Th.R | (3,0) | 2×2 | Reversed | +| Th.CW | (2,14) | 1×2 | CounterClockwise (pushes starboard, nose right) | +| Th.CCW | (3,14) | 1×2 | Clockwise (pushes port, nose left) | + +**The data has two aft 2×2 thrusters, not one.** The operator remembers "one big thruster". Fixtures follow the +data (see Q3). + +**Djinni** (mass 10000, drag 0.2, 14×17). Rows from y = 0: `....######....`, `...########...`, +`..##########..`, `.############.` ×2, `##############` ×5, `.############.`, `...########...` ×3, +`....######....`, `.....####.....`, `......##......`. + +| Hardpoint | Pos | Shape | Rotation | +|---|---|---|---| +| Stern 1 | (6,0) | 2×1 | Reversed | +| Stern 2 | (5,1) | 2×1 | Reversed | +| Stern 3 | (7,1) | 2×1 | Reversed | +| Port Aft | (4,4) | 1×2 | CounterClockwise | +| Port Fore | (4,11) | 1×2 | CounterClockwise | +| Starboard Aft | (9,4) | 1×2 | Clockwise | +| Starboard Fore | (9,11) | 1×2 | Clockwise | +| Bow | (6,14) | 2×1 | None (pushes aft) | + +Legacy thruster items used for the fixtures: Large Drive 2×2, 250000 thrust, mass 100. Medium Drive 2×1, thrust +100000..300000 (fixtures use a constant 200000), mass 100. Small Drive 1×1, 75000. + +## Solver choice (probe) + +The comparison ran on the fixtures' own normalized columns, plus a 16-column doubled Djinni: 2000 random wrench +demands each, cost measured against a 20000-iteration projected-gradient reference, double precision, +allocation-heavy scratch C#, Release build. + +| Method | Worst excess cost | Suboptimal cases | Mean time | +|---|---|---|---| +| Primal active set (bounded-variable LS, Härkegård WLS form) | ≤ 7e-15 | 0 in every case | 1.8–9.5 µs (n 3–8), 32–38 µs (n 15–16); ≤ 30 iterations cold | +| Redistributed pseudo-inverse | up to 4e3 | 270–1564 of 2000 (n ≤ 8) | about half the active set's time | + +**Pick the active set.** It is exact, its cold-start cost is bounded, and it warm-starts from last tick's solution. +The redistributed pseudo-inverse saves about half the time and is wrong in up to 78% of cases on these very +matrices. A production float implementation without allocations will be faster than the scratch numbers. + +## Consumer audit + +`cd846916`, every `*.cs` outside `Assets/Plugins`. "Dies" means deleted, not deprecated. + +| Surface | Readers / writers | Fate | +|---|---|---| +| `Ship.MovementDirection` (`Ship.cs:27`) | W: `ActionGameManager.cs:1263` (player), `Agent.cs:67,73,77` (AI); tests `BrownoutTests.cs:143,402`, `ConditionRatioTests.cs:184,193,243,258`. R: `Ship.cs:261-291` | **Survives** as the translation input. Its meaning is defined by `ShipControl` (fraction of capacity per half-axis) | +| `Entity.LookDirection` (`Entity.cs:55`) | W: `ActionGameManager.cs:1254` (player), `Agent.cs:66`, `MoveTo.cs:26`, `Combat.cs:126`, `TurretController.cs:72,75` (turret entities; no `EntityTypeRestriction`, `TurretController.cs:14-15`). R (as heading): `Ship.cs:278`. R (as aim): `LockWeapon.cs:94`, `GuidedProjectileManager.cs:56`, `EntityInstance.cs:404`, `ShipInstance.cs:117` (tractor), `ZoneRenderer.cs:432`, `ActionGameManager.cs:376` (reticle target pick) | **Survives** as heading command *and* aim. `MoveTo.cs:26` dies in Cut 3 (a double writer: `Accelerate` overwrites it when Δv > 20). The heading/aim conflation is Cut 4's question (Q4) | +| `Thruster.Axis` (`Thruster.cs:45-49`) | W: `Ship.cs:256-288` only. R: `ShipInstance.cs:139` (particles), `Thruster.cs:88` (power request), `:96` (audio), `:102-111` | Getter **survives**. The public setter **dies**; only `ShipControl` commands throttle | +| `AetherDrive.Axis` (`AetherDrive.cs:81-85`) | W: `Ship.cs:291` only. R: drive internals, audio `:170` | Same as `Thruster.Axis` | +| `IAnalogBehavior` (`Behaviors.cs:96-99`) | implemented by `Thruster.cs:40`; consumer only a comment, `Entity.cs:2007` | **Dies** (Cut 2 deletes first) | +| Bucket sets `_forward/_reverse/_right/_left/_clockwise/_counterClockwiseThrusters`, `_thrusterItems`, `_aetherDrives`, `_aetherDriveItems` (`Ship.cs:30-39`) | `Ship.cs` only | **Die** | +| `ForwardThrust`, `ReverseThrust`, `LeftStrafeThrust`, `RightStrafeThrust`, `ClockwiseTorque`, `CounterClockwiseTorque`, `Left/RightStrafeTotalTorque`, `Left/RightStrafeTorqueThrusters` (`Ship.cs:50-59`) | written by `RecalculateThrust`, read by the mixer; **no reader outside `Ship.cs`** (grep, including Unity side and tests) | **Die** | +| `Ship.TurnTime` (`Ship.cs:61-66`) | no consumer | **Dies**; replaced by `ShipControl.TurnTime` (Cut 2), consumed in Cut 3 | +| `RemoveThruster`, `RemoveAetherDrive`, their `ItemDestroyed` subscriptions (`Ship.cs:123-143`) | internal | **Die**; liveness is read live (invariant 3) | +| `Agent.Accelerate` (`Agent.cs:58-79`), `FORWARD_DELTA_THRESHOLD`/`THRUST_DELTA_THRESHOLD` (`:16-17`) | called by `MoveTo.cs:27`, `Combat.cs:127` | **Rewritten** in Cut 3 (planner). Signature kept | +| `Agent.TopSpeed` (`Agent.cs:26`) | `MoveTo.cs:25`, `Combat.cs:127`. No live catalog item carries `VelocityLimit` or `VelocityConversion` (probe), so it is 100 everywhere | Survives | +| `GameplaySettings.TorqueFloor` (`Settings.cs:205`) | `Ship.cs:115,118` | **Survives with a new role**: it sizes rotation authority (Cut 2) and no longer classifies which thrusters fire | +| `TorqueMultiplier`, `AetherTorqueMultiplier` | `Thruster.cs:107`, `AetherDrive.cs:155` | Survive; also enter the yaw-rate row of each column | +| `Entity.Direction` writers | `Thruster.cs:106`, `AetherDrive.cs:154`, `Ship.cs:79` (wormhole), `Ship.cs:283` (snap), `ActionGameManager.cs:848` (spawn), `EntitySerializer.cs:66` | `Ship.cs:283` **dies** (invariant 5); the others survive | +| `Entity.Velocity` external writers | `HullCollider.cs:15` (collision impulse), `ShieldManager.cs:79` (bounce), `VelocityConversion.cs:41`, `VelocityLimit.cs:48` | Not control; these are external forces and constraints. Untouched | +| Undock capability check (`ActionGameManager.cs:913`) | `GetBehavior() == null` | Survives. It could later read the capability model; not this campaign | +| Mining, Survey, HaulingTask, StationTowing (`Agents/Tasks/*.cs`) | data-only task classes; no movement code (grep) | Not consumers. `PatrolOrbitsState` drives `MoveToOrbitState`, which Cut 3 covers | +| Player input path | `Input.Player.Look` → `_entityYawPitch` → `LookDirection` (`:1250-1254`); `Input.Player.Move` → `MovementDirection` (`:1261-1264`). No other action writes motion (grep of `Input.Player.*`: targeting, heat, shield, stance, tractor, ping, UI) | **Already on the shared surface**. The only bypasses are the ones `Ship.cs` itself holds (the snap, direct `Axis` writes), and both die in Cut 2 | + +Out-of-scope note: the working tree has an uncommitted change to `Assets/Scripts/AetheriaInput.cs` (a "Cycle Target +Item" action). That is fire-control work, and it does not touch motion. + +--- + +# Part III: Cut map + +## Status header + +Status: cut map. Ends are owned by Part I above; this section owns the means. + +Nothing has landed. + +Open: Q1 and Q2 block Cut 2's pins. Q3 blocks only fixture naming. Q4 blocks only Cut 4. + +Follow-ups outside this campaign: +- `TurretController` has no `EntityTypeRestriction`, so equipping it on a ship would create a third + `LookDirection` writer racing the player or agent. Restrict it to non-ship hulls when turret work next opens + (`TurretController.cs:14`). +- `Thruster.Execute` applies rotation per thruster inside the tick, which couples later thrusters' force to earlier + thrusters' rotation. It is a small artefact, and it is `Thruster.cs`'s to fix, not the allocator's. +- `AetherDrive.Execute` logs "FUCK FUCK FUCK FUCK" on NaN velocity (`AetherDrive.cs:157-158`), a leftover probe. + +## Cut 1. CultMath: bounded least squares (CultLib) + +- **Repo/branch:** `F:\Projects\CultLib`, a branch from `45c2f40` (Aetheria's pinned `CultLibRevision`, + `Directory.Build.props`). Depends on nothing. +- **First:** run `dotnet test packages/cultmath/tests/CultMath.Tests` green at base and record the count. +- **Deletes first:** none. This is a pure addition, and it is the ruled owner fill (R4). +- **Adds:** one C# file in `packages/cultmath/src/CultMath/` with a static solver for + `min ||A x − b||²` subject to `lo ≤ x ≤ hi`. It serves a dense, small problem (m ≤ ~24 rows, n ≤ ~24 columns). + Rules: + - The method is a primal active set (bounded-variable LS). Each iteration solves the free set by Cholesky on the + normal equations, or by QR if Hands prefers conditioning. It terminates on KKT: at each bound the gradient sign + must be consistent with that bound. + - `float` in, `Span`/`ReadOnlySpan` arguments, caller-supplied workspace. No allocation per call. + - `x` is both warm start and result. The solver clamps it to the box first. + - It returns an iteration count or status, has a bounded iteration cap, is deterministic, and keeps its + last feasible iterate on a cap hit. A rank-deficient free set is handled (the caller adds a small ridge row, + and the solver must not throw on a singular pivot). + - Naming stays boring and HLSL-adjacent per `docs/design.md` Rules, for example a + `CultMath.BoundedLeastSquares` static class. It is C#-only like the intercept helpers (`math.cs`). No Rust or + HLSL mirror, because it is not a batch kernel and no shader needs it. +- **Tests** in `packages/cultmath/tests/CultMath.Tests`: + - `InteriorOptimumEqualsNormalEquations`: a problem whose unconstrained optimum lies inside the box returns it. + Pins the free-set solve. + - `KktHoldsOnRandomBoxedProblems`: randomized problems up to 24×16 are checked against a projected-gradient + reference to 1e-4 relative cost, and each active bound's gradient sign is checked. Pins optimality and the + multiplier test. + - `AllBoundsActive`, `ZeroColumn`, `DuplicateColumns`: degenerate columns return without throwing and are + optimal. Pins rank handling. + - `WarmStartMatchesColdStart`: a solution from a perturbed warm start equals the cold solution. Pins + that the start does not bias the result. + - `AsymmetricBounds`: `lo = −1, hi = 1` on some columns and `[0,1]` on others. Pins bidirectional actuators. + - Fixtures must not be degenerate: non-square, non-identity A, and b not in A's range. +- **Release:** build the Unity package (`packages/cultmath/scripts/build-unity-package.ps1`), bump + `unity/org.gamecult.cultmath/package.json` to 0.2.4, and tag `cultmath-unity-v0.2.4` by CultLib's own release + path. In Aetheria, bump `Packages/manifest.json`'s `org.gamecult.cultmath` tag and `Directory.Build.props` + `CultLibRevision` in one commit. +- **Authority map:** Owner: CultMath. Inputs: A, b, bounds, warm start. Outputs: x and status. Forbidden writers: + no least-squares or linear-solve code in `Aetheria/Assets/Scripts/**` (negative grep in Cut 2). +- **Verification:** `dotnet build packages/cultmath/src/CultMath/CultMath.csproj`; `dotnet test` of CultMath.Tests. + Aetheria's `dotnet test tests/Aetheria.Shared.Tests` still passes 244 after the bump. Unity compiles the new + package, and only the operator can check that. +- **Ledger estimate:** +150..220 lines of source and +150 lines of test in CultLib; Aetheria gets a 2-line bump. + +## Cut 2. `ShipControl`: shared controller and allocator replace the mixer + +- **Repo/branch:** Aetheria, a branch from the Cut 1 bump commit. Depends on Cut 1. +- **First:** before editing, commit the base flight numbers above as test constants, reproduced by the fixture spec + in "Findings". They are computed at `cd846916`, never captured from new code. Also run a LonginusX-with-Traction + pin at base: `AetherDrive.Axis` for each probe input is `(my, mx, sqrt(|sin e|)·sign)`, including e = 30°, which + gives `Axis.z = 0.7071`. +- **Deletes first** (separate commit, build and tests green, no behaviour change): + - `Ship.cs:61-66` `TurnTime` (6 lines). + - `Behaviors.cs:96-99` `IAnalogBehavior` (4 lines) and its mention in the `Thruster` declaration (`Thruster.cs:40`). + - `Entity.cs:2007`, the commented `IAnalogBehavior` line. +- **Deletes (behaviour commit):** + - `Ship.cs:30-39` (bucket and item sets, 10 lines). + - `:50-59` (capacity properties and strafe lists, 10 lines). + - `:96-118` (bucket construction; `Activate` keeps `base.Activate()` and gathers actuators, about 20 lines). + - `:123-124` (subscriptions) and `:127-143` (`RemoveAetherDrive`, `RemoveThruster`, 17 lines). + - `:145-249` (the `ThrustCalculation` region, 105 lines). + - `:255-291` (mixer and snap, 37 lines). + - The public `Axis` setters: `Thruster.cs:45-49` and `AetherDrive.cs:81-85` become getters plus a command method + only `ShipControl` calls. + - Total is about 200 lines out of `Ship.cs`. +- **Keeps:** `Ship.cs:253` gate (active, not in a wormhole); `:294-310` drag, position, gravity and rotation + quaternion; wormhole code; `Thruster.Execute`/`AetherDrive.Execute` physics unchanged. The throttle is still + `_input`/`_axis`, so power requests, audio and particles read what they read today. +- **Adds:** `ShipControl` in `Assets/Scripts/ServerShared/ShipControl.cs`, owned by `Ship` and stepped from + `Ship.Update` where the mixer was. It is one file and one owner. Types and rules: + - **Actuator columns.** A narrow interface implemented by `Thruster` and `AetherDrive`. There are two + implementers and one consumer; this replaces `IAnalogBehavior`. + - Each actuator reports its columns every tick: body-frame (Fx, Fy) in m/s² and yaw rate in rad/s + (clockwise positive) per unit throttle, plus bounds. It then accepts its throttles back. + - **Thruster:** one column, [0,1]. F is the negative item facing (`Rotate` of (0,1) by mount rotation) times + live `Evaluate(Thrust)/Mass`. Yaw is `Torque·Thrust·TorqueMultiplier/Mass`. + - **Zeroing:** the column is zero when `!Item.Active.Value` or `Item.PowerSupply ≤ 1e-4`, which is the same + predicate `Execute` gates on. `Thruster.Thrust` (stale, updated only in `Execute`) is not the source. + - **AetherDrive:** three axes. Translation is split into ± columns, [0,1] each, because the drive's efficiency + depends on sign and speed (`AetherDrive.cs:138`). Torque is one column, [−1,1]. + - **Drive authority is nominal.** Effectiveness is the drive's force at `MaximumRpm` from its own stats (rotor + mass, λ, λ multiplier, coupling efficiency, `AetherTorqueMultiplier`), scaled by the same liveness and by its + condition. It is not the instantaneous `Rpm`. Rotor spin-up is the drive's internal dynamics, not an + allocation decision. This choice is what keeps LonginusX exact: for a lone drive, self-consistent capacity + cancels the scale, so throttle equals input. + - **Heading controller** (below the inputs, shared): + - `look = LookDirection.xz`. A zero look means hold heading: no NaN, no command. + - Per rotation direction, rotation authority `ω_cap(dir)` is the sum of that direction's yaw-rate entries over + columns whose `|Thruster.Torque| > TorqueFloor`. Drive torque columns always count. Under Q2 (A) that is the + whole of rotation authority. **`TorqueFloor` no longer decides which thrusters fire.** It only sizes how + much rotation may be demanded. + - **Direction choice:** pick the direction that minimizes `angle_in_that_direction / ω_cap(dir)`, with + infinity where `ω_cap = 0`. This yields the long way round. Keep the committed direction unless the other + direction is faster by a margin, so symmetric ships do not dither near 180°. + - **Rate demand:** `ω_d = min(profile(e)·ω_cap, e/dt)`, where `profile(e) = sqrt(sin e)` for e ≤ 90° (base's + curve, which preserves the pins) and 1 beyond. The `e/dt` cap lands the heading without overshoot and + replaces the snap. + - It exposes `TurnTime(heading)` and `RotationCapacity(dir)` for Cut 3. + - **Translation demand:** + - `F_d = (mx·cap(±x), my·cap(±y))`, where `cap(d)` is the along-d component of the allocator's own solution to + "a huge demand along d, rotation held at zero". Capacity is self-consistent: one objective, and no second + rule for what full stick means. + - It exposes `TranslationCapacity(bodyDir)` along any body direction, same definition, for Cut 3. + - Probe values: Longinus cap(+y) 172.41, cap(+x) 13.74 (with side effects, so the demanded strafe comes out + about 0.09 m/s²). Djinni cap(+x) 37.04, cap(+y) 55.56, cap(−y) 18.52. + - **Allocation objective:** one bounded-LS call, priorities by weight separation (Johansen & Fossen § + prioritization): + - P1, yaw rate equals `ω_d`. Separation about 1e3. + - P2, translation error `F − F_d` in plain L2 (isotropic), each row normalized by the ship's largest column + magnitude. + - P3, a small ridge on throttle (about 1e-3), for tie-breaking and conditioning. + - Warm start from last tick's throttles. + - Probe: a demand-frame weighting with perpendicular error weighted k = 2 gave no benefit over isotropic + (k = 1). Keep it isotropic. + - **Budget:** 1 allocation solve plus 4 capacity solves per ship per tick. At the scratch timings that is well + under 100 µs per ship. Hands measures with a production build and reports. The fix for cost is solver + efficiency, not a cache, because a cache would be a second truth for liveness (invariant 3). +- **Per-file changes** (against `cd846916`): + - `Ship.cs:251-292`: `Update` calls `ShipControl.Step(dt)` inside the existing `:253` gate. The drag and position + code below stays. + - `Ship.cs:92-119`: `Activate` collects actuators into `ShipControl` (all `Thruster` and `AetherDrive` + behaviours). No classification. + - `Ship.cs:121-125`: the constructor drops both `ItemDestroyed` subscriptions. + - `Thruster.cs:40`: implement the actuator interface, drop `IAnalogBehavior`. `:45-49`: public getter, internal + command. `:61-71`: `Torque` stays as the geometry factor. + - `AetherDrive.cs:81-85`: same pattern. Add the nominal-authority read next to its stats. + - `tests/Aetheria.Shared.Tests/BrownoutTests.cs:111-131` and `ConditionRatioTests.cs:94-95`: equip the consumer + thruster on the hull's centre column, using `TryEquip(item, int2(2, y))` with a free y, so `Torque == 0`. These + tests pin brownout and condition, not flight. Their asserted values (for example `.1f` at + `BrownoutTests.cs:162`) must not change. Fix the fixture, never the assertion. `PowerCurveTests.cs:52-62`'s + comment about the `Axis` being clobbered becomes true for a different reason; update the wording only. +- **Authority map:** + - Owner: `ShipControl` decides every actuator throttle and the feasible turn direction. + - Inputs: `MovementDirection`, `LookDirection.xz`, current `Direction`, `Mass`, `dt`, each actuator's live + column, `TorqueFloor`/`TorqueMultiplier`/`AetherTorqueMultiplier`. + - Outputs: per-actuator throttle (`Thruster._input`, `AetherDrive._axis`); capability queries + (`TurnTime`, `RotationCapacity`, `TranslationCapacity`). + - Derived state: columns, capacities and `ω_d` are per-tick derived, never stored across ticks except the warm + start and the committed turn direction, which are command state and not truth. `Thruster.Axis` and + `AetherDrive.Axis` are display and command readbacks. + - Forbidden writers: `Ship.Update`'s mixer (gone); any assignment to `Thruster.Axis`/`AetherDrive.Axis` outside + `ShipControl`; `Ship.cs:283`'s direct `Direction` write; bucket pruning on `ItemDestroyed`; any Unity-side + write of throttles. + - Shared paths: player writes (`ActionGameManager.cs:1254,1263`) and AI writes (`Agent`, `MoveTo`, `Combat`) both + reach actuators only through `ShipControl.Step`. Wormhole exit leaves the gate closed until the animation + ends, as today. + - Deletion line: the delete commits above land before `ShipControl` gains behaviour. +- **Verification:** + - Builds: `dotnet build Aetheria.Shared`; `dotnet test tests/Aetheria.Shared.Tests -m:1 -p:CultLibRoot=…`. + Unity batchmode compile (operator, or Self when the editor is closed). + - Tests (new file `LocomotionControlTests.cs`, fixtures exactly as in "Findings": off-origin, off-axis nose with + `|fx| > |fy|`, nonzero dt, asymmetric damage): + - `IntactLonginusMatchesBase` and `IntactDjinniMatchesBase`: every row of the base table within tolerance + (2% of that ship's full-scale acceleration or turn rate), **except the divergences Q1 names**, which are + pinned at their probed new values once ruled. Pins the allocator against base. + - `LonginusXDriveAxisEqualsBaseFormula`: a drive-only hull; for every probe input, `AetherDrive.Axis` equals + `(my, mx, sqrt(|sin e|)·sign)` to 1e-3. Pins the live player ship's feel exactly. + - `LonginusMissingCcwReachesHeadingOnDeadSideTheLongWay`: `Th.CCW` destroyed through the real `ItemDamage` + path. Write `LookDirection` directly, as the player would, to 90° left. Heading changes are clockwise every + tick (never CCW), and the ship reaches within 2° in under 1.0 s; the base-rate bound is 270°/394.33°/s = + 0.685 s. Base comparison: error still 90.0° after 30 s. Pins the shared feasible-direction choice. + - `LonginusMissingCcwTurnsRightAsBefore`: looking 90° right reaches within 2° in 0.32 s ± one tick (base + value). Pins that damage on one side leaves the other side alone. + - `LookDirectlyBehindTurns`: intact, look exactly aft. The ship turns, `|Direction|` stays 1 ± 1e-4, and there + is no NaN. Pins the snap's deletion and the >90° profile. + - `ZeroLookHoldsHeading`: default `LookDirection` gives no NaN in `Direction` or any throttle. + - `DestroyedThrusterNeverReceivesThrottle`: for each Djinni thruster in turn, destroy it, then drive 50 + ticks of mixed inputs (forward, strafe, turn both ways, diagonal). Its `Axis` is 0 on every tick. Repeat for + `Enabled = false` and for a zero power grant. Pins invariant 7. + - `RepairedThrusterRejoins`: destroy, restore durability, `UpdatePerformance`. The next tick's strafe uses it. + Pins the live read (invariant 3) against the old permanent pruning. + - `PlayerAndAiInputsGiveIdenticalThrottles`: two identical Djinni hulls with the same damage. One gets inputs + the way `ActionGameManager` writes them (`LookDirection` with nonzero pitch from yaw/pitch Euler, + `MovementDirection` from a stick value). The other gets them through an `Agent` state writing the same yaw, + flat. After `Update`, every actuator throttle is identical. Pins R2. + - `LonginusMissingMainHoldsHeadingUnderThrust`: `Th.L` destroyed, forward input. Heading drift stays under 1° + over 2 s, and forward acceleration is at least 70 m/s² (probe: 78 with 12 lateral slip). Pins torque-coupling + compensation in the shared layer. + - Negative greps, verified against `cd846916` to hit only the lines this cut deletes: + - `\.Axis\s*[-+]?=` in `Assets/Scripts` should match nothing outside `ShipControl.cs`. At base it matches + exactly `Ship.cs:256,264,273,275,276,287,288,291`. `FieldDriver.cs`'s `RectTransform.Axis` does not match; + checked. + - `_forwardThrusters|_clockwiseThrusters|StrafeTorqueThrusters|RecalculateThrust|TurnTime\(` should match only + `ShipControl.cs`'s new `TurnTime`. + - `IAnalogBehavior` should match nothing. + - `lerp\(Direction` in `Ship.cs` should match nothing. + - No least-squares code outside CultMath: `Cholesky|pseudo.?inverse|ActiveSet` in `Assets/Scripts` should + match nothing. + - Stryker: `dotnet stryker --since:` from `tests/Aetheria.Shared.Tests`. Every non-equivalent + survivor in `ShipControl.cs`, `Thruster.cs` and `AetherDrive.cs` is killed or triaged by name. Float boundary + flips are equivalent, per the fire-control Cut 11 ruling. + - Operator: fly LonginusX and confirm it feels unchanged (strafe, turn, look behind now turns). Then fly a + thruster ship, which needs a content hull with `Thruster` hardpoints; until one exists this check is + fixture-only, so say so. +- **Operator questions:** Q1 and Q2, below. +- **Ledger estimate:** −200 lines in `Ship.cs`, −10 elsewhere. `ShipControl.cs` adds about 250–350 lines, tests + about 400. Net source roughly +100. That buys actuator-loss capability, which is the explicit ask, and removes + seven parallel role sets, the snap, and permanent pruning. + +## Cut 3. Heading planner in the AI + +- **Repo/branch:** Aetheria, after Cut 2. +- **First:** pin base AI results as constants. On base, a Djinni with all stern thrusters destroyed never reaches + the target (speed 0 at 120 s). Intact, it reaches it in 4.34 s. Both come from the probe (Agent plus MoveTo, dt + 0.02, target 300 m to port from (37,−12), nose `normalize(0.8,0.6)`). +- **Deletes first:** `Agent.cs:16-17` (the two thresholds) and `Agent.cs:60-78` (the body of `Accelerate`, 19 + lines). `MoveTo.cs:26` (the duplicate `LookDirection` write). The signature `Accelerate(float2, bool noTurn)` + stays, since `Combat.cs:127` and `MoveTo.cs:27` call it. +- **Adds:** in `Agent` (above the inputs, AI only): + - Candidates: 16 evenly spaced headings, plus the Δv direction, plus the current heading. + - Score for each: `TurnTime(θ) + |Δv| / TranslationCapacity(Δv expressed in θ's body frame)`, infinite where + capacity is 0. Both terms come from `ShipControl`'s queries (invariant 8). + - Pick the argmin with hysteresis: replan when a candidate beats the current plan by a margin, and on a cadence + (for example every 0.25 s) or when the actuator set changes. The cadence exists for cost: 18 capacity solves + per replan. + - Write `LookDirection = θ*` unless `noTurn`. Write `MovementDirection` as the per-half-axis fractions that point + the translation demand along Δv, inverting `ShipControl`'s own mapping (a `ShipControl` helper, so the AI never + re-derives it), with a deadband near zero Δv. + - `noTurn` (combat) skips heading choice but still gets capability-correct thrust direction. A Djinni with no + stern facing its target strafes or backs off on its own. +- **Authority map:** + - Owner: `Agent` decides what the AI writes to the two inputs. + - Inputs: target velocity, `ShipControl` capability queries, current velocity and heading. + - Outputs: `LookDirection` (unless `noTurn`) and `MovementDirection`. + - Derived: the plan (heading, time of last replan) is command state only. + - Forbidden writers: `MoveToState` writing `LookDirection`; any AI code writing throttles or `Direction`; any AI + capacity estimate not read from `ShipControl`. + - Shared paths: `MoveToOrbitState` (patrol) and `CombatState` both go through `Accelerate`. +- **Verification:** + - `DjinniWithoutSternReachesTargetByCrabbing`: stern cluster destroyed, target 300 m at a bearing 30° right of + the nose. Driven through `Agent` plus `MoveToState` subclass with a fixed target. The ship arrives within 10 m + in under 8 s, and the planner's chosen heading puts a lateral pair along Δv. Base never arrives. Pins R1(2) and + the stuck-forever cheese. + - `CrabBeatsPointAndBurnWhenMainIsWeak`: Djinni with Stern 1 and Stern 2 destroyed (asymmetric; Stern 3's torque + is balanced by the laterals), target 300 m at 60° left. The planner arrives strictly sooner than a naive + point-and-burn agent run in the same harness through the same Cut 2 allocator. Pins that crab walking wins when + it should, as a relation and not a captured number. + - `IntactShipsStillPointAndBurn`: an intact Djinni picks a heading within 10° of Δv and arrives no later than + base's 4.34 s + 10%. Pins no regression. + - `LonginusWithoutReverseFlipsToBrake`: target velocity opposite the current velocity; the planner turns toward + it (a Longinus has no reverse thrust). + - `PlannerUsesShipControlCapacities`: after destroying a thruster, the planner's turn time for a dead-side + heading equals `ShipControl.TurnTime` for that heading. Pins invariant 8 and the long-way-aware estimate. + - Negative grep: `LookDirection` in `Agents/States/MoveTo.cs` should match nothing. + - Stryker `--since:` over `Agent.cs`, `MoveTo.cs` and `ShipControl.cs` queries. + - Operator: watch a patrol Djinni with a stern thruster shot out crab to its next orbit. +- **Ledger estimate:** −22 lines, +120 lines of source, +250 lines of test. + +## Cut 4. Combat facing and tactics (target shape only) + +This cut gets mapped after fire-control Cut 12 lands, because that cut decides what presenting a side is worth. + +- **Owner:** `CombatState` (`Combat.cs:88-127`), above the inputs. +- **Shape:** choose the orbit side and facing from `ShipControl` capabilities (per-direction rotation authority, + translation capacity along candidate directions) and from weapon arcs. A one-way turner keeps its target drifting + toward its live turn direction. A ship with good turret arcs and poor rotation holds a heading and lets the + turrets work. Weapons with fixed arcs pull the heading onto the target. It uses the Cut 3 planner for thrust. +- **Fork:** Q4 (heading versus aim), which must be ruled before mapping. + +## Operator questions + +Each question gives options and a recommendation. Self paces these one at a time. + +- **Q1: intact-ship divergence from base (blocks Cut 2 pins).** The probe of the stated objective found these + differences on intact ships (new versus base): + - Longinus turning in place also fires a main at 12%, which adds 10.7 m/s² of forward creep (about 6% of full + forward) and cuts lateral slip from 69.0 to 67.3. The L2 objective trades a little slip for a little creep. + - Longinus forward+turn at 30° keeps 164.8 of 172.4 forward (−4%). + - Djinni strafe reaches 37.0 m/s² instead of 34.0 (+9%), by balancing torque with Stern 3 plus Bow. + - Djinni turning bleeds Stern 2 plus Bow at 10% each (zero net force, visible exhaust). + - Turns past 90° now run at full rate instead of slowing to zero at 180°. + + Everything else in the base table matches within 2%, including LonginusX's drive, which matches exactly. + - **A.** Accept these as the allocator's honest optimum and pin base exactly where they coincide. + - **B.** Require exact base behaviour. That means per-thruster role rules in the objective, which is the bucket + machine this campaign deletes. + + **Recommended: A.** The only live ship (LonginusX) is exact under A. The divergences are small, physical, and + arise only on thruster hulls, which the live catalog does not yet carry. +- **Q2: rotation from translation thrusters (blocks Cut 2).** When no thruster above `TorqueFloor` can turn a ship + one way, may the controller turn it that way by differential translation thrust? For a Longinus that is its two + mains: about 78°/s with a forward lurch. + - **A.** No. Rotation authority comes only from thrusters above `TorqueFloor` and drive torque, so a lost attitude + thruster means that direction is gone. + - **B.** Yes, as a slower fallback. + + **Recommended: A**, because it matches the operator's words ("losing an attitude thruster means you can't rotate + that way anymore") and keeps turning from making the ship lurch. Consequence of A: a Longinus with both attitude + thrusters gone cannot turn at all. +- **Q3: Longinus fixture layout (fixture naming only).** Both legacy catalogs author two aft 2×2 thrusters (`Th.L`, + `Th.R`), not the one big thruster the operator remembers. + - **A.** Fixtures follow the data: two mains. + - **B.** Model one centred main. + + **Recommended: A**, with a note that a lost main is itself a test case (`LonginusMissingMainHoldsHeadingUnderThrust`). +- **Q4: heading versus aim (blocks Cut 4 only).** `LookDirection` is both the heading command and the aim, read by + `LockWeapon.cs:94`'s lock cone, guided projectiles, reticle targeting and the tractor beam. Capability-aware + facing wants the AI to hold a heading that is not its aim, and under R2 any split applies to the player too. + - **A.** Add a heading input beside the aim on the shared surface. The player's heading follows the view unless a + player control is added later. + - **B.** Keep one input, and the AI accepts degraded lock cones and missile aim while it manoeuvres. + + **Recommended: A**, decided when Cut 4 is mapped. + +## Subtraction ledger (estimates) + +| Cut | Removed | Added | Dependencies / targets | +|---|---|---|---| +| 1 | 0 | ~200 src + ~150 test (CultLib) | CultMath gains one file; cultmath-unity 0.2.4; Aetheria pin bump | +| 2 | ~210 (`Ship.cs` ~200, `Behaviors.cs` 4, `Entity.cs` 1, setters) | ~300 src + ~400 test | `IAnalogBehavior` removed; no new package, target or authored field | +| 3 | ~22 | ~120 src + ~250 test | none | +| 4 | not mapped | not mapped | not mapped | From 7a515e6f45c3c2e4f2270a0247e3be688eecc06f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 20:27:02 +0200 Subject: [PATCH 003/116] Locomotion map revised: rulings recorded, content restore becomes Cut 1 Q1 (divergence accepted), Q2 (differential main thrust allowed, no thruster exclusion) and Q3 (fixtures follow the data) are ruled. A content-restore cut recovers the legacy thruster hulls, which the live catalog lost, and the rotation design no longer excludes any actuator. Co-Authored-By: Claude Opus 5 --- docs/locomotion-cut.md | 357 ++++++++++++++++++++++++++++++----------- 1 file changed, 265 insertions(+), 92 deletions(-) diff --git a/docs/locomotion-cut.md b/docs/locomotion-cut.md index 47d47d872..b015c6a7b 100644 --- a/docs/locomotion-cut.md +++ b/docs/locomotion-cut.md @@ -2,7 +2,10 @@ Date: 2026-09-22 -Status: target and cut map, from an Imagination pass. None of it has landed. Every anchor is against +Status: target and cut map, from an Imagination pass, revised 2026-09-22 after the operator ruled Q1, Q2 and +Q3 and asked for the legacy thruster hulls back. None of it has landed. **The cuts are renumbered by that +revision**: the content restore is the new Cut 1, and what the first committed version called Cuts 1 to 4 are now +Cuts 2 to 5. Every anchor is against `origin/codex/item-provenance` at `cd846916`; the working tree on `codex/fire-control-10` (`e8fbb6a6`) has an identical tree for every file named below. The first half of this document is the **target** (the ends). The second half is the **cut map** (the means). When the campaign closes, split the target out as @@ -52,6 +55,22 @@ Self, relaying an operator answer, 2026-09-22. This is the layer split, stated p - The long-way test therefore belongs to the shared-controller cut and drives `LookDirection` directly, as the player would. The crab-walk test belongs to the planner cut. +Operator rulings on this map, 2026-09-22, relayed by Self: + +- **Q1 = A, the divergence is accepted.** "The divergence is fine." A small divergence from base behaviour on + intact thruster ships is accepted: the Longinus's forward creep of about 10.7 m/s² while turning in place, and + the Djinn's roughly 9% faster strafe. Base is pinned exactly only where the old mixer and the allocator agree, + and the accepted divergences are named with their measured magnitudes in Cut 3's verification, so Soul can tell + an accepted divergence from a regression. **No per-thruster role rules are reintroduced to match base.** +- **Q2 = B, differential main thrust is allowed.** "That's absolutely viable and can be a better decision than + turning around the long way, I wouldn't introduce a thruster exclusion for this." Any live actuator's torque may + be spent on rotation, mains included. A damaged ship turning toward its dead side lurches forward, and the + planner may prefer that to the long way round when it is faster. +- **Q3, fixtures follow the data.** "Good catch on the Longinus main thrusters, I was misremembering." Two aft 2×2 + mains, as both legacy catalogs author them. +- **Restore the legacy thruster hulls.** Asked whether the lost layouts could come back: "can we restore these + layouts?" They become Cut 1, the campaign's first cut, ahead of the solver. + --- # Part I: Target @@ -87,9 +106,8 @@ creates a real handicap and not an exploit. Today's snap-to-look lerp (`Ship.cs:280-284`) is a free, thrusterless rotation, and it goes. 6. **Kinematic rotation is retained.** Rotation stays kinematic: yaw rate is proportional to live throttle (`Thruster.cs:106-107`, `AetherDrive.cs:154-155`), with no angular momentum. Nothing found here argues for - raising it. It keeps turn time exact (angle divided by rate), which the planner relies on. It also matches the - operator's model ("losing an attitude thruster means you can't rotate that way anymore"): with no momentum, - there is no coast. + raising it. It keeps turn time exact (angle divided by rate), which is what lets the planner compare a long + way round against a slow lurching turn without simulating either. 7. **A dead actuator never receives throttle.** A destroyed, disabled or unpowered actuator gets zero throttle, not a saturated request it cannot honour. 8. **Capability is one model.** Turn time, turn direction, rotation capacity and translation capacity are computed @@ -99,7 +117,7 @@ creates a real handicap and not an exploit. ## Canonical implementations - **Solver:** CultMath (`F:\Projects\CultLib\packages\cultmath`), a box-constrained linear least-squares solver, - added by Cut 1. It is the only numerical authority. Aetheria composes the problem and calls it. + added by Cut 2. It is the only numerical authority. Aetheria composes the problem and calls it. - **Allocation reference:** T. A. Johansen and T. I. Fossen, "Control allocation: A survey," *Automatica* 49(5), 2013. Its weighted-least-squares form with priorities is what `ShipControl` states. The active-set method follows O. Härkegård, "Efficient active set algorithms for solving constrained least squares problems in aircraft control @@ -108,10 +126,12 @@ creates a real handicap and not an exploit. ## Out of scope - **Fire-control Cut 12** (edge-on hit placement in `FireControl.cs`, `Entity.ApplyHit`, `DamageSchematic`) is - being mapped concurrently by another Imagination pass. Cuts 1 to 3 here touch none of those files. The allocator - is a read-only downstream consumer of item destruction (`ItemDamage`, `Entity.cs:488`). **Overlap:** Cut 4 (combat + being mapped concurrently by another Imagination pass. Cuts 2 to 4 here touch none of those files. The allocator + is a read-only downstream consumer of item destruction (`ItemDamage`, `Entity.cs:488`). **Overlap:** Cut 5 (combat facing) will rewrite `Agents/States/Combat.cs:88-127`, and presenting armour edge-on is exactly what Cut 12's hit - placement rewards. Cut 4 must be mapped after Cut 12 lands. + placement rewards. Cut 5 must be mapped after Cut 12 lands. Cut 1 restores catalog content, and the only file it + and Cut 12 could both touch is `GameData/Aetheria.cc`; Cut 1 must not be in flight while another cut writes the + catalog. - **The old AetheriaEve/CultMesh/daemon rebuild** is taxidermy. It is not precedent and not a consumer. - Thruster geometry (the normalized moment-arm `Thruster.Torque`, `Thruster.cs:64-69`, which ignores lever length and uses unweighted shape cells for centre of mass) is the game's model and stays as authored. Arrival braking in @@ -135,10 +155,11 @@ runtime-derived per tick, or per planner cadence, and are never saved. | `MovementDirection`, `LookDirection` (runtime) | fields on `Ship`/`Entity` | overwritten each frame by whoever flies the ship | player (`ActionGameManager`) or AI (`Agent` layer), never both on one ship (`Zone.cs:111` gives agents only to `!IsPlayerShip`) | | Committed turn direction, planner plan (runtime) | fields on `ShipControl` / `Agent` | reset on wormhole, dock, spawn | `ShipControl` / `Agent` | | `ThrusterData`, `AetherDriveData`, `HullData.Hardpoints/Shape` (persisted, catalog) | catalog record key | authored | unchanged; read only | -| `GameplaySettings.TorqueFloor`, `TorqueMultiplier`, `AetherTorqueMultiplier` (authored, `Settings.cs:205-207`; values in `Assets/Resources/Settings.asset`: 0.5, 0.1, 0.1) | field name | authored | unchanged values. `TorqueFloor` changes role; see Cut 2 | +| `GameplaySettings.TorqueFloor`, `TorqueMultiplier`, `AetherTorqueMultiplier` (authored, `Settings.cs:205-207`; values in `Assets/Resources/Settings.asset`: 0.5, 0.1, 0.1) | field name | authored | unchanged values. `TorqueFloor` changes role; see Cut 3 | -This campaign adds no authored field. If Hands finds it needs a tunable, that is an operator question, and any new -field on a persisted CultCache document must be nullable (MessagePack nil rule). +This campaign adds no authored **field**. Cut 1 adds authored **records** (hulls, thruster designs and their +products) to `GameData/Aetheria.cc` using the schema as it stands. If Hands finds it needs a tunable, that is an +operator question, and any new field on a persisted CultCache document must be nullable (MessagePack nil rule). --- @@ -178,7 +199,7 @@ clockwise positive. never used for rotation), attitude thrusters ±0.998. Djinni stern 0 / ±0.161, laterals ±0.727 (aft) and ±0.867 (fore), bow 0. -**Base flight table** (`cd846916`, after warm-up). These are the Cut 2 pins: +**Base flight table** (`cd846916`, after warm-up). These are the Cut 3 pins: | Input | Longinus (r, f, °/s) | Djinni (r, f, °/s) | |---|---|---| @@ -218,11 +239,12 @@ later thrusters push along an already-rotated nose. The allocator cannot see thi "Traction" (rotor mass (30,20,5), λ (0.5,1,1), MaxRpm 40000..80000). Two thruster designs exist ("deep space burnout", "Large Drive"), but no live ship can mount them (`docs/content-batch-one.md` §1 says the same). **AetherDrive is therefore the load-bearing actuator for the player today**, and the allocator must reproduce its - base behaviour exactly. + base behaviour exactly. Cut 1 restores the thruster hulls, which is what makes any play check on a thruster ship + possible at all. - **Existing tests sit on an off-centre thruster.** `BrownoutTests`/`ConditionRatioTests` equip a one-cell thruster by `TryFindSpace` onto a 5×5 hull. It lands at (1,1) with `Torque` −0.707, and `MovementDirection (0,−1)` fires it while it spins the ship. Under an allocator that holds heading, a lone off-centre thruster cannot fire without - rotating, so those fixtures must be re-seated (Cut 2). + rotating, so those fixtures must be re-seated (Cut 3). ## Legacy hull layouts (probe) @@ -241,8 +263,8 @@ live LonginusX's. Rows from y = 0: `.####.` ×4, `######` ×5, `.####.` ×2, `.. | Th.CW | (2,14) | 1×2 | CounterClockwise (pushes starboard, nose right) | | Th.CCW | (3,14) | 1×2 | Clockwise (pushes port, nose left) | -**The data has two aft 2×2 thrusters, not one.** The operator remembers "one big thruster". Fixtures follow the -data (see Q3). +**The data has two aft 2×2 thrusters, not one.** The operator remembered "one big thruster" and ruled on it, +2026-09-22: "Good catch on the Longinus main thrusters, I was misremembering." Fixtures follow the data. **Djinni** (mass 10000, drag 0.2, 14×17). Rows from y = 0: `....######....`, `...########...`, `..##########..`, `.############.` ×2, `##############` ×5, `.############.`, `...########...` ×3, @@ -284,23 +306,23 @@ matrices. A production float implementation without allocations will be faster t | Surface | Readers / writers | Fate | |---|---|---| | `Ship.MovementDirection` (`Ship.cs:27`) | W: `ActionGameManager.cs:1263` (player), `Agent.cs:67,73,77` (AI); tests `BrownoutTests.cs:143,402`, `ConditionRatioTests.cs:184,193,243,258`. R: `Ship.cs:261-291` | **Survives** as the translation input. Its meaning is defined by `ShipControl` (fraction of capacity per half-axis) | -| `Entity.LookDirection` (`Entity.cs:55`) | W: `ActionGameManager.cs:1254` (player), `Agent.cs:66`, `MoveTo.cs:26`, `Combat.cs:126`, `TurretController.cs:72,75` (turret entities; no `EntityTypeRestriction`, `TurretController.cs:14-15`). R (as heading): `Ship.cs:278`. R (as aim): `LockWeapon.cs:94`, `GuidedProjectileManager.cs:56`, `EntityInstance.cs:404`, `ShipInstance.cs:117` (tractor), `ZoneRenderer.cs:432`, `ActionGameManager.cs:376` (reticle target pick) | **Survives** as heading command *and* aim. `MoveTo.cs:26` dies in Cut 3 (a double writer: `Accelerate` overwrites it when Δv > 20). The heading/aim conflation is Cut 4's question (Q4) | +| `Entity.LookDirection` (`Entity.cs:55`) | W: `ActionGameManager.cs:1254` (player), `Agent.cs:66`, `MoveTo.cs:26`, `Combat.cs:126`, `TurretController.cs:72,75` (turret entities; no `EntityTypeRestriction`, `TurretController.cs:14-15`). R (as heading): `Ship.cs:278`. R (as aim): `LockWeapon.cs:94`, `GuidedProjectileManager.cs:56`, `EntityInstance.cs:404`, `ShipInstance.cs:117` (tractor), `ZoneRenderer.cs:432`, `ActionGameManager.cs:376` (reticle target pick) | **Survives** as heading command *and* aim. `MoveTo.cs:26` dies in Cut 4 (a double writer: `Accelerate` overwrites it when Δv > 20). The heading/aim conflation is Cut 5's question (Q4) | | `Thruster.Axis` (`Thruster.cs:45-49`) | W: `Ship.cs:256-288` only. R: `ShipInstance.cs:139` (particles), `Thruster.cs:88` (power request), `:96` (audio), `:102-111` | Getter **survives**. The public setter **dies**; only `ShipControl` commands throttle | | `AetherDrive.Axis` (`AetherDrive.cs:81-85`) | W: `Ship.cs:291` only. R: drive internals, audio `:170` | Same as `Thruster.Axis` | -| `IAnalogBehavior` (`Behaviors.cs:96-99`) | implemented by `Thruster.cs:40`; consumer only a comment, `Entity.cs:2007` | **Dies** (Cut 2 deletes first) | +| `IAnalogBehavior` (`Behaviors.cs:96-99`) | implemented by `Thruster.cs:40`; consumer only a comment, `Entity.cs:2007` | **Dies** (Cut 3 deletes first) | | Bucket sets `_forward/_reverse/_right/_left/_clockwise/_counterClockwiseThrusters`, `_thrusterItems`, `_aetherDrives`, `_aetherDriveItems` (`Ship.cs:30-39`) | `Ship.cs` only | **Die** | | `ForwardThrust`, `ReverseThrust`, `LeftStrafeThrust`, `RightStrafeThrust`, `ClockwiseTorque`, `CounterClockwiseTorque`, `Left/RightStrafeTotalTorque`, `Left/RightStrafeTorqueThrusters` (`Ship.cs:50-59`) | written by `RecalculateThrust`, read by the mixer; **no reader outside `Ship.cs`** (grep, including Unity side and tests) | **Die** | -| `Ship.TurnTime` (`Ship.cs:61-66`) | no consumer | **Dies**; replaced by `ShipControl.TurnTime` (Cut 2), consumed in Cut 3 | +| `Ship.TurnTime` (`Ship.cs:61-66`) | no consumer | **Dies**; replaced by `ShipControl.TurnTime` (Cut 3), consumed in Cut 4 | | `RemoveThruster`, `RemoveAetherDrive`, their `ItemDestroyed` subscriptions (`Ship.cs:123-143`) | internal | **Die**; liveness is read live (invariant 3) | -| `Agent.Accelerate` (`Agent.cs:58-79`), `FORWARD_DELTA_THRESHOLD`/`THRUST_DELTA_THRESHOLD` (`:16-17`) | called by `MoveTo.cs:27`, `Combat.cs:127` | **Rewritten** in Cut 3 (planner). Signature kept | +| `Agent.Accelerate` (`Agent.cs:58-79`), `FORWARD_DELTA_THRESHOLD`/`THRUST_DELTA_THRESHOLD` (`:16-17`) | called by `MoveTo.cs:27`, `Combat.cs:127` | **Rewritten** in Cut 4 (planner). Signature kept | | `Agent.TopSpeed` (`Agent.cs:26`) | `MoveTo.cs:25`, `Combat.cs:127`. No live catalog item carries `VelocityLimit` or `VelocityConversion` (probe), so it is 100 everywhere | Survives | -| `GameplaySettings.TorqueFloor` (`Settings.cs:205`) | `Ship.cs:115,118` | **Survives with a new role**: it sizes rotation authority (Cut 2) and no longer classifies which thrusters fire | +| `GameplaySettings.TorqueFloor` (`Settings.cs:205`) | `Ship.cs:115,118` | **Survives with a new role**: it sizes the rotation demand (Cut 3, Q5) and no longer classifies which thrusters fire | | `TorqueMultiplier`, `AetherTorqueMultiplier` | `Thruster.cs:107`, `AetherDrive.cs:155` | Survive; also enter the yaw-rate row of each column | | `Entity.Direction` writers | `Thruster.cs:106`, `AetherDrive.cs:154`, `Ship.cs:79` (wormhole), `Ship.cs:283` (snap), `ActionGameManager.cs:848` (spawn), `EntitySerializer.cs:66` | `Ship.cs:283` **dies** (invariant 5); the others survive | | `Entity.Velocity` external writers | `HullCollider.cs:15` (collision impulse), `ShieldManager.cs:79` (bounce), `VelocityConversion.cs:41`, `VelocityLimit.cs:48` | Not control; these are external forces and constraints. Untouched | | Undock capability check (`ActionGameManager.cs:913`) | `GetBehavior() == null` | Survives. It could later read the capability model; not this campaign | -| Mining, Survey, HaulingTask, StationTowing (`Agents/Tasks/*.cs`) | data-only task classes; no movement code (grep) | Not consumers. `PatrolOrbitsState` drives `MoveToOrbitState`, which Cut 3 covers | -| Player input path | `Input.Player.Look` → `_entityYawPitch` → `LookDirection` (`:1250-1254`); `Input.Player.Move` → `MovementDirection` (`:1261-1264`). No other action writes motion (grep of `Input.Player.*`: targeting, heat, shield, stance, tractor, ping, UI) | **Already on the shared surface**. The only bypasses are the ones `Ship.cs` itself holds (the snap, direct `Axis` writes), and both die in Cut 2 | +| Mining, Survey, HaulingTask, StationTowing (`Agents/Tasks/*.cs`) | data-only task classes; no movement code (grep) | Not consumers. `PatrolOrbitsState` drives `MoveToOrbitState`, which Cut 4 covers | +| Player input path | `Input.Player.Look` → `_entityYawPitch` → `LookDirection` (`:1250-1254`); `Input.Player.Move` → `MovementDirection` (`:1261-1264`). No other action writes motion (grep of `Input.Player.*`: targeting, heat, shield, stance, tractor, ping, UI) | **Already on the shared surface**. The only bypasses are the ones `Ship.cs` itself holds (the snap, direct `Axis` writes), and both die in Cut 3 | Out-of-scope note: the working tree has an uncommitted change to `Assets/Scripts/AetheriaInput.cs` (a "Cycle Target Item" action). That is fire-control work, and it does not touch motion. @@ -315,7 +337,13 @@ Status: cut map. Ends are owned by Part I above; this section owns the means. Nothing has landed. -Open: Q1 and Q2 block Cut 2's pins. Q3 blocks only fixture naming. Q4 blocks only Cut 4. +Rulings (operator, 2026-09-22): **Q1 A** ("The divergence is fine"), **Q2 B** ("that's absolutely viable and can +be a better decision than turning around the long way, I wouldn't introduce a thruster exclusion for this"), **Q3** +fixtures follow the data ("Good catch on the Longinus main thrusters, I was misremembering"), and Cut 1 exists +because of "can we restore these layouts?". The Q2 text below is marked as history; the live design is in Cut 3. + +Open: **Q4** (heading versus aim) blocks only Cut 5. **Q5** (how the rotation demand is sized under Q2 = B) blocks +Cut 3. Cut 1's recoverable-content list is an operator review item before Hands writes any record. Follow-ups outside this campaign: - `TurretController` has no `EntityTypeRestriction`, so equipping it on a ship would create a third @@ -325,7 +353,113 @@ Follow-ups outside this campaign: thrusters' rotation. It is a small artefact, and it is `Thruster.cs`'s to fix, not the allocator's. - `AetherDrive.Execute` logs "FUCK FUCK FUCK FUCK" on NaN velocity (`AetherDrive.cs:157-158`), a leftover probe. -## Cut 1. CultMath: bounded least squares (CultLib) +## Cut 1. Restore the legacy thruster ship hulls to the live catalog + +A pure content cut, separable from every code cut, so Soul can falsify it on its own. Operator, 2026-09-22: "can +we restore these layouts?" + +- **Repo/branch:** Aetheria, a branch from `codex/locomotion`. Depends on nothing. It blocks no code, but nothing + in this campaign can be flown on a thruster ship until it lands, and Cuts 3 and 4 get their real fixtures from + it. It writes `GameData/Aetheria.cc`, so it must not be in flight while another cut writes the catalog. +- **Standing rule** (memory: `aetheria-content-audits-pre-breach`): `GameData/Aetheria.cc` is the post-breach + bare-minimum rebuild, so absence there is not evidence of absence, and **the operator's word is the authority** + on what the pre-breach content was. The list below is therefore an **operator review item before Hands writes + anything into the catalog**. + +### What the legacy record actually holds (probe) + +Sources, all decoded in the scratchpad: the git-LFS objects behind `GameData/Legacy/AetherDB.2021-04-14.msgpack` +(`21ce7bd2…`, 76 KB) and `AetherDB.2021-03-05.msgpack` (`142998fd…`, 59 KB), and the last +`GameData/AetherDB.msgpack` before the Cut 9 import (`8963a686…`, 46 KB, the commit's own parent at `70fbaca1^`). +All three are present in `.git/lfs/objects`; nothing had to be fetched. + +The comparison explains the loss. The 2021-04-14 record carries 5 ship-class hulls and 45 gear designs. The import +source carries 3 hulls (LonginusX, Zenith, Turret) and 25 gear. **Longinus, Djinni and every thruster below the +2×2 class were already gone from the file Cut 9 imported**, which is why the live catalog has no thruster ship. + +**Recoverable in full from 2021-04-14** (name, shape, mass, price, hardpoint list with position, shape, rotation +and armour, behaviours, temperature band, manufacturer, prefab and schematic paths): + +| Record | Kind | Legacy detail | Notes | +|---|---|---|---| +| **Longinus** | ship hull | 6×17, mass 2500, drag 0.1, price 7,500,000, maker Alakrita | The shape is byte-identical to the live LonginusX's. 4 thruster hardpoints (`Th.L`/`Th.R` 2×2 aft, `Th.CW`/`Th.CCW` 1×2 in the nose) plus control module, 2 energy, 2 launcher, 2 radiator, reactor, sensors | +| **Djinni** | ship hull | 14×17, mass 10000, drag 0.2, price 10,000,000, maker Rossum & Douglas | 8 thruster hardpoints (3 stern, 4 lateral, 1 bow) plus 4 launcher, 2 ballistic, reactor, control module, 4 radiator, sensors, and a shield hardpoint | +| **Medium Drive** | thruster gear | 2×1, mass 100, thrust 100000..300000, price 50,000, no maker | **The one item both restored hulls need.** Fits the 2×1 stern/bow hardpoints and, rotated, the 1×2 lateral and nose ones | +| **Small Drive** | thruster gear | 1×1, mass 50, thrust 75000, price 25,000, no maker | Optional; the cheap end of the line | +| **Victoire** | thruster gear | 2×1, mass 25, thrust 200000..1500000, maker Alakrita | Optional; the racing thruster, and a natural Longinus fit | +| **Talaria** | thruster gear | 2×1, mass 150, thrust 75000..250000, maker Aeronautics Unlimited | Optional | +| **RevvITup 2.0** | thruster gear | 2×2, mass 150, thrust 250000..1000000, maker NiteLife Energy | Optional; a second 2×2 beside the two the catalog already ships | + +Everything those records depend on is **already in the live catalog**: all 12 factions (Alakrita and Rossum & +Douglas included), both existing 2×2 thrusters, and the Unity presentation. The prefabs still carry the hardpoint +transforms by their legacy names — `Th.L`, `Th.R`, `Th.CW`, `Th.CCW` in `Assets/Content/Prefabs/Ships/ +Longinus.prefab` (GUID `4a3db609…`, already the hull the live LonginusX points at) and `Thruster Stern 1..3`, +`Thruster Port/Starboard Fore/Aft` and `Thruster Bow` in `Djinni.prefab` (GUID `79024f63…`). The schematics exist +too (`schema_Longinus.png` `51702555…`, already referenced; `djinni_schematic_transparent.png` `66f3acdd…`, unused +today). So no art, no prefab work and no faction work is needed. + +**Not recoverable, because it post-dates the breach and has to be authored:** + +- `HardpointData.FiringArc` (Key 6) for the Djinni's weapon hardpoints. The legacy hardpoint record stops at + armour. 0 means "use `GameplaySettings.FiringArc`"; the `firing-arc-migrate` command in `tools/AetherDb` is the + precedent for deriving one from mount rotation. +- `ItemRole` lists on the restored designs, and `ProductRole` qualities on their products. + `RoleAuthoringTests` refuses a stat naming a role its design lacks, and `docs/content-batch-one.md` fixes the + nomenclature (`injector`/`nozzle` for thrusters, `plating` for hulls). The live "deep space burnout" carries + `injector, nozzle`; "Large Drive" carries none. +- A `FactionProductData` per restored design. `LoadoutGenerator` picks products, not designs, so a design without + one exists but is never generated or sold. The catalog holds 47 products today. +- Any `PowerSupply` term on the restored thrusters' stats (the stats-and-power cut's brownout curve). The shipped + thrusters request zero energy, so matching them is the safe default and is a content decision. +- Provenance is runtime, not authored: `ItemManager` mints the `Lot` (`docs/item-provenance-target.md`). + +**One open identification.** Both legacy hulls carry a second hull behaviour that LonginusX does not (behaviour +tag 11 in the legacy union, a single `PerformanceStat`; Longinus `50..50`, Djinni the same). LonginusX carries only +the one LonginusX still has today, `ReflectorData`. Hands must identify tag 11 against the current `BehaviorData` +union before authoring. **If it is `VelocityConversion`, it matters to this whole campaign**: that behaviour damps +velocity toward the nose (`VelocityConversion.cs:41`), which is a direct tax on crab walking, and no item in the +live catalog carries it. Put the identification in front of the operator with the rest of the list. + +### The cut + +- **Deletes first:** nothing. This is content restoration. +- **Adds:** the two hull records, `Medium Drive`, and whichever optional thrusters the operator's review keeps; + a `FactionProductData` for each; roles for each; firing arcs on the Djinni's weapon hardpoints. +- **Mechanism:** a one-shot command in `tools/AetherDb` (`restore-hulls [apply]`), in the shape of the existing + `*-migrate [apply]` commands: dry run by default, opens the catalog through `AetheriaStores.Open(catalogWritable: + true)` only when applying, writes every record in one `Commit`, and refuses to run twice. The Cut 9 importer + (`tools/AetherDb/Import.cs`, added in `70fbaca1` and deleted by Cut 10) is the precedent for reading the legacy + `[tag, payload]` records and rewriting them by the target type's `[Key]` shape; read it out of git history rather + than re-deriving the mapping. **Delete the command in the same campaign, as Cut 10 deleted the importer**, so the + carrying cost of the restore is zero once it has run. +- **Authority map:** + - Owner: the catalog (`GameData/Aetheria.cc`) owns authored content; the operator owns what the content *is*. + - Inputs: the legacy LFS records, the existing factions and assets, the operator's review of the list above. + - Outputs: new `HullData`, `GearData` and `FactionProductData` records. + - Derived state: none. Runtime `Lot`s and provenance are minted as for any other design. + - Forbidden writers: nothing else may write the catalog while this cut is in flight; the one-shot command must + refuse a second apply rather than upsert twice. + - Deletion line: the command goes once the records are in. +- **Verification:** + - `FireControlCut7Tests.ShippedCatalogOpensAndGeneratesAnArmedHull` still passes unchanged: the real catalog + opens, every `EquippableItemData` deserializes, and LonginusX still generates an armed loadout. + - New `RestoredHullsTests` against the real catalog, read-only: each restored hull exists, has the expected + thruster hardpoint count (Longinus 4, Djinni 8), every thruster hardpoint accepts a catalog thruster design + through `TryEquip` at that hardpoint's own position, and the ship then moves and turns under + `MovementDirection`/`LookDirection` for a few ticks. Before Cut 3 this runs on the old mixer, which is the + point: it proves the content, not the controller. + - `LoadoutGenerator` generates each restored hull when filtered to it, which is the check that the products and + roles are authored correctly. + - `dotnet run --project tools/AetherDb -- census` names the two hulls with their makers, and `dangling` reports + no new dangling reference. + - Operator: the review of the list above before anything is written, then the Studio click-through, then a play + check flying a restored hull. That play check is what unblocks the rest of this campaign. +- **Operator questions:** the recoverable list itself, and the behaviour-tag-11 identification, both above. +- **Ledger estimate:** no source removed. About 3 to 7 catalog records added, plus their products, plus a one-shot + command of roughly 150 lines that is deleted again in this campaign. Net carrying cost: the content, and nothing + else. + +## Cut 2. CultMath: bounded least squares (CultLib) - **Repo/branch:** `F:\Projects\CultLib`, a branch from `45c2f40` (Aetheria's pinned `CultLibRevision`, `Directory.Build.props`). Depends on nothing. @@ -362,15 +496,16 @@ Follow-ups outside this campaign: path. In Aetheria, bump `Packages/manifest.json`'s `org.gamecult.cultmath` tag and `Directory.Build.props` `CultLibRevision` in one commit. - **Authority map:** Owner: CultMath. Inputs: A, b, bounds, warm start. Outputs: x and status. Forbidden writers: - no least-squares or linear-solve code in `Aetheria/Assets/Scripts/**` (negative grep in Cut 2). + no least-squares or linear-solve code in `Aetheria/Assets/Scripts/**` (negative grep in Cut 3). - **Verification:** `dotnet build packages/cultmath/src/CultMath/CultMath.csproj`; `dotnet test` of CultMath.Tests. Aetheria's `dotnet test tests/Aetheria.Shared.Tests` still passes 244 after the bump. Unity compiles the new package, and only the operator can check that. - **Ledger estimate:** +150..220 lines of source and +150 lines of test in CultLib; Aetheria gets a 2-line bump. -## Cut 2. `ShipControl`: shared controller and allocator replace the mixer +## Cut 3. `ShipControl`: shared controller and allocator replace the mixer -- **Repo/branch:** Aetheria, a branch from the Cut 1 bump commit. Depends on Cut 1. +- **Repo/branch:** Aetheria, a branch from the Cut 2 bump commit. Depends on Cut 2. Cut 1 is not a + prerequisite for the code, but its restored hulls are what the operator's play check needs. - **First:** before editing, commit the base flight numbers above as test constants, reproduced by the fixture spec in "Findings". They are computed at `cd846916`, never captured from new code. Also run a LonginusX-with-Traction pin at base: `AetherDrive.Axis` for each probe input is `(my, mx, sqrt(|sin e|)·sign)`, including e = 30°, which @@ -411,22 +546,36 @@ Follow-ups outside this campaign: cancels the scale, so throttle equals input. - **Heading controller** (below the inputs, shared): - `look = LookDirection.xz`. A zero look means hold heading: no NaN, no command. - - Per rotation direction, rotation authority `ω_cap(dir)` is the sum of that direction's yaw-rate entries over - columns whose `|Thruster.Torque| > TorqueFloor`. Drive torque columns always count. Under Q2 (A) that is the - whole of rotation authority. **`TorqueFloor` no longer decides which thrusters fire.** It only sizes how - much rotation may be demanded. + - **No actuator is excluded from rotation** (Q2 = B). The allocator may spend any live column's torque to meet + the rotation demand, mains included, and a ship with no attitude thruster left on one side turns that way by + differential main thrust, lurching forward while it does. + - What `TorqueFloor` still does is **size the demand**, not gate an actuator. Per rotation direction, + `ω_cap(dir)` is the sum of that direction's yaw-rate entries over columns whose + `|Thruster.Torque| > TorqueFloor` (drive torque columns always count); **if that sum is zero, `ω_cap(dir)` + is the sum over every column instead.** So a ship with attitude authority does not burn its mains to turn + a few percent faster, and a ship without it still turns. This piecewise sizing is Q5; it is the only part of + the Q2 = B design that is a choice rather than a consequence. + - Probe, at the fixture values: a Longinus with `Th.CCW` destroyed has zero counter-clockwise attitude + authority, so the fall-back sizes the demand from its mains, and the allocator turns it counter-clockwise at + 78.5°/s with `Th.R` at full throttle and 86.20 m/s² of forward lurch. Clockwise is untouched at 394.4°/s. + A hull whose single main sits on the centre line (torque 0) has no counter-clockwise authority at all, even + with the fall-back, so for that ship the long way round is the only way round. - **Direction choice:** pick the direction that minimizes `angle_in_that_direction / ω_cap(dir)`, with - infinity where `ω_cap = 0`. This yields the long way round. Keep the committed direction unless the other - direction is faster by a margin, so symmetric ships do not dither near 180°. + infinity where `ω_cap = 0`. With the fall-back in play this is a real comparison rather than a formality: + the damaged Longinus above takes the long way round for a 90° turn to port (270°/394.4 = 0.685 s against + 90°/78.5 = 1.15 s) and the lurching short way for a 20° turn to port (0.255 s against 0.862 s), which is + exactly the operator's "can be a better decision than turning around the long way". Keep the committed + direction unless the other is faster by a margin, so symmetric ships do not dither near 180°. - **Rate demand:** `ω_d = min(profile(e)·ω_cap, e/dt)`, where `profile(e) = sqrt(sin e)` for e ≤ 90° (base's curve, which preserves the pins) and 1 beyond. The `e/dt` cap lands the heading without overshoot and replaces the snap. - - It exposes `TurnTime(heading)` and `RotationCapacity(dir)` for Cut 3. + - It exposes `TurnTime(heading)` and `RotationCapacity(dir)` for Cut 4. Both account for the fall-back, so + the planner sees the same slow lurching turn the controller would actually fly. - **Translation demand:** - `F_d = (mx·cap(±x), my·cap(±y))`, where `cap(d)` is the along-d component of the allocator's own solution to "a huge demand along d, rotation held at zero". Capacity is self-consistent: one objective, and no second rule for what full stick means. - - It exposes `TranslationCapacity(bodyDir)` along any body direction, same definition, for Cut 3. + - It exposes `TranslationCapacity(bodyDir)` along any body direction, same definition, for Cut 4. - Probe values: Longinus cap(+y) 172.41, cap(+x) 13.74 (with side effects, so the demanded strafe comes out about 0.09 m/s²). Djinni cap(+x) 37.04, cap(+y) 55.56, cap(−y) 18.52. - **Allocation objective:** one bounded-LS call, priorities by weight separation (Johansen & Fossen § @@ -477,14 +626,33 @@ Follow-ups outside this campaign: - Tests (new file `LocomotionControlTests.cs`, fixtures exactly as in "Findings": off-origin, off-axis nose with `|fx| > |fy|`, nonzero dt, asymmetric damage): - `IntactLonginusMatchesBase` and `IntactDjinniMatchesBase`: every row of the base table within tolerance - (2% of that ship's full-scale acceleration or turn rate), **except the divergences Q1 names**, which are - pinned at their probed new values once ruled. Pins the allocator against base. + (2% of that ship's full-scale acceleration or turn rate), except the four accepted divergences below, which + are pinned at their own probed values. Pins the allocator against base. + - **Accepted divergences** (operator, 2026-09-22, "The divergence is fine"). Soul reads these as pinned + behaviour, not as regressions, and anything outside them is a regression: + 1. Longinus turning in place fires a main at 0.12 throttle: forward creep 10.7 m/s² where base had 0, and + lateral slip 67.3 m/s² where base had 69.0. + 2. Longinus thrusting forward while turning 30° keeps 164.8 m/s² of the 172.4 base forward (−4%). + 3. Djinni strafe reaches 37.0 m/s² against base's 34.0 (+9%), by balancing torque with Stern 3 and Bow. + 4. Djinni turning in place runs Stern 2 and Bow at about 0.10 each; net force stays zero, but their exhaust + is visible. + Plus two intended fixes, which are not divergences to tolerate but behaviour this cut is buying: turns past + 90° run at full rate instead of decaying to zero at 180°, and `|Direction|` no longer shrinks when the look + is directly aft. - `LonginusXDriveAxisEqualsBaseFormula`: a drive-only hull; for every probe input, `AetherDrive.Axis` equals `(my, mx, sqrt(|sin e|)·sign)` to 1e-3. Pins the live player ship's feel exactly. - - `LonginusMissingCcwReachesHeadingOnDeadSideTheLongWay`: `Th.CCW` destroyed through the real `ItemDamage` - path. Write `LookDirection` directly, as the player would, to 90° left. Heading changes are clockwise every - tick (never CCW), and the ship reaches within 2° in under 1.0 s; the base-rate bound is 270°/394.33°/s = - 0.685 s. Base comparison: error still 90.0° after 30 s. Pins the shared feasible-direction choice. + - `CentreMainHullTakesTheLongWayRound`: the long-way fixture, built so the long way is the *only* way. One + centred main (torque 0, so no differential authority) and one clockwise attitude thruster, the + counter-clockwise one destroyed through the real `ItemDamage` path. Write `LookDirection` directly, as the + player would, to 90° left. Heading changes clockwise every tick (never counter-clockwise), and the ship + arrives within 2° in under 1.0 s; the base-rate bound is 270°/394.33°/s = 0.685 s. Base comparison: a ship + of that shape never turns at all. Pins the shared feasible-direction choice. + - `LonginusMissingCcwPrefersTheLongWayAtNinetyDegrees`: the two-main Longinus with `Th.CCW` destroyed, looking + 90° left. It turns clockwise, because 0.685 s beats the 1.15 s its mains would take. Pins that the + comparison is by time, not by a rule about which actuators are "for" turning. + - `LonginusMissingCcwLurchesRoundTheShortWayAtTwentyDegrees`: the same ship looking 20° left. It turns + counter-clockwise on differential main thrust (`Th.R` at full), arrives within 2°, and its forward velocity + rises while it does. Pins Q2 = B: the lurching turn is available and is chosen when it is faster. - `LonginusMissingCcwTurnsRightAsBefore`: looking 90° right reaches within 2° in 0.32 s ± one tick (base value). Pins that damage on one side leaves the other side alone. - `LookDirectlyBehindTurns`: intact, look exactly aft. The ship turns, `|Direction|` stays 1 ± 1e-4, and there @@ -512,20 +680,20 @@ Follow-ups outside this campaign: - `lerp\(Direction` in `Ship.cs` should match nothing. - No least-squares code outside CultMath: `Cholesky|pseudo.?inverse|ActiveSet` in `Assets/Scripts` should match nothing. - - Stryker: `dotnet stryker --since:` from `tests/Aetheria.Shared.Tests`. Every non-equivalent + - Stryker: `dotnet stryker --since:` from `tests/Aetheria.Shared.Tests`. Every non-equivalent survivor in `ShipControl.cs`, `Thruster.cs` and `AetherDrive.cs` is killed or triaged by name. Float boundary flips are equivalent, per the fire-control Cut 11 ruling. - Operator: fly LonginusX and confirm it feels unchanged (strafe, turn, look behind now turns). Then fly a - thruster ship, which needs a content hull with `Thruster` hardpoints; until one exists this check is - fixture-only, so say so. -- **Operator questions:** Q1 and Q2, below. + restored Longinus or Djinni from Cut 1, shoot a thruster off it, and confirm the damaged handling and the + lurching turn. Without Cut 1 this half of the check cannot be done at all. +- **Operator questions:** Q5, below. Q1, Q2 and Q3 are ruled. - **Ledger estimate:** −200 lines in `Ship.cs`, −10 elsewhere. `ShipControl.cs` adds about 250–350 lines, tests about 400. Net source roughly +100. That buys actuator-loss capability, which is the explicit ask, and removes seven parallel role sets, the snap, and permanent pruning. -## Cut 3. Heading planner in the AI +## Cut 4. Heading planner in the AI -- **Repo/branch:** Aetheria, after Cut 2. +- **Repo/branch:** Aetheria, after Cut 3. - **First:** pin base AI results as constants. On base, a Djinni with all stern thrusters destroyed never reaches the target (speed 0 at 120 s). Intact, it reaches it in 4.34 s. Both come from the probe (Agent plus MoveTo, dt 0.02, target 300 m to port from (37,−12), nose `normalize(0.8,0.6)`). @@ -536,6 +704,10 @@ Follow-ups outside this campaign: - Candidates: 16 evenly spaced headings, plus the Δv direction, plus the current heading. - Score for each: `TurnTime(θ) + |Δv| / TranslationCapacity(Δv expressed in θ's body frame)`, infinite where capacity is 0. Both terms come from `ShipControl`'s queries (invariant 8). + - **No direction is assumed impossible.** Under Q2 = B a damaged ship usually can still turn both ways, one of + them slowly and with a lurch, so `TurnTime` returns a finite number either way and the scoring compares them. + Nothing in the planner may branch on "this ship is a one-way turner"; the only asymmetry it sees is in the + numbers `ShipControl` hands it. - Pick the argmin with hysteresis: replan when a candidate beats the current plan by a margin, and on a cadence (for example every 0.25 s) or when the actuator set changes. The cadence exists for cost: 18 capacity solves per replan. @@ -559,80 +731,81 @@ Follow-ups outside this campaign: the stuck-forever cheese. - `CrabBeatsPointAndBurnWhenMainIsWeak`: Djinni with Stern 1 and Stern 2 destroyed (asymmetric; Stern 3's torque is balanced by the laterals), target 300 m at 60° left. The planner arrives strictly sooner than a naive - point-and-burn agent run in the same harness through the same Cut 2 allocator. Pins that crab walking wins when + point-and-burn agent run in the same harness through the same Cut 3 allocator. Pins that crab walking wins when it should, as a relation and not a captured number. - `IntactShipsStillPointAndBurn`: an intact Djinni picks a heading within 10° of Δv and arrives no later than base's 4.34 s + 10%. Pins no regression. - `LonginusWithoutReverseFlipsToBrake`: target velocity opposite the current velocity; the planner turns toward it (a Longinus has no reverse thrust). + - `PlannerPrefersTheLurchingTurnWhenItIsFaster`: the Longinus with `Th.CCW` destroyed, given a Δv 20° to port. + The heading it writes is reached counter-clockwise, and the forward lurch shows in the trajectory. Pins that + the planner inherits the controller's Q2 = B comparison instead of a one-way-turner assumption. - `PlannerUsesShipControlCapacities`: after destroying a thruster, the planner's turn time for a dead-side heading equals `ShipControl.TurnTime` for that heading. Pins invariant 8 and the long-way-aware estimate. - Negative grep: `LookDirection` in `Agents/States/MoveTo.cs` should match nothing. - - Stryker `--since:` over `Agent.cs`, `MoveTo.cs` and `ShipControl.cs` queries. + - Stryker `--since:` over `Agent.cs`, `MoveTo.cs` and `ShipControl.cs` queries. - Operator: watch a patrol Djinni with a stern thruster shot out crab to its next orbit. - **Ledger estimate:** −22 lines, +120 lines of source, +250 lines of test. -## Cut 4. Combat facing and tactics (target shape only) +## Cut 5. Combat facing and tactics (target shape only) This cut gets mapped after fire-control Cut 12 lands, because that cut decides what presenting a side is worth. - **Owner:** `CombatState` (`Combat.cs:88-127`), above the inputs. - **Shape:** choose the orbit side and facing from `ShipControl` capabilities (per-direction rotation authority, - translation capacity along candidate directions) and from weapon arcs. A one-way turner keeps its target drifting - toward its live turn direction. A ship with good turret arcs and poor rotation holds a heading and lets the - turrets work. Weapons with fixed arcs pull the heading onto the target. It uses the Cut 3 planner for thrust. + translation capacity along candidate directions) and from weapon arcs. Under Q2 = B there are few true one-way + turners, so the tactic is about asymmetry rather than impossibility: a ship that turns one way fast and the other + way slowly, with a lurch, keeps its target drifting toward its fast side. A ship with good turret arcs and poor rotation holds a heading and lets the + turrets work. Weapons with fixed arcs pull the heading onto the target. It uses the Cut 4 planner for thrust. - **Fork:** Q4 (heading versus aim), which must be ruled before mapping. ## Operator questions -Each question gives options and a recommendation. Self paces these one at a time. +Each question gives options and a recommendation. Self paces these one at a time. Q1, Q2 and Q3 were ruled on +2026-09-22; their text is kept as history, and the live design is in the cuts above. -- **Q1: intact-ship divergence from base (blocks Cut 2 pins).** The probe of the stated objective found these - differences on intact ships (new versus base): - - Longinus turning in place also fires a main at 12%, which adds 10.7 m/s² of forward creep (about 6% of full - forward) and cuts lateral slip from 69.0 to 67.3. The L2 objective trades a little slip for a little creep. - - Longinus forward+turn at 30° keeps 164.8 of 172.4 forward (−4%). - - Djinni strafe reaches 37.0 m/s² instead of 34.0 (+9%), by balancing torque with Stern 3 plus Bow. - - Djinni turning bleeds Stern 2 plus Bow at 10% each (zero net force, visible exhaust). - - Turns past 90° now run at full rate instead of slowing to zero at 180°. +- **Q5: how the rotation demand is sized under Q2 = B (blocks Cut 3).** No actuator is excluded from rotation, so + the only remaining choice is how much rotation the controller asks for. The demand cannot simply be "everything + every actuator could produce": a Longinus turning 90° in place would then run a main at 0.97 throttle and lurch + forward at 84 m/s², about half its full thrust, on every hard turn (probe). + - **A.** Size the demand from attitude authority (columns above `TorqueFloor`, plus drive torque), and fall back + to every column when that is zero for a direction. An intact ship does not burn its mains to turn a few + percent faster; a ship with no attitude thruster left on one side still turns that way, at 78.5°/s with a + lurch on the fixture Longinus. No actuator is ever excluded from *meeting* the demand. + - **B.** Always size from every column. Simpler to state, and it makes every hard turn on a ship with off-centre + mains a forward lurch, intact or not. - Everything else in the base table matches within 2%, including LonginusX's drive, which matches exactly. - - **A.** Accept these as the allocator's honest optimum and pin base exactly where they coincide. - - **B.** Require exact base behaviour. That means per-thruster role rules in the objective, which is the bucket - machine this campaign deletes. - - **Recommended: A.** The only live ship (LonginusX) is exact under A. The divergences are small, physical, and - arise only on thruster hulls, which the live catalog does not yet carry. -- **Q2: rotation from translation thrusters (blocks Cut 2).** When no thruster above `TorqueFloor` can turn a ship - one way, may the controller turn it that way by differential translation thrust? For a Longinus that is its two - mains: about 78°/s with a forward lurch. - - **A.** No. Rotation authority comes only from thrusters above `TorqueFloor` and drive torque, so a lost attitude - thruster means that direction is gone. - - **B.** Yes, as a slower fallback. - - **Recommended: A**, because it matches the operator's words ("losing an attitude thruster means you can't rotate - that way anymore") and keeps turning from making the ship lurch. Consequence of A: a Longinus with both attitude - thrusters gone cannot turn at all. -- **Q3: Longinus fixture layout (fixture naming only).** Both legacy catalogs author two aft 2×2 thrusters (`Th.L`, - `Th.R`), not the one big thruster the operator remembers. - - **A.** Fixtures follow the data: two mains. - - **B.** Model one centred main. - - **Recommended: A**, with a note that a lost main is itself a test case (`LonginusMissingMainHoldsHeadingUnderThrust`). -- **Q4: heading versus aim (blocks Cut 4 only).** `LookDirection` is both the heading command and the aim, read by + **Recommended: A.** It is what makes Q2 = B's lurching turn an option the ship takes when it is worth it, + instead of a permanent tax on turning. The cost of A is that `ω_cap` is piecewise, which is stated in Cut 3 and + pinned by two tests. +- **Q4: heading versus aim (blocks Cut 5 only).** `LookDirection` is both the heading command and the aim, read by `LockWeapon.cs:94`'s lock cone, guided projectiles, reticle targeting and the tractor beam. Capability-aware facing wants the AI to hold a heading that is not its aim, and under R2 any split applies to the player too. - **A.** Add a heading input beside the aim on the shared surface. The player's heading follows the view unless a player control is added later. - **B.** Keep one input, and the AI accepts degraded lock cones and missile aim while it manoeuvres. - **Recommended: A**, decided when Cut 4 is mapped. + **Recommended: A**, decided when Cut 5 is mapped. + +### Ruled (history) + +- **Q1: intact-ship divergence from base.** **Ruled A, 2026-09-22: "The divergence is fine."** The four accepted + divergences and their magnitudes are listed in Cut 3's verification. The rejected option was to require exact + base behaviour, which would have meant reintroducing per-thruster role rules into the objective. +- **Q2: rotation from translation thrusters.** **Ruled B, 2026-09-22: "that's absolutely viable and can be a + better decision than turning around the long way, I wouldn't introduce a thruster exclusion for this."** The + option this map recommended (A: rotation authority only from thrusters above `TorqueFloor`, so a lost attitude + thruster means that direction is gone) was rejected. Cut 3 and Cut 4 are written to B, and Q5 is what is left of + the question. +- **Q3: Longinus fixture layout.** **Ruled, 2026-09-22: "Good catch on the Longinus main thrusters, I was + misremembering."** Fixtures follow the data: two aft 2×2 mains. ## Subtraction ledger (estimates) | Cut | Removed | Added | Dependencies / targets | |---|---|---|---| -| 1 | 0 | ~200 src + ~150 test (CultLib) | CultMath gains one file; cultmath-unity 0.2.4; Aetheria pin bump | -| 2 | ~210 (`Ship.cs` ~200, `Behaviors.cs` 4, `Entity.cs` 1, setters) | ~300 src + ~400 test | `IAnalogBehavior` removed; no new package, target or authored field | -| 3 | ~22 | ~120 src + ~250 test | none | -| 4 | not mapped | not mapped | not mapped | +| 1 | 0 source; the one-shot command is deleted again in this campaign | 3–7 catalog records + products; ~150 lines of throwaway tool + ~120 test | No new package, target, asset or authored field. Existing prefabs, schematics and factions are reused | +| 2 | 0 | ~200 src + ~150 test (CultLib) | CultMath gains one file; cultmath-unity 0.2.4; Aetheria pin bump | +| 3 | ~210 (`Ship.cs` ~200, `Behaviors.cs` 4, `Entity.cs` 1, setters) | ~300 src + ~450 test | `IAnalogBehavior` removed; no new package, target or authored field | +| 4 | ~22 | ~130 src + ~280 test | none | +| 5 | not mapped | not mapped | not mapped | From 6f4c11fc014e4da2387e6f0c5dc7608249c81f89 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 20:50:14 +0200 Subject: [PATCH 004/116] Locomotion map: one full-wrench solve, a predicting planner, and TorqueFloor dies Q5 is dissolved rather than answered: the controller asks for the force and the torque it wants together and the solver minimises both, so the lurch is spent only when the translation was wanted anyway. The planner rolls candidates forward and scores where the ship ends up, which is what makes strafing beat the big circle. The legacy hulls' second behaviour is VelocityLimit, not VelocityConversion, as the operator said. Co-Authored-By: Claude Opus 5 --- docs/locomotion-cut.md | 407 +++++++++++++++++++++++++++-------------- 1 file changed, 266 insertions(+), 141 deletions(-) diff --git a/docs/locomotion-cut.md b/docs/locomotion-cut.md index b015c6a7b..c49e8439d 100644 --- a/docs/locomotion-cut.md +++ b/docs/locomotion-cut.md @@ -2,8 +2,10 @@ Date: 2026-09-22 -Status: target and cut map, from an Imagination pass, revised 2026-09-22 after the operator ruled Q1, Q2 and -Q3 and asked for the legacy thruster hulls back. None of it has landed. **The cuts are renumbered by that +Status: target and cut map, from an Imagination pass, revised twice on 2026-09-22: first after the operator +ruled Q1, Q2 and Q3 and asked for the legacy thruster hulls back, then after the operator approved the content +inventory, corrected the hull-behaviour guess and dissolved Q5 by replacing the controller's rotation demand with +a single full-wrench solve. None of it has landed. **The cuts are renumbered by that revision**: the content restore is the new Cut 1, and what the first committed version called Cuts 1 to 4 are now Cuts 2 to 5. Every anchor is against `origin/codex/item-provenance` at `cd846916`; the working tree on `codex/fire-control-10` (`e8fbb6a6`) has an @@ -71,6 +73,25 @@ Operator rulings on this map, 2026-09-22, relayed by Self: - **Restore the legacy thruster hulls.** Asked whether the lost layouts could come back: "can we restore these layouts?" They become Cut 1, the campaign's first cut, ahead of the solver. +Second round, same day: + +- **Cut 1 is approved as inventoried, optional gear included.** "Yes and yes": the Longinus and Djinni hulls, + Medium Drive, and Small Drive, Victoire, Talaria and RevvITup 2.0 as well. +- **The second hull behaviour is not `VelocityConversion`.** "I don't think VelocityConversion was on either + hull." The decode agrees: it is `VelocityLimitData`. See Cut 1. +- **Q5 is dissolved, not answered: the rotation demand is not sized at all.** "It should only do that if the + ship's controller also *wants* to accelerate forwards that much, but I'm not sure how to encode that while + allowing the lurch. Like, lurch burn to compensate for a missing attitude thruster is also only a good move when + you both need to turn a bit and don't mind being all the way over there after. It doesn't help when, say, + there's a guy to port and your aft thruster is out, because you'd just end up turning in a great big circle + without changing your bearing at all." On the shape that replaced it: "Yeah, that's better." + - **Cut 3** now runs one weighted least-squares solve over the whole desired wrench (Fx, Fy, yaw) at once, + under throttle bounds. No separate rotation demand, no `TorqueFloor`-sized demand, no piecewise fall-back, and + no branch anywhere on whether a ship can turn a given way. The weights are the design knob, and both error + terms share a currency because each axis is normalized by what that ship can actually achieve on it. + - **Cut 4** chooses manoeuvres by predicted outcome over a short horizon, simulating each candidate, never by a + rule. The operator's case above is its test. + --- # Part I: Target @@ -110,9 +131,10 @@ creates a real handicap and not an exploit. way round against a slow lurching turn without simulating either. 7. **A dead actuator never receives throttle.** A destroyed, disabled or unpowered actuator gets zero throttle, not a saturated request it cannot honour. -8. **Capability is one model.** Turn time, turn direction, rotation capacity and translation capacity are computed - in one place (`ShipControl`), from the same effectiveness matrix the allocator solves with. The AI queries them - and never recomputes a private estimate. +8. **Capability is one model.** What a ship can do is computed in one place (`ShipControl`), from the same live + column matrix the allocator solves with: the achievable extremes, the turn-direction comparison, and the solve + itself. The AI reads those and predicts with the controller's own step; it never keeps a private estimate of + what the ship can do, and never branches on which actuators are missing. ## Canonical implementations @@ -155,7 +177,7 @@ runtime-derived per tick, or per planner cadence, and are never saved. | `MovementDirection`, `LookDirection` (runtime) | fields on `Ship`/`Entity` | overwritten each frame by whoever flies the ship | player (`ActionGameManager`) or AI (`Agent` layer), never both on one ship (`Zone.cs:111` gives agents only to `!IsPlayerShip`) | | Committed turn direction, planner plan (runtime) | fields on `ShipControl` / `Agent` | reset on wormhole, dock, spawn | `ShipControl` / `Agent` | | `ThrusterData`, `AetherDriveData`, `HullData.Hardpoints/Shape` (persisted, catalog) | catalog record key | authored | unchanged; read only | -| `GameplaySettings.TorqueFloor`, `TorqueMultiplier`, `AetherTorqueMultiplier` (authored, `Settings.cs:205-207`; values in `Assets/Resources/Settings.asset`: 0.5, 0.1, 0.1) | field name | authored | unchanged values. `TorqueFloor` changes role; see Cut 3 | +| `GameplaySettings.TorqueMultiplier`, `AetherTorqueMultiplier` (authored, `Settings.cs:206-207`; values in `Assets/Resources/Settings.asset`: 0.1, 0.1) | field name | authored | unchanged. `TorqueFloor` (`:205`, authored 0.5) loses its last consumer in Cut 3 and is deleted with its authored value | This campaign adds no authored **field**. Cut 1 adds authored **records** (hulls, thruster designs and their products) to `GameData/Aetheria.cc` using the schema as it stands. If Hands finds it needs a tunable, that is an @@ -316,7 +338,7 @@ matrices. A production float implementation without allocations will be faster t | `RemoveThruster`, `RemoveAetherDrive`, their `ItemDestroyed` subscriptions (`Ship.cs:123-143`) | internal | **Die**; liveness is read live (invariant 3) | | `Agent.Accelerate` (`Agent.cs:58-79`), `FORWARD_DELTA_THRESHOLD`/`THRUST_DELTA_THRESHOLD` (`:16-17`) | called by `MoveTo.cs:27`, `Combat.cs:127` | **Rewritten** in Cut 4 (planner). Signature kept | | `Agent.TopSpeed` (`Agent.cs:26`) | `MoveTo.cs:25`, `Combat.cs:127`. No live catalog item carries `VelocityLimit` or `VelocityConversion` (probe), so it is 100 everywhere | Survives | -| `GameplaySettings.TorqueFloor` (`Settings.cs:205`) | `Ship.cs:115,118` | **Survives with a new role**: it sizes the rotation demand (Cut 3, Q5) and no longer classifies which thrusters fire | +| `GameplaySettings.TorqueFloor` (`Settings.cs:205`) | `Ship.cs:115,118` only | **Dies** in Cut 3. The full-wrench solve has no threshold to apply it to, and nothing else reads it | | `TorqueMultiplier`, `AetherTorqueMultiplier` | `Thruster.cs:107`, `AetherDrive.cs:155` | Survive; also enter the yaw-rate row of each column | | `Entity.Direction` writers | `Thruster.cs:106`, `AetherDrive.cs:154`, `Ship.cs:79` (wormhole), `Ship.cs:283` (snap), `ActionGameManager.cs:848` (spawn), `EntitySerializer.cs:66` | `Ship.cs:283` **dies** (invariant 5); the others survive | | `Entity.Velocity` external writers | `HullCollider.cs:15` (collision impulse), `ShieldManager.cs:79` (bounce), `VelocityConversion.cs:41`, `VelocityLimit.cs:48` | Not control; these are external forces and constraints. Untouched | @@ -342,8 +364,13 @@ be a better decision than turning around the long way, I wouldn't introduce a th fixtures follow the data ("Good catch on the Longinus main thrusters, I was misremembering"), and Cut 1 exists because of "can we restore these layouts?". The Q2 text below is marked as history; the live design is in Cut 3. -Open: **Q4** (heading versus aim) blocks only Cut 5. **Q5** (how the rotation demand is sized under Q2 = B) blocks -Cut 3. Cut 1's recoverable-content list is an operator review item before Hands writes any record. +Second-round rulings (operator, 2026-09-22): Cut 1 approved as inventoried, optional gear included ("Yes and +yes"); the second hull behaviour is not `VelocityConversion` ("I don't think VelocityConversion was on either +hull") and the decode names it `VelocityLimitData`; **Q5 is dissolved** by the full-wrench solve ("Yeah, that's +better"), so the Q5 text below and every "sized demand" design it belonged to are history. + +Open: **Q4** (heading versus aim) blocks only Cut 5. **Q6** (the one weight in Cut 3's solve) does not block +Hands, who starts at the recommended default; the operator settles it by feel in the Cut 1 play check. Follow-ups outside this campaign: - `TurretController` has no `EntityTypeRestriction`, so equipping it on a ship would create a third @@ -356,7 +383,8 @@ Follow-ups outside this campaign: ## Cut 1. Restore the legacy thruster ship hulls to the live catalog A pure content cut, separable from every code cut, so Soul can falsify it on its own. Operator, 2026-09-22: "can -we restore these layouts?" +we restore these layouts?", and on the inventory below, optional gear included: "Yes and yes". **The list is +approved**; what is left for review is anything Hands finds that this map did not name. - **Repo/branch:** Aetheria, a branch from `codex/locomotion`. Depends on nothing. It blocks no code, but nothing in this campaign can be flown on a thruster ship until it lands, and Cuts 3 and 4 get their real fixtures from @@ -413,18 +441,32 @@ today). So no art, no prefab work and no faction work is needed. thrusters request zero energy, so matching them is the safe default and is a content decision. - Provenance is runtime, not authored: `ItemManager` mints the `Lot` (`docs/item-provenance-target.md`). -**One open identification.** Both legacy hulls carry a second hull behaviour that LonginusX does not (behaviour -tag 11 in the legacy union, a single `PerformanceStat`; Longinus `50..50`, Djinni the same). LonginusX carries only -the one LonginusX still has today, `ReflectorData`. Hands must identify tag 11 against the current `BehaviorData` -union before authoring. **If it is `VelocityConversion`, it matters to this whole campaign**: that behaviour damps -velocity toward the nose (`VelocityConversion.cs:41`), which is a direct tax on crab walking, and no item in the -live catalog carries it. Put the identification in front of the operator with the rest of the list. +**The second hull behaviour, identified** (probe; the operator was right that it is not `VelocityConversion`). +Both legacy hulls carry a behaviour LonginusX does not, union tag 11 in the legacy record. The union table is in +`Assets/Scripts/ServerShared/Behaviors/Behaviors.cs` at the legacy-project commit `7006a6b0` and unchanged at +`b4c06b50`: **tag 11 is `VelocityLimitData`**; `VelocityConversionData` is tag 10, and it is on neither hull. The +table cross-checks against the live catalog on every record that survived: tag 6 `ReflectorData` (live LonginusX +has exactly that and nothing else), 8 `ThrusterData` and 9 `WearData` ("deep space burnout" carries both), 3 +`RadiatorData` (Arctica), 7 `ShieldData` (legacy "Shield"), 31 `CapacitorData` (legacy "Capacitor"). + +The payload is a single `PerformanceStat` in `Key(1)`, `VelocityLimitData.TopSpeed`, authored `50..50` with the +legacy exponents `1, 0, 1.5` on both hulls. So **both restored hulls cap at 50 units of speed**, where a hull +without the behaviour leaves `Agent.TopSpeed` at its 100 default (`Agent.cs:26`) and leaves the player uncapped. +`VelocityLimit.Execute` clamps `Entity.Velocity` (`VelocityLimit.cs:48`), so this is a real handling difference on +the restored hulls and Cut 4's arrival times are measured with it. + +**The legacy stat shape differs from today's.** A legacy `PerformanceStat` is +`[Min, Max, HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent]`; today it is `Min`, `Max` and +a `Terms` list of `StatTerm` (`ItemData.cs:632-643`, keys 2 to 4 retired). Every restored stat has to be +translated, not copied. `docs/stats-power-cut1-migration.md` is the precedent for that mapping and the authority +on what each legacy exponent becomes. ### The cut - **Deletes first:** nothing. This is content restoration. -- **Adds:** the two hull records, `Medium Drive`, and whichever optional thrusters the operator's review keeps; - a `FactionProductData` for each; roles for each; firing arcs on the Djinni's weapon hardpoints. +- **Adds:** the two hull records and all five thruster designs (`Medium Drive`, `Small Drive`, `Victoire`, + `Talaria`, `RevvITup 2.0`), approved 2026-09-22; a `FactionProductData` for each; roles for each; firing arcs on + the Djinni's weapon hardpoints; each hull's `VelocityLimit` behaviour with its translated stat. - **Mechanism:** a one-shot command in `tools/AetherDb` (`restore-hulls [apply]`), in the shape of the existing `*-migrate [apply]` commands: dry run by default, opens the catalog through `AetheriaStores.Open(catalogWritable: true)` only when applying, writes every record in one `Commit`, and refuses to run twice. The Cut 9 importer @@ -452,9 +494,9 @@ live catalog carries it. Put the identification in front of the operator with th roles are authored correctly. - `dotnet run --project tools/AetherDb -- census` names the two hulls with their makers, and `dangling` reports no new dangling reference. - - Operator: the review of the list above before anything is written, then the Studio click-through, then a play - check flying a restored hull. That play check is what unblocks the rest of this campaign. -- **Operator questions:** the recoverable list itself, and the behaviour-tag-11 identification, both above. + - Operator: the Studio click-through, then a play check flying a restored hull. That play check is what + unblocks the rest of this campaign, and it is where Q6's weight gets settled by feel. +- **Operator questions:** none open. The list is approved and tag 11 is identified. - **Ledger estimate:** no source removed. About 3 to 7 catalog records added, plus their products, plus a one-shot command of roughly 150 lines that is deleted again in this campaign. Net carrying cost: the content, and nothing else. @@ -544,52 +586,81 @@ live catalog carries it. Put the identification in front of the operator with th condition. It is not the instantaneous `Rpm`. Rotor spin-up is the drive's internal dynamics, not an allocation decision. This choice is what keeps LonginusX exact: for a lone drive, self-consistent capacity cancels the scale, so throttle equals input. - - **Heading controller** (below the inputs, shared): - - `look = LookDirection.xz`. A zero look means hold heading: no NaN, no command. - - **No actuator is excluded from rotation** (Q2 = B). The allocator may spend any live column's torque to meet - the rotation demand, mains included, and a ship with no attitude thruster left on one side turns that way by - differential main thrust, lurching forward while it does. - - What `TorqueFloor` still does is **size the demand**, not gate an actuator. Per rotation direction, - `ω_cap(dir)` is the sum of that direction's yaw-rate entries over columns whose - `|Thruster.Torque| > TorqueFloor` (drive torque columns always count); **if that sum is zero, `ω_cap(dir)` - is the sum over every column instead.** So a ship with attitude authority does not burn its mains to turn - a few percent faster, and a ship without it still turns. This piecewise sizing is Q5; it is the only part of - the Q2 = B design that is a choice rather than a consequence. - - Probe, at the fixture values: a Longinus with `Th.CCW` destroyed has zero counter-clockwise attitude - authority, so the fall-back sizes the demand from its mains, and the allocator turns it counter-clockwise at - 78.5°/s with `Th.R` at full throttle and 86.20 m/s² of forward lurch. Clockwise is untouched at 394.4°/s. - A hull whose single main sits on the centre line (torque 0) has no counter-clockwise authority at all, even - with the fall-back, so for that ship the long way round is the only way round. - - **Direction choice:** pick the direction that minimizes `angle_in_that_direction / ω_cap(dir)`, with - infinity where `ω_cap = 0`. With the fall-back in play this is a real comparison rather than a formality: - the damaged Longinus above takes the long way round for a 90° turn to port (270°/394.4 = 0.685 s against - 90°/78.5 = 1.15 s) and the lurching short way for a 20° turn to port (0.255 s against 0.862 s), which is - exactly the operator's "can be a better decision than turning around the long way". Keep the committed - direction unless the other is faster by a margin, so symmetric ships do not dither near 180°. - - **Rate demand:** `ω_d = min(profile(e)·ω_cap, e/dt)`, where `profile(e) = sqrt(sin e)` for e ≤ 90° (base's - curve, which preserves the pins) and 1 beyond. The `e/dt` cap lands the heading without overshoot and - replaces the snap. - - It exposes `TurnTime(heading)` and `RotationCapacity(dir)` for Cut 4. Both account for the fall-back, so - the planner sees the same slow lurching turn the controller would actually fly. - - **Translation demand:** - - `F_d = (mx·cap(±x), my·cap(±y))`, where `cap(d)` is the along-d component of the allocator's own solution to - "a huge demand along d, rotation held at zero". Capacity is self-consistent: one objective, and no second - rule for what full stick means. - - It exposes `TranslationCapacity(bodyDir)` along any body direction, same definition, for Cut 4. - - Probe values: Longinus cap(+y) 172.41, cap(+x) 13.74 (with side effects, so the demanded strafe comes out - about 0.09 m/s²). Djinni cap(+x) 37.04, cap(+y) 55.56, cap(−y) 18.52. - - **Allocation objective:** one bounded-LS call, priorities by weight separation (Johansen & Fossen § - prioritization): - - P1, yaw rate equals `ω_d`. Separation about 1e3. - - P2, translation error `F − F_d` in plain L2 (isotropic), each row normalized by the ship's largest column - magnitude. - - P3, a small ridge on throttle (about 1e-3), for tie-breaking and conditioning. - - Warm start from last tick's throttles. - - Probe: a demand-frame weighting with perpendicular error weighted k = 2 gave no benefit over isotropic - (k = 1). Keep it isotropic. - - **Budget:** 1 allocation solve plus 4 capacity solves per ship per tick. At the scratch timings that is well - under 100 µs per ship. Hands measures with a production build and reports. The fix for cost is solver - efficiency, not a cache, because a cache would be a second truth for liveness (invariant 3). + - **One solve over the whole wrench.** There is no rotation stage and no translation stage. Each tick + `ShipControl` builds a desired wrench `v = (Fx_d, Fy_d, ω_d)` and solves, once, + + min over u in [lo, hi] of Σ_axis ( w_axis · (B_axis · u − v_axis) / cap_axis )² + ridge · |u|² + + where `B` is the live column matrix, `cap_axis` is that axis's achievable extreme, and `ridge` is about 1e-3. + The normalization is what lets a rotation error and a translation error be compared: each residual reads as a + fraction of what this ship can do on that axis. + - **Achievable extremes.** `cap` for an axis is the largest value that axis can take inside the throttle box, + which for a box constraint is just the sum of the positive entries of that row (the negative ones for the + other half-axis). Six numbers, no solve: `+x`, `−x`, `+y`, `−y`, clockwise, counter-clockwise. They are also + what `MovementDirection` is a fraction of. Probe values: Longinus `+y` 172.41, `±x` 68.97, yaw 8.25 rad/s + (472.9°/s); Djinni `+y` 55.56, `−y` 18.52, `±x` 37.04, yaw 3.25 rad/s (186.2°/s). + - **Demand.** `F_d = (mx · cap(±x), my · cap(±y))` from `MovementDirection`. `ω_d = min(profile(e) · cap(dir), + e / dt)` toward the `LookDirection` heading, `profile(e) = sqrt(sin e)` for e ≤ 90° (base's own curve) and 1 + beyond, with the `e/dt` term landing the heading without overshoot in place of the deleted snap. A zero + `LookDirection` means hold heading: no demand, no NaN. + - **Direction choice** stays a time comparison, `angle_in_that_direction / cap(dir)`, infinite where `cap` is + zero, with the committed direction held unless the other is faster by a margin. It is not a branch on whether + the ship "can" turn: under Q2 = B `cap(dir)` is almost always non-zero, and the comparison is what makes the + long way round appear. + - **Weights.** Translation rows weigh 1. The yaw row weighs `w`, the single knob (Q6), default **3**. There is + no other tuning constant in the objective. + - **The three regimes fall out of that solve; none of them is a rule** (closed-loop probe, below): + - (a) *Turning while forward thrust is also wanted.* The mains are already firing for `F_d`, so their torque + costs nothing extra, and the ship turns with them. + - (b) *Turning in place.* `F_d` is zero, so every newton of main thrust is pure error, and the solver spends it + only to the extent the remaining yaw residual is worth more. The intact Longinus is no longer forced into + the 84 m/s² burn the earlier sized-demand design produced. + - (c) *No attitude authority in that direction.* The only way to cut the yaw residual at all is the mains, so + the solver takes the lurch, and the direction comparison decides whether that beats the long way round. + - **`TorqueFloor` has no consumer left.** It was read only at `Ship.cs:115,118`. The field goes from + `GameplaySettings` (`Settings.cs:205`) along with its authored value in `Assets/Resources/Settings.asset`; + `TorqueMultiplier` and `AetherTorqueMultiplier` stay, because they are physics. + - **Budget.** One solve per ship per tick, plus six sums. The earlier design's four extra capacity solves are + gone. The fix for cost is solver efficiency, not a cache, because a cache would be a second truth for liveness + (invariant 3). + +### Closed-loop probe behind Cut 3 + +The single-tick table in Part II is measured against the real engine at `cd846916`. The numbers in this section +come from a **separate scratch model**: the fixture columns above, kinematic rotation, `dt` 0.02, the objective as +stated, and the base mixer re-implemented against the same integrator so the two are compared like for like. They +are design evidence and test targets, not engine pins; Hands reproduces them against the real engine and reports +the differences. + +| Manoeuvre (Longinus fixture) | base mixer | solve at w = 3 | +|---|---|---| +| Turn 90° in place | 0.32 s, 13.9 m/s of drift | 0.30 s, 16.9 m/s of drift, main peaks at 0.73 throttle | +| Turn 90° at full forward | 0.32 s | 0.30 s | +| Strafe right, 1 s, look held | no strafe at all | 10.7 m/s gained, heading holds within 1.1° | +| Djinni turn 90° in place | 0.68 s, no drift | 0.68 s, no drift, no stern throttle | + +The drift in a turn is dominated by the nose thruster, which pushes sideways whatever the controller does: base +pays 13.9 m/s for a 90° turn and the solve pays 16.9 m/s, in about the same time. That is the honest shape of +regime (b): the main does contribute torque, but the turn does not become a forward burn. + +The yaw weight trades heading-holding against how hard the mains are spent, and on this hull both come from the +same pair of actuators: + +| w | strafe gained in 1 s | heading deviation while strafing | 90° turn in place | main peak | +|---|---|---|---|---| +| 0.5 | 0.1 m/s | 179° (heading lost) | 1.62 s | 0.14 | +| 1 | 46.4 m/s | 59° | 0.60 s | 0.37 | +| 2 | 13.7 m/s | 3.8° | 0.36 s | 0.63 | +| **3** | **10.7 m/s** | **1.1°** | **0.30 s** | **0.73** | +| 5 | 9.9 m/s | 0.4° | 0.28 s | 0.79 | + +Damaged cases at w = 3: a Longinus with `Th.CCW` destroyed reaches a heading 90° to port **the long way round** +(0.74 s, sweeping +269°) and a heading 20° to port **the short way, on differential main thrust** (1.18 s, sweeping +−18°, gaining 19.7 m/s). A hull whose single main sits on the centre line has no counter-clockwise authority at +all and always takes the long way (0.86 s). A Longinus with both nose thrusters gone still turns 20° in 0.60 s on +its mains alone, and does it while gaining 83 m/s if forward thrust was also asked for against 20 m/s if it was +not — regime (a) against regime (b) on one fixture. + - **Per-file changes** (against `cd846916`): - `Ship.cs:251-292`: `Update` calls `ShipControl.Step(dt)` inside the existing `:253` gate. The drag and position code below stays. @@ -607,15 +678,16 @@ live catalog carries it. Put the identification in front of the operator with th - **Authority map:** - Owner: `ShipControl` decides every actuator throttle and the feasible turn direction. - Inputs: `MovementDirection`, `LookDirection.xz`, current `Direction`, `Mass`, `dt`, each actuator's live - column, `TorqueFloor`/`TorqueMultiplier`/`AetherTorqueMultiplier`. - - Outputs: per-actuator throttle (`Thruster._input`, `AetherDrive._axis`); capability queries - (`TurnTime`, `RotationCapacity`, `TranslationCapacity`). - - Derived state: columns, capacities and `ω_d` are per-tick derived, never stored across ticks except the warm - start and the committed turn direction, which are command state and not truth. `Thruster.Axis` and + column, `TorqueMultiplier`/`AetherTorqueMultiplier`. + - Outputs: per-actuator throttle (`Thruster._input`, `AetherDrive._axis`); the achievable extremes, the + movement-command mapping, `TurnTime(heading)`, and the step itself, which Cut 4 uses as its prediction model. + - Derived state: columns, extremes and the demand are per-tick derived, never stored across ticks except the + warm start and the committed turn direction, which are command state and not truth. `Thruster.Axis` and `AetherDrive.Axis` are display and command readbacks. - Forbidden writers: `Ship.Update`'s mixer (gone); any assignment to `Thruster.Axis`/`AetherDrive.Axis` outside `ShipControl`; `Ship.cs:283`'s direct `Direction` write; bucket pruning on `ItemDestroyed`; any Unity-side - write of throttles. + write of throttles. Also forbidden inside `ShipControl`: any test of whether an actuator is "for" rotation or + translation, any per-direction special case, and any second solve that re-decides part of the wrench. - Shared paths: player writes (`ActionGameManager.cs:1254,1263`) and AI writes (`Agent`, `MoveTo`, `Combat`) both reach actuators only through `ShipControl.Step`. Wormhole exit leaves the gate closed until the animation ends, as today. @@ -630,12 +702,13 @@ live catalog carries it. Put the identification in front of the operator with th are pinned at their own probed values. Pins the allocator against base. - **Accepted divergences** (operator, 2026-09-22, "The divergence is fine"). Soul reads these as pinned behaviour, not as regressions, and anything outside them is a regression: - 1. Longinus turning in place fires a main at 0.12 throttle: forward creep 10.7 m/s² where base had 0, and - lateral slip 67.3 m/s² where base had 69.0. - 2. Longinus thrusting forward while turning 30° keeps 164.8 m/s² of the 172.4 base forward (−4%). + 1. Longinus turning in place spends its mains for torque, peaking near 0.73 throttle at w = 3. The + manoeuvre costs 16.9 m/s of drift against base's 13.9 and finishes in 0.30 s against base's 0.32. + 2. Longinus strafing gains a weak strafe it never had: about 10.7 m/s in a held second, heading holding + within about 1°, where base produced exactly nothing. 3. Djinni strafe reaches 37.0 m/s² against base's 34.0 (+9%), by balancing torque with Stern 3 and Bow. - 4. Djinni turning in place runs Stern 2 and Bow at about 0.10 each; net force stays zero, but their exhaust - is visible. + 4. Djinni turning in place may run a stern thruster and the bow thruster against each other at low throttle; + net force stays zero, but their exhaust is visible. Plus two intended fixes, which are not divergences to tolerate but behaviour this cut is buying: turns past 90° run at full rate instead of decaying to zero at 180°, and `|Direction|` no longer shrinks when the look is directly aft. @@ -645,14 +718,29 @@ live catalog carries it. Put the identification in front of the operator with th centred main (torque 0, so no differential authority) and one clockwise attitude thruster, the counter-clockwise one destroyed through the real `ItemDamage` path. Write `LookDirection` directly, as the player would, to 90° left. Heading changes clockwise every tick (never counter-clockwise), and the ship - arrives within 2° in under 1.0 s; the base-rate bound is 270°/394.33°/s = 0.685 s. Base comparison: a ship - of that shape never turns at all. Pins the shared feasible-direction choice. + arrives within 2° in under 1.2 s (model: 0.86 s). Base comparison: a ship of that shape never turns at all. + Pins the shared feasible-direction choice. - `LonginusMissingCcwPrefersTheLongWayAtNinetyDegrees`: the two-main Longinus with `Th.CCW` destroyed, looking - 90° left. It turns clockwise, because 0.685 s beats the 1.15 s its mains would take. Pins that the - comparison is by time, not by a rule about which actuators are "for" turning. - - `LonginusMissingCcwLurchesRoundTheShortWayAtTwentyDegrees`: the same ship looking 20° left. It turns - counter-clockwise on differential main thrust (`Th.R` at full), arrives within 2°, and its forward velocity - rises while it does. Pins Q2 = B: the lurching turn is available and is chosen when it is faster. + 90° left. It sweeps clockwise through about +270° and arrives (model: 0.74 s). Pins that the comparison is by + time, not by a rule about which actuators are "for" turning. + - `LonginusMissingCcwLurchesRoundTheShortWayAtTwentyDegrees`: the same ship looking 20° left. It sweeps + counter-clockwise through about −20° on differential main thrust, arrives within 2°, and its speed rises + while it does (model: 1.18 s, 19.7 m/s). Pins that the lurching turn is available and is chosen when it is + faster. + - `IntactShipTurningInPlaceDoesNotBurnForward`: the intact Djinni, looking 90° right with + `MovementDirection` zero. Its speed at the end of the turn is under 1 m/s and no stern or bow thruster + exceeds a low throttle. Pins regime (b) on a hull that has the actuators to keep translation clean. The + intact Longinus, which does not, is covered by accepted divergence 1 instead, with its own bound: drift no + worse than 1.3× base's 13.9 m/s and turn time no worse than base's 0.32 s. + - `NoAttitudeAuthorityStillTurns`: a Longinus with both nose thrusters destroyed, looking 20° right. It + arrives, on differential main thrust, and it gains speed doing so (model: 0.60 s). Pins regime (c). + - `TurningIsCheaperWhenForwardThrustIsAlsoWanted`: the same mains-only ship, 20° right, run twice with + `MovementDirection` zero and at full forward. Both arrive in the same time; the second gains much more + speed because the burn was wanted anyway (model: 20 m/s against 83 m/s). Pins regime (a) as a consequence of + one solve rather than a special case. + - `StrafeHoldsHeading`: the intact Longinus, full strafe held for 1 s with `LookDirection` fixed. Heading + deviation stays under 2° (model at w = 3: 1.1°). Pins the weight's other half, which is what stops the nose + swinging when a ship strafes with a thruster that also yaws. - `LonginusMissingCcwTurnsRightAsBefore`: looking 90° right reaches within 2° in 0.32 s ± one tick (base value). Pins that damage on one side leaves the other side alone. - `LookDirectlyBehindTurns`: intact, look exactly aft. The ship turns, `|Direction|` stays 1 ± 1e-4, and there @@ -676,6 +764,8 @@ live catalog carries it. Put the identification in front of the operator with th checked. - `_forwardThrusters|_clockwiseThrusters|StrafeTorqueThrusters|RecalculateThrust|TurnTime\(` should match only `ShipControl.cs`'s new `TurnTime`. + - `TorqueFloor` should match nothing in `Assets/Scripts`, and nothing in `Assets/Resources/Settings.asset` + once Unity rewrites it. - `IAnalogBehavior` should match nothing. - `lerp\(Direction` in `Ship.cs` should match nothing. - No least-squares code outside CultMath: `Cholesky|pseudo.?inverse|ActiveSet` in `Assets/Scripts` should @@ -686,10 +776,11 @@ live catalog carries it. Put the identification in front of the operator with th - Operator: fly LonginusX and confirm it feels unchanged (strafe, turn, look behind now turns). Then fly a restored Longinus or Djinni from Cut 1, shoot a thruster off it, and confirm the damaged handling and the lurching turn. Without Cut 1 this half of the check cannot be done at all. -- **Operator questions:** Q5, below. Q1, Q2 and Q3 are ruled. -- **Ledger estimate:** −200 lines in `Ship.cs`, −10 elsewhere. `ShipControl.cs` adds about 250–350 lines, tests - about 400. Net source roughly +100. That buys actuator-loss capability, which is the explicit ask, and removes - seven parallel role sets, the snap, and permanent pruning. +- **Operator questions:** Q6, below, which does not block the cut. Q1, Q2, Q3 are ruled and Q5 is dissolved. +- **Ledger estimate:** −200 lines in `Ship.cs`, −12 elsewhere (`IAnalogBehavior`, `TorqueFloor` and its authored + value). `ShipControl.cs` adds about 250–350 lines, tests about 500. Net source roughly +100. That buys + actuator-loss capability, which is the explicit ask, and removes seven parallel role sets, the snap, permanent + pruning, one authored setting and four capacity solves a tick. ## Cut 4. Heading planner in the AI @@ -700,62 +791,82 @@ live catalog carries it. Put the identification in front of the operator with th - **Deletes first:** `Agent.cs:16-17` (the two thresholds) and `Agent.cs:60-78` (the body of `Accelerate`, 19 lines). `MoveTo.cs:26` (the duplicate `LookDirection` write). The signature `Accelerate(float2, bool noTurn)` stays, since `Combat.cs:127` and `MoveTo.cs:27` call it. -- **Adds:** in `Agent` (above the inputs, AI only): - - Candidates: 16 evenly spaced headings, plus the Δv direction, plus the current heading. - - Score for each: `TurnTime(θ) + |Δv| / TranslationCapacity(Δv expressed in θ's body frame)`, infinite where - capacity is 0. Both terms come from `ShipControl`'s queries (invariant 8). - - **No direction is assumed impossible.** Under Q2 = B a damaged ship usually can still turn both ways, one of - them slowly and with a lurch, so `TurnTime` returns a finite number either way and the scoring compares them. - Nothing in the planner may branch on "this ship is a one-way turner"; the only asymmetry it sees is in the - numbers `ShipControl` hands it. - - Pick the argmin with hysteresis: replan when a candidate beats the current plan by a margin, and on a cadence - (for example every 0.25 s) or when the actuator set changes. The cadence exists for cost: 18 capacity solves - per replan. +- **Adds:** in `Agent` (above the inputs, AI only). **The planner chooses by predicted outcome, never by a + rule.** It simulates each candidate and compares where the ship actually ends up. + - **Candidate manoeuvre:** a pair (heading command, movement command) held for the horizon. The set is the + headings the ship might fly with the translation that points the demand along Δv at each: 16 evenly spaced + headings (Hands default; 8 to 32 is the sensible range), plus the Δv direction and the current heading. + - **Prediction:** roll each candidate forward over a horizon of 1.0 s (Hands default; 0.5 to 2.0 s is the + sensible range) in 5 substeps (3 to 10), stepping the *same* `ShipControl` solve and the same kinematics the + ship will actually fly. There is no second physics model and no closed-form estimate: one owner for what the + ship does, which is invariant 8 applied to prediction. + - **Score:** the predicted outcome at the horizon — remaining distance to the objective, and the closing speed + that ends the horizon (so a manoeuvre that arrives pointing the wrong way with no closing speed loses to one + that is simply nearer and still closing). Infinite where the candidate ends further away than it started. + - **This is what answers the operator's case.** A ship whose rotation has to be bought with main thrust turns in + a great circle: the prediction shows the heading improving while the range opens, so a lateral burn that keeps + the bearing and closes wins on its own numbers. Nothing in the planner tests whether the ship is a one-way + turner, whether a thruster is missing, or which actuator would be used. + - Pick the argmin with hysteresis: replan when a candidate beats the current plan by a margin, on a cadence + (0.25 s default) or when the actuator set changes. The cadence exists for cost: 18 candidates × 5 substeps is + about 90 solves per replan, roughly 1 ms per replan per ship at the scratch timings, so a 0.25 s cadence over + 20 AI ships is a few percent of a core. Hands measures and reports; if it is too slow the answer is fewer + candidates or a shorter horizon, both of which are stated ranges, not a cache. - Write `LookDirection = θ*` unless `noTurn`. Write `MovementDirection` as the per-half-axis fractions that point the translation demand along Δv, inverting `ShipControl`'s own mapping (a `ShipControl` helper, so the AI never re-derives it), with a deadband near zero Δv. - - `noTurn` (combat) skips heading choice but still gets capability-correct thrust direction. A Djinni with no + - `noTurn` (combat) skips the heading half of the candidate but still predicts the translation. A Djinni with no stern facing its target strafes or backs off on its own. - **Authority map:** - Owner: `Agent` decides what the AI writes to the two inputs. - - Inputs: target velocity, `ShipControl` capability queries, current velocity and heading. + - Inputs: target velocity and objective position, the `ShipControl` step used as the prediction model, current + velocity and heading. - Outputs: `LookDirection` (unless `noTurn`) and `MovementDirection`. - Derived: the plan (heading, time of last replan) is command state only. - - Forbidden writers: `MoveToState` writing `LookDirection`; any AI code writing throttles or `Direction`; any AI - capacity estimate not read from `ShipControl`. + - Forbidden writers: `MoveToState` writing `LookDirection`; any AI code writing throttles or `Direction`; any + second model of what the ship will do, whether a closed-form estimate, a capability table of its own, or a + branch on which actuators are missing. - Shared paths: `MoveToOrbitState` (patrol) and `CombatState` both go through `Accelerate`. - **Verification:** - `DjinniWithoutSternReachesTargetByCrabbing`: stern cluster destroyed, target 300 m at a bearing 30° right of - the nose. Driven through `Agent` plus `MoveToState` subclass with a fixed target. The ship arrives within 10 m - in under 8 s, and the planner's chosen heading puts a lateral pair along Δv. Base never arrives. Pins R1(2) and - the stuck-forever cheese. + the nose, driven through `Agent` plus a fixed-target `MoveToState`. The ship arrives within 10 m in under 8 s. + Base never arrives (speed 0 at 120 s). Pins R1(2) and the stuck-forever cheese. + - `CrabBeatsTheBigCircle`: **the operator's case, and the reason Cut 4 predicts instead of deciding.** The + fixture is a hull that can strafe cleanly but can only rotate by spending a main: two off-centre mains aft and + one amidships lateral pair whose torque cancels, no attitude thrusters, with an objective abeam to port at + 300 m. A turn-to-face policy run in the same harness spends the manoeuvre sweeping a wide arc that barely + improves the bearing and opens the range; the planner's choice closes it. Pinned as time to reach the + objective, and as range at the horizon, never as "the planner chose to strafe". - `CrabBeatsPointAndBurnWhenMainIsWeak`: Djinni with Stern 1 and Stern 2 destroyed (asymmetric; Stern 3's torque is balanced by the laterals), target 300 m at 60° left. The planner arrives strictly sooner than a naive - point-and-burn agent run in the same harness through the same Cut 3 allocator. Pins that crab walking wins when - it should, as a relation and not a captured number. - - `IntactShipsStillPointAndBurn`: an intact Djinni picks a heading within 10° of Δv and arrives no later than - base's 4.34 s + 10%. Pins no regression. - - `LonginusWithoutReverseFlipsToBrake`: target velocity opposite the current velocity; the planner turns toward - it (a Longinus has no reverse thrust). - - `PlannerPrefersTheLurchingTurnWhenItIsFaster`: the Longinus with `Th.CCW` destroyed, given a Δv 20° to port. - The heading it writes is reached counter-clockwise, and the forward lurch shows in the trajectory. Pins that - the planner inherits the controller's Q2 = B comparison instead of a one-way-turner assumption. - - `PlannerUsesShipControlCapacities`: after destroying a thruster, the planner's turn time for a dead-side - heading equals `ShipControl.TurnTime` for that heading. Pins invariant 8 and the long-way-aware estimate. + point-and-burn agent run in the same harness through the same Cut 3 controller. A relation, not a captured + number. + - `IntactShipsStillPointAndBurn`: an intact Djinni arrives no later than base's 4.34 s + 10%, with the restored + hull's own `VelocityLimit` in force. Pins no regression. + - `LonginusWithoutReverseFlipsToBrake`: target velocity opposite the current velocity; the predicted-outcome + score puts the ship round, because a Longinus has no reverse thrust. + - `PlannerTakesTheLurchWhenItPays`: the Longinus with `Th.CCW` destroyed and a Δv 20° to port, where the + lurching turn is genuinely faster. The ship gets there sooner than a fixed-heading strafe would in the same + harness. Pins that the planner inherits the controller's comparison rather than avoiding the lurch on + principle. + - `PlannerPredictionMatchesFlight`: run a candidate's prediction, then fly the same commands for the same + horizon, and compare the end states. They agree within a small tolerance. Pins that the prediction uses the + real controller and that nobody has grown a second model. - Negative grep: `LookDirection` in `Agents/States/MoveTo.cs` should match nothing. - Stryker `--since:` over `Agent.cs`, `MoveTo.cs` and `ShipControl.cs` queries. - Operator: watch a patrol Djinni with a stern thruster shot out crab to its next orbit. -- **Ledger estimate:** −22 lines, +120 lines of source, +250 lines of test. +- **Ledger estimate:** −22 lines, +140 lines of source (the prediction loop replaces the closed-form score), + +300 lines of test. No new type beyond the plan and the candidate. ## Cut 5. Combat facing and tactics (target shape only) This cut gets mapped after fire-control Cut 12 lands, because that cut decides what presenting a side is worth. - **Owner:** `CombatState` (`Combat.cs:88-127`), above the inputs. -- **Shape:** choose the orbit side and facing from `ShipControl` capabilities (per-direction rotation authority, - translation capacity along candidate directions) and from weapon arcs. Under Q2 = B there are few true one-way - turners, so the tactic is about asymmetry rather than impossibility: a ship that turns one way fast and the other - way slowly, with a lurch, keeps its target drifting toward its fast side. A ship with good turret arcs and poor rotation holds a heading and lets the +- **Shape:** extend Cut 4's predicted-outcome scoring with what the weapons want — arcs, ranges and the aim the + fire-control path will take — so the facing a ship holds is the one its predicted damage favours, not a rule + about its turn asymmetry. A ship that turns one way fast and the other way slowly will, under that scoring, tend + to keep its target drifting toward its fast side, because that is what predicts better. A ship with good turret arcs and poor rotation holds a heading and lets the turrets work. Weapons with fixed arcs pull the heading onto the target. It uses the Cut 4 planner for thrust. - **Fork:** Q4 (heading versus aim), which must be ruled before mapping. @@ -764,20 +875,29 @@ This cut gets mapped after fire-control Cut 12 lands, because that cut decides w Each question gives options and a recommendation. Self paces these one at a time. Q1, Q2 and Q3 were ruled on 2026-09-22; their text is kept as history, and the live design is in the cuts above. -- **Q5: how the rotation demand is sized under Q2 = B (blocks Cut 3).** No actuator is excluded from rotation, so - the only remaining choice is how much rotation the controller asks for. The demand cannot simply be "everything - every actuator could produce": a Longinus turning 90° in place would then run a main at 0.97 throttle and lurch - forward at 84 m/s², about half its full thrust, on every hard turn (probe). - - **A.** Size the demand from attitude authority (columns above `TorqueFloor`, plus drive torque), and fall back - to every column when that is zero for a direction. An intact ship does not burn its mains to turn a few - percent faster; a ship with no attitude thruster left on one side still turns that way, at 78.5°/s with a - lurch on the fixture Longinus. No actuator is ever excluded from *meeting* the demand. - - **B.** Always size from every column. Simpler to state, and it makes every hard turn on a ship with off-centre - mains a forward lurch, intact or not. +- **Q6: the yaw weight in Cut 3's solve (does not block Hands).** The solve has exactly one knob, the weight on + the yaw residual against the translation residuals, after each axis is normalized by that ship's achievable + extreme. On a Longinus both the strafe and the turn come from the same nose thrusters, so the knob trades + heading-holding against how hard the mains are spent for torque (closed-loop model, table in Cut 3): - **Recommended: A.** It is what makes Q2 = B's lurching turn an option the ship takes when it is worth it, - instead of a permanent tax on turning. The cost of A is that `ω_cap` is piecewise, which is stated in Cut 3 and - pinned by two tests. + | w | strafe gained in a held second | heading deviation while strafing | 90° turn in place | main peak | + |---|---|---|---|---| + | 1 | 46.4 m/s | 59° | 0.60 s | 0.37 | + | 2 | 13.7 m/s | 3.8° | 0.36 s | 0.63 | + | 3 | 10.7 m/s | 1.1° | 0.30 s | 0.73 | + | 5 | 9.9 m/s | 0.4° | 0.28 s | 0.79 | + + The Djinni is unaffected at every value: it has the actuators to do both cleanly. Base, for reference, does not + strafe at all and turns 90° in 0.32 s. + - **A: w = 3.** Heading holds while strafing, turns match base's speed, and the mains carry a real share of the + torque in a turn. + - **B: w ≈ 1.** The mains stay nearly out of turns, but a Longinus asked to strafe swings its nose most of the + way round, because strafing and turning are the same thrusters. + - **C: w = 5.** Heading holding is near-exact and the mains are spent hardest. + + **Recommended: A.** Hands starts there and the Cut 3 pins are written against it. This is a feel question, so + the honest place to settle it is the Cut 1 play check; changing it afterwards moves numbers in pins, not + structure. - **Q4: heading versus aim (blocks Cut 5 only).** `LookDirection` is both the heading command and the aim, read by `LockWeapon.cs:94`'s lock cone, guided projectiles, reticle targeting and the tractor beam. Capability-aware facing wants the AI to hold a heading that is not its aim, and under R2 any split applies to the player too. @@ -787,7 +907,7 @@ Each question gives options and a recommendation. Self paces these one at a time **Recommended: A**, decided when Cut 5 is mapped. -### Ruled (history) +### Ruled or dissolved (history) - **Q1: intact-ship divergence from base.** **Ruled A, 2026-09-22: "The divergence is fine."** The four accepted divergences and their magnitudes are listed in Cut 3's verification. The rejected option was to require exact @@ -795,10 +915,15 @@ Each question gives options and a recommendation. Self paces these one at a time - **Q2: rotation from translation thrusters.** **Ruled B, 2026-09-22: "that's absolutely viable and can be a better decision than turning around the long way, I wouldn't introduce a thruster exclusion for this."** The option this map recommended (A: rotation authority only from thrusters above `TorqueFloor`, so a lost attitude - thruster means that direction is gone) was rejected. Cut 3 and Cut 4 are written to B, and Q5 is what is left of - the question. + thruster means that direction is gone) was rejected. Cut 3 and Cut 4 are written to B. - **Q3: Longinus fixture layout.** **Ruled, 2026-09-22: "Good catch on the Longinus main thrusters, I was misremembering."** Fixtures follow the data: two aft 2×2 mains. +- **Q5: how the rotation demand is sized.** **Dissolved, 2026-09-22.** The question assumed a separate rotation + demand that something had to size. The operator refused the premise — "it should only do that if the ship's + controller also *wants* to accelerate forwards that much" — and the answer is one solve over the whole wrench + with both residuals in the same currency, which makes the three regimes consequences rather than rules. Neither + of the options this map offered (size from attitude authority with a fall-back; size from everything) survives, + and `TorqueFloor` loses its last consumer. What is left of the question is Q6, the single weight. ## Subtraction ledger (estimates) @@ -806,6 +931,6 @@ Each question gives options and a recommendation. Self paces these one at a time |---|---|---|---| | 1 | 0 source; the one-shot command is deleted again in this campaign | 3–7 catalog records + products; ~150 lines of throwaway tool + ~120 test | No new package, target, asset or authored field. Existing prefabs, schematics and factions are reused | | 2 | 0 | ~200 src + ~150 test (CultLib) | CultMath gains one file; cultmath-unity 0.2.4; Aetheria pin bump | -| 3 | ~210 (`Ship.cs` ~200, `Behaviors.cs` 4, `Entity.cs` 1, setters) | ~300 src + ~450 test | `IAnalogBehavior` removed; no new package, target or authored field | -| 4 | ~22 | ~130 src + ~280 test | none | +| 3 | ~212 (`Ship.cs` ~200, `Behaviors.cs` 4, `Entity.cs` 1, setters, `TorqueFloor` and its authored value) | ~300 src + ~500 test | `IAnalogBehavior` and one authored setting removed; four capacity solves a tick removed; no new package, target or authored field | +| 4 | ~22 | ~140 src + ~300 test | none | | 5 | not mapped | not mapped | not mapped | From d38efba3a356e37a103227b96fd9fcd9ced1b60f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 03:01:21 +0200 Subject: [PATCH 005/116] Cut 12.2 Soul fix batch 2, S1/S3: one shadow authority in Lane, sort without a comparer S1 (HIGH, crash in play): Lane's own shadow prefilter (a re-derivation of Silhouette's centre/h) and the independent SlabAlongB 2-axis intersection test could disagree at a float edge, rejecting a cell the shadow sum had already counted metal for -- an empty lane after a hit passed its roll (R3 violation), reproducible through Zone.Update. Both are replaced by one shared admission test (Extent(c, axis), the exact support of a unit square along an axis) that Silhouette and Lane now both call; SlabAlongB is deleted, and its geometry is kept only as a non-rejecting AlongBearing() that computes entry/exit for already-admitted cells (the doc's own equivalence proof -- "s in the shadow" is exactly "the line intersects" -- makes rejection there unreachable by construction, not merely unlikely). S3 (allocation): Interval and LaneCell now implement IComparable (same order as the deleted IntervalByLoComparer/LaneCellComparer) so Silhouette's and Lane's sorts use Array.Sort(T[], int, int), which resolves T's own comparison at JIT time, instead of the (T[], int, int, IComparer) overload, which wraps even a cached singleton comparer in a fresh delegate per call. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 119 ++++++++++++--------- 1 file changed, 66 insertions(+), 53 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 63d20a4da..ced141d24 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -307,6 +307,20 @@ public static class FireControl // of each carrying their own copy of the sign. private static float2 Lateral(float2 bearing) => float2(-bearing.y, bearing.x); + // Cut 12.2 fix batch (S1, Soul's second pass): the one function that projects a unit cell onto an axis -- + // the exact support of the axis-aligned unit square centred on c along `axis`, half-width + // h = (|axis.x| + |axis.y|) / 2. Silhouette's shadow (axis = ell) and Lane's own admission test (axis = + // ell) now read this SAME arithmetic instead of Lane re-deriving its own copy of centre/h -- the two used + // to differ only in that Lane's copy was a plain re-derivation, not a second formula, but "not a second + // formula" is exactly why they never drifted apart in exact arithmetic and exactly why the real defect was + // never here: it was the second, independent SlabAlongB gate below, deleted with this fix. + private static Interval Extent(float2 c, float2 axis) + { + var h = (abs(axis.x) + abs(axis.y)) / 2f; + var centre = dot(c, axis); + return new Interval { Lo = centre - h, Hi = centre + h }; + } + public static float DeviationProbability(PendingShot shot, float now, out float deviation) { var elapsed = now - shot.FireTime; @@ -680,15 +694,11 @@ public static class FireControl { var ell = Lateral(bearing); var coords = hull.Shape.Coordinates; - var h = (abs(ell.x) + abs(ell.y)) / 2f; var intervals = buffer ?? new Interval[coords.Length]; for (var i = 0; i < coords.Length; i++) - { - var centre = dot((float2) coords[i], ell); - intervals[i] = new Interval { Lo = centre - h, Hi = centre + h }; - } - Array.Sort(intervals, 0, coords.Length, IntervalByLoComparer.Instance); + intervals[i] = Extent((float2) coords[i], ell); + Array.Sort(intervals, 0, coords.Length); // Merge in place: sorted ascending by Lo, so an interval overlaps (or exactly abuts) the last kept // interval whenever its own Lo does not exceed that interval's Hi. @@ -778,29 +788,36 @@ public static class FireControl // walk's first element is the impact cell. Only that first element is read in 12.2; the rest is 12.3's // armour-first absorption march. Writes into the caller-supplied pooled buffer and returns the walked // count; never allocates on its own. + // Cut 12.2 fix batch (S1, Soul's second pass): admission is now decided ONCE, by the exact same shadow + // interval Silhouette sums over (Extent(c, ell), the shared function above) -- not by a re-derivation and + // not by a second, independent geometric test. The old code ran two tests: this shadow prefilter (a + // "re-derivation" of Silhouette's own centre/h, inline) AND SlabAlongB, an independent 2-axis ray-box + // intersection that could reject a cell the shadow test had just admitted, purely on float rounding at an + // interval edge -- an empty lane for a shot that had already passed its roll (R3 violation, the crash Soul + // reproduced through Zone.Update). Deleting the second gate removes the split authority: a cell the shadow + // sum counted metal for can no longer be un-counted here. public static int Lane(HullData hull, float2 b, float s, LaneCell[] buffer) { var ell = Lateral(b); - var h = (abs(ell.x) + abs(ell.y)) / 2f; var coords = hull.Shape.Coordinates; var n = 0; for (var i = 0; i < coords.Length; i++) { var c = (float2) coords[i]; - var centre = dot(c, ell); - if (s < centre - h || s >= centre + h) continue; // outside this cell's own lateral shadow - - if (!SlabAlongB(c, b, ell, s, out var entry, out var exit)) continue; // the shadow test is a conservative bound; the exact slab can still miss + var lateral = Extent(c, ell); + if (s < lateral.Lo || s >= lateral.Hi) continue; // half-open, the same admission Silhouette sums over // F9 (Soul's fix batch, 2026-09-24): Projection (dot(cell, b)) is precomputed here, into the - // struct, so the sort below needs no closure over `b` -- LaneCellComparer.Instance is a stateless - // singleton, not a per-call Comparer.Create allocation the "never allocates" comment used to - // contradict. + // struct, so the sort below needs no closure over `b`. S3 fix batch: the sort itself now uses + // LaneCell's own IComparable, not a per-call Comparer.Create allocation the "never + // allocates" comment used to contradict. Projection is also the "along-bearing projection" the cut + // doc orders entry by. + AlongBearing(c, b, ell, s, out var entry, out var exit); buffer[n++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit, Projection = dot(c, b) }; } - Array.Sort(buffer, 0, n, LaneCellComparer.Instance); + Array.Sort(buffer, 0, n); var walked = n > 0 ? 1 : 0; for (var i = 1; i < n; i++) @@ -811,29 +828,35 @@ public static class FireControl return walked; } - // The slab (ray-box) intersection of point(t) = t*b + s*ell against the unit square centred on cell c, one - // axis at a time: solving bAxis*t + val in [cLo, cHi] for each of x and y, then entry = the later of the - // two lower bounds, exit = the earlier of the two upper bounds. A near-zero bAxis component means the ray - // does not move along that axis at all, so it either always satisfies that axis's bound (no constraint on - // t) or never does (no intersection). - private static bool SlabAlongB(float2 c, float2 b, float2 ell, float s, out float entry, out float exit) + // Cut 12.2 fix batch (S1): the along-bearing entry/exit parameters for a cell the shadow test above has + // ALREADY admitted -- unconditional, never a gate. The doc's own proof ("s lies in the shadow is exactly + // the same event as this lane has metal") makes the intersection this computes a certainty, not a + // hypothesis to re-test: for a convex shape, a value inside its projection onto ell is achieved by a + // nonempty segment of the line ell=s, so the box-slab solve below cannot come back empty in exact + // arithmetic. It only ever disagreed with the shadow test by float rounding at the interval's own edge, + // which is exactly where SlabAlongB used to throw the metal away; here that same rounding is absorbed by + // clamping entry <= exit instead of rejecting. + private static void AlongBearing(float2 c, float2 b, float2 ell, float s, out float entry, out float exit) { entry = float.NegativeInfinity; exit = float.PositiveInfinity; - return SlabAxis(b.x, c.x - .5f, c.x + .5f, s * ell.x, ref entry, ref exit) - && SlabAxis(b.y, c.y - .5f, c.y + .5f, s * ell.y, ref entry, ref exit); + SlabAxis(b.x, c.x - .5f, c.x + .5f, s * ell.x, ref entry, ref exit); + SlabAxis(b.y, c.y - .5f, c.y + .5f, s * ell.y, ref entry, ref exit); + if (entry > exit) exit = entry; // rounding only -- the shadow test already proved a real intersection } - private static bool SlabAxis(float bAxis, float lo, float hi, float val, ref float entry, ref float exit) + // The slab (ray-box) bound of point(t) = t*b + s*ell against the unit square centred on cell c, one axis at + // a time: solving bAxis*t + val in [lo, hi], then folding the result into the running (entry, exit) bound. + // A near-zero bAxis component means the ray does not move along that axis at all, so it adds no constraint + // on t (the shadow test above already established this axis's bound holds). + private static void SlabAxis(float bAxis, float lo, float hi, float val, ref float entry, ref float exit) { - if (abs(bAxis) < 1e-9f) - return val >= lo && val <= hi; // no motion along this axis: in bounds forever, or never + if (abs(bAxis) < 1e-9f) return; // no motion along this axis: already covered by the shadow admission test var t1 = (lo - val) / bAxis; var t2 = (hi - val) / bAxis; entry = max(entry, min(t1, t2)); exit = min(exit, max(t1, t2)); - return entry <= exit; } // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source @@ -925,20 +948,15 @@ public struct FireControlDiagnostic // Cut 12.2 (docs/fire-control-cut.md): a merged interval of a hull's lateral shadow, in the schematic frame's // lateral (ell) coordinate. -public struct Interval +// S3 fix batch (Hands, 2026-09-25): IComparable (sorted by Lo) instead of a separate IComparer -- +// Array.Sort(T[], int, int, IComparer) boxes/wraps the comparer into a fresh delegate on CoreCLR every call +// even when the comparer instance itself is a cached singleton; Array.Sort(T[], int, int) resolves T's own +// IComparable at JIT time and allocates nothing per call. Same order as the deleted IntervalByLoComparer. +public struct Interval : IComparable { public float Lo; public float Hi; -} - -// Cost fix batch (Self, 2026-09-24): a stateless singleton, not a per-call Comparer.Create -- Silhouette's -// own sort no longer allocates a wrapper on every call, matching the ArrayPool pooling this batch added on the -// forecast path. -internal sealed class IntervalByLoComparer : IComparer -{ - public static readonly IntervalByLoComparer Instance = new IntervalByLoComparer(); - private IntervalByLoComparer() { } - public int Compare(Interval x, Interval y) => x.Lo.CompareTo(y.Lo); + public int CompareTo(Interval other) => Lo.CompareTo(other.Lo); } // Cut 12.2: the hull's lateral shadow at one bearing, and the exact Gaussian mass it carries. Intervals holds @@ -958,7 +976,11 @@ public struct Silhouette // Cut 12.2: one occupied cell FireControl.Lane crossed at a fixed lateral offset, with its own slab entry/exit // parameter along the bearing. Entry ascending is nearest-to-farthest along the shot's own travel direction. -public struct LaneCell +// S3 fix batch (Hands, 2026-09-25): IComparable replaces the separate LaneCellComparer, for the same +// reason Interval's own comparer was deleted -- the (T[], int, int, IComparer) sort overload allocates a +// wrapper delegate per call even for a cached singleton comparer; (T[], int, int) does not. Same order as the +// deleted comparer: entry ascending, ties by projection along b, then cell index for full determinism. +public struct LaneCell : IComparable { public int2 Cell; public float Entry; @@ -966,24 +988,15 @@ public struct LaneCell // F9 (Soul's fix batch, 2026-09-24): dot(Cell, b), precomputed once per candidate so Lane's sort tie-break // needs no closure over the bearing. public float Projection; -} -// F9: Lane's own sort order (entry ascending, ties by projection along b, then cell index for full -// determinism) as a stateless singleton -- Array.Sort's IComparer overload needs an instance, and this one -// is allocated exactly once, ever, not per Lane() call. -internal sealed class LaneCellComparer : IComparer -{ - public static readonly LaneCellComparer Instance = new LaneCellComparer(); - private LaneCellComparer() { } - - public int Compare(LaneCell x, LaneCell y) + public int CompareTo(LaneCell other) { - var byEntry = x.Entry.CompareTo(y.Entry); + var byEntry = Entry.CompareTo(other.Entry); if (byEntry != 0) return byEntry; - var byProjection = x.Projection.CompareTo(y.Projection); + var byProjection = Projection.CompareTo(other.Projection); if (byProjection != 0) return byProjection; - var byX = x.Cell.x.CompareTo(y.Cell.x); - return byX != 0 ? byX : x.Cell.y.CompareTo(y.Cell.y); + var byX = Cell.x.CompareTo(other.Cell.x); + return byX != 0 ? byX : Cell.y.CompareTo(other.Cell.y); } } From d82b6bf9edeb93d6bb8e0a05060f8208240d476a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 03:06:44 +0200 Subject: [PATCH 006/116] Cut 12.2 Soul fix batch 2, S1: behavioural tests, not reflection, for the shadow fix Replaces LateralDrawIsHalfOpenAtTheShadowsTopEdge (reflection into the private LateralDraw) with: - ShotAtBearingLoTouchingOnlyACellCornerHitsMetal and ShotAtBearingLoOnTurningArmourHitsMetal, Soul's two named-seed crash repros (Cut12-edge-68442709 and Cut12-edge-64078070 -- StableHash(zone name) seeds Zone.CombatSeed, so the exact name reproduces the exact die roll) run through the real public Fire/Zone.Update path, asserting the shot commits a hit whose cell's own projected lateral interval contains the drawn Lateral, instead of throwing. - LaneNeverEmptiesAtAnIntervalEdge, the general sweep: every interval's Lo and the point just below its own half-open Hi, across several bearings, precisions and hulls (including a non-convex, holed one), must never return an empty lane from FireControl.Lane. Build gained a zoneName parameter (Cut 6.1's one-fixed-name convention doesn't serve a repro that needs an exact CombatSeed) and a tracking parameter and an equipLMarker fixture, both used by later findings in this batch. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut12Tests.cs | 178 +++++++++++++++--- 1 file changed, 148 insertions(+), 30 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 54e5a02e9..2d0d504e0 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -63,6 +63,19 @@ public sealed class FireControlCut12Tests : IDisposable return shape; } + // S1 fix batch: a non-convex hull for the empty-lane sweep -- a solid square with its centre cell punched + // out, the same "holed7" shape Soul's own EdgeSweep probe used. + private static Shape HoledShape(int w, int h) + { + var shape = SolidShape(w, h); + shape[new int2(w / 2, h / 2)] = false; + return shape; + } + + // A unit direction at `d` degrees, matching Soul's own probe convention (SoulProbe122b.Deg) -- used here to + // name an exact facing/bearing for a crash repro. + private static float2 Deg(double d) => float2((float) Math.Sin(d * Math.PI / 180), (float) Math.Cos(d * Math.PI / 180)); + private sealed class Engagement { public ItemManager Items; @@ -75,6 +88,8 @@ public sealed class FireControlCut12Tests : IDisposable public EquippedItem[] Markers; public EquippedItem BigMarker; public int2[] BigMarkerCells; + public EquippedItem LMarker; + public int2[] LMarkerCells; } // A shooter off the origin (Cut 11.3), a target over a caller-supplied hull shape, a full-strength @@ -85,7 +100,19 @@ public sealed class FireControlCut12Tests : IDisposable GameplaySettings settings, Shape hullShape, float precision, float velocity = 0, float spread = 0, float2? targetFacing = null, bool equipShield = false, float shieldCapacity = 30, - int2[] markerCells = null, float accuracy = 1f, float penetration = 0, bool equipBigMarker = false) + int2[] markerCells = null, float accuracy = 1f, float penetration = 0, bool equipBigMarker = false, + // S1 fix batch (Hands, 2026-09-25): a caller-chosen zone name -- Cut 6.1's own convention is one fixed + // name so a fixture is replayable byte-for-byte, but Soul's own crash repros (Zone.CombatSeed is + // StableHash(name)) each need their OWN exact name to reproduce a specific die roll. + string zoneName = "Cut12", + // S7 fix batch (Hands, 2026-09-25): Tracking authored through Build, like Precision and Accuracy already + // are, instead of a caller patching PendingShot.Tracking after Fire (TheHudEstimateIsTheCommitPrice's own + // former shape) -- one authoring path for every frozen shooter stat. + float tracking = 100000f, + // S8 fix batch (Hands, 2026-09-25): an L-shaped (3-cell, not 2x2) Tool item -- its true centroid differs + // from both any single cell and the bounding-box centre a mutant could substitute (N11), which a + // rectangular BigMarker cannot distinguish. + bool equipLMarker = false) { var hullData = new HullData { @@ -125,7 +152,7 @@ public sealed class FireControlCut12Tests : IDisposable MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new TargetingSystemData { - Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f) + Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(tracking) } } }); cache.Upsert(new GearData @@ -155,6 +182,15 @@ public sealed class FireControlCut12Tests : IDisposable Name = "BigMarker", Hardpoint = HardpointType.Tool, Shape = bigMarkerShape, Durability = 1000000, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); + // S8 fix batch: an L-tromino (3 of a 2x2's 4 cells) -- its centroid ((0,0)+(1,0)+(0,1))/3 = (1/3,1/3) is + // neither any one of its own cells nor the bounding box's centre (.5,.5), so a fixture built from it can + // tell a true centroid apart from both. + var lMarkerShape = new Shape(2, 2) { [new int2(0, 0)] = true, [new int2(1, 0)] = true, [new int2(0, 1)] = true }; + cache.Upsert(new GearData + { + Name = "LMarker", Hardpoint = HardpointType.Tool, Shape = lMarkerShape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); @@ -165,7 +201,7 @@ public sealed class FireControlCut12Tests : IDisposable // is replayable byte-for-byte across runs. A GUID-suffixed name here (this file's own earlier defect) // reseeded the die on every run, which is exactly what made TurningArmourIntoTheShotTakesItOnTheArmour // flaky (F2, Soul's fix batch, 2026-09-24). - var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut12", Owner = null }, null); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem { @@ -212,6 +248,18 @@ public sealed class FireControlCut12Tests : IDisposable Assert.Equal(4, bigMarkerCells.Length); // fixture precondition: the full 2x2 footprint landed } + EquippedItem lMarker = null; + int2[] lMarkerCells = null; + if (equipLMarker) + { + var lm = Make("LMarker", lot++, 1000000); + Assert.True(target.TryFindSpace(lm, out var lmPos)); + Assert.True(target.TryEquip(lm, lmPos)); + lMarker = target.Equipment.Single(x => x.EquippableItem == lm); + lMarkerCells = hullShape.Coordinates.Where(v => target.GearOccupancy[v.x, v.y] == lMarker).ToArray(); + Assert.Equal(3, lMarkerCells.Length); // fixture precondition: the full 3-cell L footprint landed + } + if (equipShield) { var reactor = Make("Reactor", lot++, 10); @@ -247,7 +295,8 @@ public sealed class FireControlCut12Tests : IDisposable return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, - HullData = hullData, Markers = markers, BigMarker = bigMarker, BigMarkerCells = bigMarkerCells + HullData = hullData, Markers = markers, BigMarker = bigMarker, BigMarkerCells = bigMarkerCells, + LMarker = lMarker, LMarkerCells = lMarkerCells }; } @@ -527,39 +576,108 @@ public sealed class FireControlCut12Tests : IDisposable Assert.True(outcome.Cell.y >= 5.5f, $"expected every hit to land on the bow row (y>=5.5), got cell {outcome.Cell}"); } - // F1 (Soul's fix batch, 2026-09-24): LateralDraw's clamp used to close the top of the shadow interval - // ([Lo, Hi] instead of the spec's half-open [Lo, Hi)). Soul enumerated all 2^24 NextFloat outputs against - // this exact geometry (TurningArmourIntoTheShotTakesItOnTheArmour's own 3x11 hull, bearing (0,-1), - // Precision .4 -> interval [-0.5, 2.5]) and found 3 values of u that drew s == Hi == 2.5 exactly, at which - // Lane's own half-open filter (`s >= centre + h` continues, :731) then found no candidate cell at all, - // returning an empty lane for a shot that had already passed its roll (R3 violation) -- the reflection - // call below reproduces that exact draw. u=0 is the analogous exact-Lo case (the first NextFloat output). + // S1 (Soul's second pass, 2026-09-25): at a non-axis-aligned bearing, a lateral draw exactly at an + // interval's Lo used to touch only a cell's corner -- Lane's own shadow prefilter admitted the cell but the + // independent SlabAlongB slab test rejected it on float rounding, Lane returned zero cells, and Commit's + // own guard threw InvalidOperationException out of Zone.Update (R3 violation: a hit that already passed + // its roll must always land on metal). Soul reproduced this live, not through reflection into a private + // method: zone name Cut12-edge-68442709 (StableHash seeds Zone.CombatSeed, so this exact name reproduces + // the exact die roll), a 7x9 hull, Precision 2, target facing 1 degree, shot 1. Fixed by deleting the + // second, disagreeing gate (this fix batch's FireControl.cs change) -- this pins the exact repro against a + // real Commit, not a reflection call, and checks the placement lands where the roll priced it. [Fact] - public void LateralDrawIsHalfOpenAtTheShadowsTopEdge() + public void ShotAtBearingLoTouchingOnlyACellCornerHitsMetal() { - var hull = new HullData { Name = "H", HullType = HullType.Ship, Shape = SolidShape(3, 11) }; - var bearing = float2(0, -1); - const float precision = .4f; - var sil = FireControl.Silhouette(null, hull, null, bearing, precision); - Assert.Equal(1, sil.Count); // fixture precondition: one interval, matching Soul's own probe - Assert.Equal(-0.5f, sil.Intervals[0].Lo, 3); - Assert.Equal(2.5f, sil.Intervals[0].Hi, 3); + var e = Build(TestSettings(), SolidShape(7, 9), precision: 2f, targetFacing: Deg(1), zoneName: "Cut12-edge-68442709"); - var lateralDraw = (Func) typeof(FireControl) - .GetMethod("LateralDraw", BindingFlags.NonPublic | BindingFlags.Static) - .CreateDelegate(typeof(Func)); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); - void AssertLandsOnMetal(float u, string label) + Assert.NotNull(outcome); + Assert.True(outcome.Hit, "fixture: this exact seed must roll a hit (this is the repro, not a general property)"); + Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, not on the hull's own schematic"); + + var ell = float2(-outcome.Bearing.y, outcome.Bearing.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + var centre = dot((float2) outcome.Cell, ell); + Assert.True(outcome.Lateral >= centre - h && outcome.Lateral < centre + h, + $"hit cell {outcome.Cell}'s own projected lateral interval [{centre - h},{centre + h}) does not contain the drawn Lateral {outcome.Lateral}"); + } + + // S1: the same class of empty-lane crash on the ruling's own TurningArmourIntoTheShotTakesItOnTheArmour + // geometry (3x11 hull, velocity 20, Precision .4, the fire-then-turn sequence that fixture uses) -- + // Soul's search found a zone name (Cut12-edge-64078070) whose draw lands on an interval edge after the + // target turns its bow into the shot. + [Fact] + public void ShotAtBearingLoOnTurningArmourHitsMetal() + { + var e = Build(TestSettings(), SolidShape(3, 11), precision: .4f, velocity: 20, zoneName: "Cut12-edge-64078070"); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + var travelDirection = pending.TravelDirection; + + // At Fire: bow faces the same way the shot travels (stern exposed). Before commit: turn the bow to + // face the incoming shot head-on, TurningArmourIntoTheShotTakesItOnTheArmour's own sequence. + e.Target.Direction = travelDirection; + e.Zone.Update(1f); + e.Target.Direction = -travelDirection; + + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + e.Zone.Update(4f); // past commit (4.5s), short of arrival (5s) + e.Zone.Update(2f); // past arrival + + Assert.NotNull(outcome); + Assert.True(outcome.Hit, "fixture: this exact seed/sequence must roll a hit (this is the repro, not a general property)"); + Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, not on the hull's own schematic"); + Assert.True(outcome.Cell.y >= 5.5f, $"expected the hit to land on the bow row (y>=5.5), got cell {outcome.Cell}"); + + var ell = float2(-outcome.Bearing.y, outcome.Bearing.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + var centre = dot((float2) outcome.Cell, ell); + Assert.True(outcome.Lateral >= centre - h && outcome.Lateral < centre + h, + $"hit cell {outcome.Cell}'s own projected lateral interval [{centre - h},{centre + h}) does not contain the drawn Lateral {outcome.Lateral}"); + } + + // S1 sweep: across many bearings and precisions, on hulls including a non-convex (holed) one, Lane must + // find at least one occupied cell for every lateral offset at an interval's own Lo, and just below its Hi + // -- the exact edges LateralDraw's own half-open clamp can produce (u=0 draws Lo; u near 1 draws just + // below Hi). An empty lane at either edge is precisely the class of crash S1 fixed; this is the general + // property the two named-seed repros above are specific instances of. + [Fact] + public void LaneNeverEmptiesAtAnIntervalEdge() + { + var shapes = new[] { SolidShape(7, 9), SolidShape(3, 11), HoledShape(7, 7) }; + var precisions = new[] { .3f, .5f, 1f, 2f }; + var failures = new List(); + + foreach (var shape in shapes) { - var s = lateralDraw(sil, u); - var buffer = new LaneCell[hull.Shape.Coordinates.Length]; - var count = FireControl.Lane(hull, bearing, s, buffer); - Assert.True(count > 0, $"{label}: u={u:R} drew s={s:R} (interval [{sil.Intervals[0].Lo},{sil.Intervals[0].Hi}]) -- Lane found no occupied cell for a hit that already passed its roll"); - Assert.True(hull.Shape[buffer[0].Cell], $"{label}: Lane's own first cell {buffer[0].Cell} is not on the hull's schematic"); + var hull = new HullData { Name = "Sweep", HullType = HullType.Ship, Shape = shape }; + var buffer = new LaneCell[shape.Coordinates.Length]; + for (var deg = 0; deg < 360; deg += 3) + { + var b = Deg(deg); + foreach (var precision in precisions) + { + var sil = FireControl.Silhouette(null, hull, null, b, precision); + for (var k = 0; k < sil.Count; k++) + { + var iv = sil.Intervals[k]; + if (FireControl.Lane(hull, b, iv.Lo, buffer) == 0) + failures.Add($"{shape.Width}x{shape.Height} deg={deg} p={precision} k={k} at Lo={iv.Lo:R}"); + var justBelowHi = Math.Max(iv.Lo, iv.Hi - 1e-4f - 1e-5f); + if (FireControl.Lane(hull, b, justBelowHi, buffer) == 0) + failures.Add($"{shape.Width}x{shape.Height} deg={deg} p={precision} k={k} just below Hi={iv.Hi:R}"); + } + } + } } - AssertLandsOnMetal(0.9999998f, "at the top edge (u drives s to exactly Hi)"); - AssertLandsOnMetal(0f, "at the bottom edge (u drives s to exactly Lo)"); + Assert.True(failures.Count == 0, $"{failures.Count} empty lanes at an interval edge, e.g.: {string.Join("; ", failures.Take(5))}"); } // Entity.ApplyHit's own penetration march, replicated independently (not by calling ApplyHit or reading From c2b27b10eeb6a0aebb84311a9d6d55c23decfab8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 03:11:27 +0200 Subject: [PATCH 007/116] Cut 12.2 Soul fix batch 2, S2: pin the within-prong ratio, not just direction TwoProngHullSplitsTheScatterAcrossBothIntervals's own within-prong check (atNearColumn > atFarColumn) held under M12/M12b (an entire second silhouette built at the wrong sigma) and the sigma/sqrt(2) Stryker mutants, because every wrong sigma tried still puts more mass on the nearer column -- direction alone cannot separate a sigma of 1.5 from 1.5*sqrt(2). Their analytic near/far mass ratios are well apart (~2.9 true vs ~1.73 at sigma*sqrt(2)), so the test now computes the true ratio from the same per-cell Gaussian integral it already uses and asserts the empirical ratio (8000 shots) tracks it within a tolerance that rejects the wrong-sigma ratio. Manually verified against the exact M12 and M12b patches from Soul's mutation script: both now fail this test (empirical ratios 581 and 1.68, matching a collapsed and a sqrt(2)-shifted sigma respectively, against the analytic ~2.9). Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut12Tests.cs | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 2d0d504e0..a634bec05 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -921,14 +921,28 @@ public sealed class FireControlCut12Tests : IDisposable Assert.True(Math.Abs(empiricalShareA - expectedShareA) < .05, $"prong A's empirical share {empiricalShareA:F3} does not match its analytic share {expectedShareA:F3}"); - // Within-prong shape, not just the aggregate share: prong A is x in {0,1}, projected lateral centres - // 0 and -1 respectively (ell=(-1,0)); a=-3 sits closer to x=1's centre (-1) than x=0's (0), so a real - // Gaussian shape (not a coin flip within the prong, and not "first cell only") puts strictly more mass - // on x=1 than x=0. + // S2 fix batch (Hands, 2026-09-25): within-prong SHAPE, pinned by ratio, not just direction. Prong A is + // x in {0,1}, projected lateral centres 0 and -1 respectively (ell=(-1,0)), a=-3. The old + // "atNearColumn > atFarColumn" check survived M12/M12b (an entire second silhouette built at sigma + // floor .5 or at sigma*sqrt(2)) and the sigma/sqrt(2) Stryker mutants at :~753/:~750, because every one + // of those wrong sigmas still puts more mass on the nearer column -- direction alone can't tell a + // sigma of 1.5 from a sigma of 1.5*sqrt(2). The RATIO can: Soul's own figures are ~2.9 at the true + // sigma against ~1.73 at sigma*sqrt(2), well separated at 8000 shots. The near and far columns' own raw + // (unmerged) intervals are exactly the two ends of `raw` for prong A, reused here rather than + // recomputed. + var nearInterval = raw.First(iv => Math.Abs(iv.Lo - (-1.5f)) < 1e-3f); // x=1: [-1.5,-0.5) + var farInterval = raw.First(iv => Math.Abs(iv.Lo - (-0.5f)) < 1e-3f); // x=0: [-0.5,0.5) + var nearMass = Mass(nearInterval); + var farMass = Mass(farInterval); + var analyticRatio = nearMass / farMass; + Assert.True(analyticRatio > 2.3, $"fixture: analytic near/far ratio ({analyticRatio:F3}) must be well clear of the wrong-sigma ratio (~1.73) to actually distinguish the two"); + var atNearColumn = hits.Count(o => o.Cell.x == 1); var atFarColumn = hits.Count(o => o.Cell.x == 0); - Assert.True(atNearColumn > atFarColumn, - $"expected the column nearer the aim point (x=1, {atNearColumn} hits) to outweigh the far one (x=0, {atFarColumn} hits)"); + Assert.True(atNearColumn > 0 && atFarColumn > 0, $"expected hits on both columns, got near={atNearColumn} far={atFarColumn}"); + var empiricalRatio = atNearColumn / (double) atFarColumn; + Assert.True(Math.Abs(empiricalRatio - analyticRatio) < .6, + $"empirical near/far ratio {empiricalRatio:F3} ({atNearColumn}/{atFarColumn}) does not track the analytic ratio {analyticRatio:F3} -- a sigma perturbed by sqrt(2) would give ~1.73 instead"); } // HitsLandOnTheFacingEdge supersedes EveryHitLandsOnMetal: on a concave (holed) hull, every hit's Cell is From 7eadea88abdb322d2c84944b8a438ec0d4ec6472 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 03:14:15 +0200 Subject: [PATCH 008/116] Cut 12.2 Soul fix batch 2, S4: pin flight time for an invisible target F8 (Soul's earlier fix batch, 2026-09-24) moved range computation ahead of the visibility gate in FireControl.PFire so Fire's own flight time stays real even when the shot prices at PFire 0, but no test observed that behaviour -- N4 (Soul's own mutation set, reverting F8) survived every existing test. InvisibleTargetStillResolvesAtItsRealFlightTime fires at a target removed from VisibleEntities and checks the shot does not resolve before its real 5s arrival (N4's bug would resolve it instantly, at range 0) and resolves as a miss once it does. Manually verified against the exact N4 patch: it now fails (arrival at ~1s instead of ~6s, i.e. range treated as 0). Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut12Tests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index a634bec05..29090e85c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -446,6 +446,33 @@ public sealed class FireControlCut12Tests : IDisposable Assert.Equal(expected, p, 4); } + // S4 fix batch (Hands, 2026-09-25): F8 (Soul's earlier fix batch, 2026-09-24) moved range/flightTime + // computation ahead of the visibility gate, so Fire's own flight time reflects the real distance even when + // PFire prices the shot at 0 -- but nothing behavioural pinned that. N4 (Soul's mutation set: "range after + // visibility gate", reverting F8) survived every existing test. A shot at a target the shooter cannot + // currently see must still resolve, as a miss (PFire gates it to 0), at the arrival time the real + // range/velocity imply -- not immediately (range 0) and not never (flight time 0 means instant resolve). + [Fact] + public void InvisibleTargetStillResolvesAtItsRealFlightTime() + { + var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f, velocity: 20); // range 100 -> 5s flight + e.Shooter.VisibleEntities.Remove(e.Target); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Equal(0f, pending.PFire); // fixture precondition: the visibility gate is actually closed + var expectedArrival = pending.FireTime + 100f / 20f; // the real range (100) over the real velocity (20) + Assert.Equal(expectedArrival, pending.ArrivalTime, 3); + + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + e.Zone.Update(4.9f); + Assert.Null(outcome); // not yet: N4's bug (range 0) would have resolved this instantly + e.Zone.Update(.2f); // past the real 5s arrival + Assert.NotNull(outcome); + Assert.False(outcome.Hit, "fixture: PFire 0 (not visible) must resolve as a miss"); + } + // Stryker survivors: ResolveAimPoint's fallback (aimed but currently occupying zero cells -> hull centre // of mass) and its multi-cell average were both untested -- every existing marker fixture uses the // catalog's 1x1 "Marker" stub, where sum/length and sum*length agree at length 1, and no fixture ever From 4510a62c4f0708aeb615b78a2adbe2c985637a40 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 03:17:38 +0200 Subject: [PATCH 009/116] Cut 12.2 Soul fix batch 2, S7: author Tracking through Build, not by patching after Fire TheHudEstimateIsTheCommitPrice used to fire each shot, then reach into Zone.PendingShots and overwrite the frozen Tracking field after the fact. Build now takes a `tracking` parameter (default 100000, matching the old fixed value) alongside Precision and Accuracy, so this test authors Tracking = 6 up front like every other frozen shooter stat, with no post-hoc patch loop. Manually verified M15 (Commit applies PSpread twice) still fails this test (empirical 0.070 vs forecast 0.126) after the rewrite. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut12Tests.cs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 29090e85c..829dd0304 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -1052,10 +1052,15 @@ public sealed class FireControlCut12Tests : IDisposable // a target jinked off its fire-time projection so pDeviation is strictly less than 1) and checks the // EMPIRICAL hit fraction over the real Commit path against the HUD's own forecast, with a tolerance sized // from the sample (3 sigma of a Bernoulli(p, n)). + // S7 fix batch (Hands, 2026-09-25): Tracking is now authored through Build's own `tracking` parameter (one + // authoring path for every frozen shooter stat, matching Precision/Accuracy) instead of patching + // PendingShot.Tracking after every Fire -- the operator's own rule (2026-09-22) is that a fixture should + // author its state up front, not reach into a frozen field post hoc. An authored Tracking of 6 against this + // 5x7 hull and jink gives pDeviation ~= .33, strictly between 0 and 1, with no patching. [Fact] public void TheHudEstimateIsTheCommitPrice() { - var e = Build(TestSettings(), SolidShape(5, 7), precision: .5f, velocity: 40, spread: 6f); // 2.5s flight, commits at 2.0s + var e = Build(TestSettings(), SolidShape(5, 7), precision: .5f, velocity: 40, spread: 6f, tracking: 6f); // 2.5s flight, commits at 2.0s var origin = e.Target.Position; const int shots = 800; @@ -1065,16 +1070,8 @@ public sealed class FireControlCut12Tests : IDisposable e.Target.Position = origin; // reset before each Fire so every shot freezes the same FireTargetPosition var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.001f); - // The fixture's own Targeting system freezes Tracking = 100000 (full-strength isolation, Build's - // own convention) -- against that, a jink small enough to keep the target on this 5x7 hull barely - // registers (pDeviation ~= .99996). Lower the frozen Tracking after the fact, the same way - // CommitProbabilityIsTheExactProductOfItsFourFactors overrides a frozen field, so the jink below - // produces a pDeviation strictly between 0 and 1. var index = e.Zone.PendingShots.FindIndex(s => s.ShotId == shotId); - var shot = e.Zone.PendingShots[index]; - shot.Tracking = 6f; - e.Zone.PendingShots[index] = shot; - pendingSnapshots.Add(shot); + pendingSnapshots.Add(e.Zone.PendingShots[index]); } // One jink, after every shot is queued, before any of them commit -- pDeviation < 1 for all of them, From 0ece224b0b6a1eab071a9f0efdbb5e70a85e01fe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 03:21:40 +0200 Subject: [PATCH 010/116] Cut 12.2 Soul fix batch 2, S8: an L-shaped aimed item, not just a square Only a 2x2 (BigMarker) aimed item was ever tested -- a square's centroid and its bounding-box centre are the same point, so N11 (aim point = bounding-box centre instead of the true cell-average centroid) survived. AimingAtAnLShaped ItemCentresOnItsCentroidNotItsBoundingBox equips a 3-cell L-tromino (Build's new equipLMarker fixture), fires from a diagonal facing so the shape's true asymmetry projects fully onto the lateral axis, and asserts the empirical mean lateral offset tracks the item's own true centroid (computed by the test) and not its bounding-box centre. Manually verified against N11's own patch: it now fails (empirical -2.139, tracking the wrong bounding-box prediction -2.121, against the true centroid -1.886); the existing BigMarker test is unaffected since a square's two candidate points coincide. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut12Tests.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 829dd0304..6160ad02f 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -533,6 +533,42 @@ public sealed class FireControlCut12Tests : IDisposable $"empirical mean lateral offset {empiricalMeanLateral:F3} does not track the centroid's own lane {expectedA:F3}"); } + // S8 fix batch (Hands, 2026-09-25): the BigMarker test above only ever exercises a 2x2 (square) footprint, + // whose true centroid and bounding-box centre are the same point -- N11 (aim point = bounding-box centre + // instead of the cell average) survived because nothing could tell the two apart. An L-tromino (3 of a + // 2x2's 4 cells) makes them different points: this fixture pins the aim point against the exact centroid + // the test computes itself, and separately checks it against the wrong (bounding-box) candidate to prove + // the fixture actually distinguishes them. + [Fact] + public void AimingAtAnLShapedItemCentresOnItsCentroidNotItsBoundingBox() + { + // A diagonal facing (not axis-aligned) puts the L-shape's actual asymmetry -- its centroid sits off + // the bounding-box centre along the diagonal, not along either grid axis -- fully onto the lateral + // axis, instead of losing most of it to the axis a purely axis-aligned facing would leave unmeasured. + var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: normalize(float2(1, 1)), equipLMarker: true); + Assert.True(e.Shooter.TrySelectTargetItem(e.LMarker)); + + var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + var bearing = normalize(e.Target.ToSchematic(travelDirection)); + var ell = float2(-bearing.y, bearing.x); + var centroid = e.LMarkerCells.Aggregate(float2.zero, (t, c) => t + (float2) c) / e.LMarkerCells.Length; + var boundingBoxCentre = (float2(e.LMarkerCells.Min(c => c.x), e.LMarkerCells.Min(c => c.y)) + + float2(e.LMarkerCells.Max(c => c.x), e.LMarkerCells.Max(c => c.y))) / 2f; + Assert.True(lengthsq(centroid - boundingBoxCentre) > .01f, "fixture: the L-shape's centroid and bounding-box centre must actually differ"); + + var expectedA = dot(centroid, ell); + var wrongA = dot(boundingBoxCentre, ell); + Assert.True(Math.Abs(expectedA - wrongA) > .15f, "fixture: the two candidate aim points must project to distinguishably different lateral values"); + + var outcomes = FireMany(e, 3000); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 300, $"expected a healthy number of hits, got {hits.Count}"); + + var empiricalMeanLateral = hits.Average(o => (double) o.Lateral); + Assert.True(Math.Abs(empiricalMeanLateral - expectedA) < .1, + $"empirical mean lateral offset {empiricalMeanLateral:F3} (bounding-box centre would predict {wrongA:F3}) does not track the true centroid's own lane {expectedA:F3}"); + } + // BroadsideIsEasierThanHeadOn: kills the bearing being ignored and the old max(W,H) bound. A 2x12 hull is // much easier to hit broadside (its long axis facing the shot) than head-on (its short axis facing it). [Fact] From 73c53a93350d21fe78d37335d9a46642cbdd8c58 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 03:24:13 +0200 Subject: [PATCH 011/116] Cut 12.2 Soul fix batch 2, :~483: pin the zero-probability guard CommitProbability's `if (p <= 0f) return 0f;` guards against building a Silhouette for a shot that cannot hit. Stryker's `p < 0` boundary mutant (excluding only p == 0 exactly) survived because nothing exercised the guard at that exact boundary. ZeroProbabilityShotCommitsAMissWithoutBuildingA Silhouette sets a real, fired shot's PFire to 0, calls CommitProbability directly and asserts sil stays the zeroed default (Intervals null, Count 0 -- no Silhouette was built), then resolves the shot through Zone.Update and checks it lands as a clean miss. Manually verified against the exact `p < 0` patch: it now fails (sil.Intervals comes back non-null, a real Silhouette array). Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut12Tests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 6160ad02f..dc6f2d6dc 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -446,6 +446,34 @@ public sealed class FireControlCut12Tests : IDisposable Assert.Equal(expected, p, 4); } + // :~483 fix batch (Hands, 2026-09-25): `if (p <= 0f) return 0f;` in CommitProbability is a guard against + // building a silhouette for a shot that cannot hit -- Stryker's own `p < 0` boundary mutant (excluding only + // the single value p == 0 exactly) survived because nothing exercised the guard at exactly 0. A + // zero-probability shot must commit as a clean miss AND must do so without ever building a real + // Silhouette -- `sil` stays the zeroed default CommitProbability assigns before the early return, not a + // Silhouette with real Intervals/Count/POnHull. + [Fact] + public void ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette() + { + var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f); + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var index = e.Zone.PendingShots.FindIndex(s => s.ShotId == shotId); + var shot = e.Zone.PendingShots[index]; + shot.PFire = 0f; // the exact boundary the `p < 0` mutant would let through unnoticed + e.Zone.PendingShots[index] = shot; + + var p = FireControl.CommitProbability(shot, e.Zone.Time, out var sil); + Assert.Equal(0f, p); + Assert.Null(sil.Intervals); // still the zeroed default -- no Silhouette was ever built + Assert.Equal(0, sil.Count); + + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + e.Zone.Update(.01f); + Assert.NotNull(outcome); + Assert.False(outcome.Hit, "fixture: PFire 0 must commit as a clean miss"); + } + // S4 fix batch (Hands, 2026-09-25): F8 (Soul's earlier fix batch, 2026-09-24) moved range/flightTime // computation ahead of the visibility gate, so Fire's own flight time reflects the real distance even when // PFire prices the shot at 0 -- but nothing behavioural pinned that. N4 (Soul's mutation set: "range after From ca1670a21670085212f7836040acd7ec81abe365 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 03:30:30 +0200 Subject: [PATCH 012/116] Cut 12.2 Soul fix batch 2, S3: measure allocations, correct an overclaiming comment Measured (Release, 7x9 solid hull -- the LonginusX shipped-hull fixture the brief named is not available to this test file): - Lane: 0 B / 1000 calls (was: 64 B/call from Array.Sort's comparer wrapper; now confirmed 0 after the earlier IComparable change in this batch). - Silhouette (caller-supplied buffer): 0 B / 1000 calls. - HitProbability, gated in (a real, non-gated-out call): ~120 B/call. This is NOT from Lane, Silhouette or the comparer -- it comes from Accuracy/ Resolution's own GetBehavior, which walks Entity.Equipment and each item's Behaviors (both ReactiveCollection); that type's enumerator is heap-allocated per foreach. This predates Cut 12.2 and is out of this cut's scope to fix. - Inspect, gated in: ~280 B/call (same cause, more GetBehavior calls). The Forecast comment claiming "the forecast path (HitProbability, Inspect) no longer allocates on a gated-in call" was not true and is corrected to say what was actually measured: Silhouette itself is allocation-free given a pooled buffer, and the residual cost belongs to Entity's own collection choice. LaneAndSilhouetteAllocateNothingGivenAPooledBuffer asserts the true zero-alloc claims exactly and records (with a loose regression bound, not a false zero) the unrelated Entity-level cost on the full HitProbability path. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 10 +++- .../FireControlCut12Tests.cs | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index ced141d24..3f121017f 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -188,9 +188,17 @@ public static class FireControl // the same thing are two places for the same bug to hide and only be found once. // Cost fix batch (Self, 2026-09-24): Forecast reads only sil.POnHull and sil.Span, never sil.Intervals, so // it rents the Silhouette's own scratch array from ArrayPool.Shared and returns it before - // returning -- the forecast path (HitProbability, Inspect) no longer allocates on a gated-in call. + // returning -- Silhouette itself (given this pooled buffer) allocates nothing, measured directly. // Commit's own path (CommitProbability -> Silhouette, uncalled from here) still needs sil.Intervals alive // for the lateral draw that follows it, so it keeps its own owned array; only the forecast pools. + // S3 fix batch (Hands, 2026-09-25) correction: "the forecast path no longer allocates on a gated-in call" + // overclaimed -- a gated-in HitProbability/Inspect call still allocates roughly 120/280 bytes respectively + // (measured, Release), from Accuracy/Resolution's own GetBehavior walking Entity.Equipment and each + // item's Behaviors (both ReactiveCollection, whose enumerator is heap-allocated per foreach), not from + // anything Silhouette or this pooling touches. That cost predates Cut 12.2 and belongs to Entity's own + // collection choice; it is out of this cut's scope. Cut 10's own guard is what actually matters + // operationally: HitProbability and CommitProbability both bail before Forecast is ever called for the + // common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0). private static (Silhouette Silhouette, float PSpread) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range) { var bearing = Bearing(target, TravelDirection(weapon, source, target)); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index dc6f2d6dc..6b1a1a14d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -1214,4 +1214,52 @@ public sealed class FireControlCut12Tests : IDisposable Assert.True(AbsorbsFrom(float2(0, -1))); // stern toward the shot Assert.True(AbsorbsFrom(float2(1, 0))); // beam } + + // S3 fix batch (Hands, 2026-09-25): Lane's own comment ("never allocates on its own") and Silhouette's + // pooled path are pinned directly, instead of trusting the comment. Measured (Release, LonginusX-shaped + // fixture unavailable to this test file -- a 7x9 solid hull is used instead): Lane and Silhouette (given a + // caller-supplied buffer) allocate exactly 0 bytes over 1000 calls, confirming the S3 change itself + // (Array.Sort(T[], int, int) resolving Interval/LaneCell's own IComparable at JIT time, not the + // (T[], int, int, IComparer) overload that wrapped even a cached singleton comparer in a fresh delegate + // per call). A full gated-in HitProbability call is NOT zero-allocation (measured ~120 B/call, Release) -- + // that cost is Accuracy/Resolution's GetBehavior walking Entity's ReactiveCollection fields, whose + // enumerator is heap-allocated per foreach; it predates this cut and is out of its scope (see the corrected + // comment on Forecast). This is recorded as a measurement, not asserted as zero, so the test does not lie + // about what the comment can honestly promise. + [Fact] + public void LaneAndSilhouetteAllocateNothingGivenAPooledBuffer() + { + var e = Build(TestSettings(), SolidShape(7, 9), precision: .5f); + var hull = e.HullData; + var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + var bearing = normalize(e.Target.ToSchematic(travelDirection)); + var sil = FireControl.Silhouette(e.Target, hull, null, bearing, .5f); + var laneBuffer = new LaneCell[hull.Shape.Coordinates.Length]; + var intervalBuffer = new Interval[hull.Shape.Coordinates.Length]; + + for (var i = 0; i < 100; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); // JIT warm-up + for (var i = 0; i < 100; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1000; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); + var laneAllocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(laneAllocated == 0, $"1000 Lane calls allocated {laneAllocated} bytes; \"never allocates\" is not true"); + + before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1000; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); + var silhouetteAllocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(silhouetteAllocated == 0, $"1000 buffered Silhouette calls allocated {silhouetteAllocated} bytes"); + + // Recorded, not asserted zero: the full gated-in HitProbability call still allocates, from Entity's own + // collection enumeration (GetBehavior over ReactiveCollection), not from anything this fix batch + // touches. A loose upper bound catches a real regression (e.g. a reintroduced comparer wrapper) without + // pretending Entity's own cost is Lane's or Silhouette's to fix here. + for (var i = 0; i < 100; i++) FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + before = GC.GetAllocatedBytesForCurrentThread(); + var sum = 0f; + for (var i = 0; i < 1000; i++) sum += FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + var hitProbabilityAllocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(sum > 0f, "fixture: this must be a real, gated-in call, not a gated-out one"); + Assert.True(hitProbabilityAllocated < 500_000, $"1000 gated-in HitProbability calls allocated {hitProbabilityAllocated} bytes -- well above Entity's own ~120 B/call baseline; likely a reintroduced Lane/Silhouette allocation"); + } } From c7b6e80c70838d2ef782edfe0533cd9bf9df5029 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 05:17:10 +0200 Subject: [PATCH 013/116] Cut 12.2 closed: status, what three Soul passes found, and what 12.3 inherits Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index dbaee6897..d4dbacafd 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2371,6 +2371,47 @@ a no-behaviour cut this size folds into 12.2's. factor should read higher from the beam. - Turn your armoured face into a slow missile and watch where it lands. +**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: +`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` +(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. + +What the Soul passes found, in order, because two of them are lessons for 12.3: +- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, + and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so + every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause + found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; + `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration + could follow a turn made after commit; multi-cell aimed items were untested. +- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. + The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result + into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root + cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, + `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the + two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an + `IComparer` in a delegate per sort). +- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the + slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all + cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and + float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration + found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact + arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. + The `Commit` invariant throw is unreachable. + +Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX +from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside +fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an +item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per +`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. + +**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at +b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant +survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of +their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in +`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make +`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through +visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` +import. + ### Cut 12.3. How a direct hit travels - **Repo/branch:** Aetheria, on top of 12.2. From 70c3e0ccdb1385d3fb6cf8aff66be0978853ded5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 05:55:59 +0200 Subject: [PATCH 014/116] Cut 12.3, sub-cut A: armour absorbs first down the direct-hit lane Deletes Entity.DamageSchematic (the even split over the whole hit shape) and Entity.ApplyHit (the 0.5-step penetration march), replacing both with Entity.Absorb (armour then item, per cell) and Entity.DamageHull, moved verbatim from DamageSchematic's own body. FireControl.Apply now builds 2n+1 parallel lanes from the committed Bearing/Lateral (Q12-2 = A), shares damage evenly over the lanes that meet metal, walks each lane in order via the already-verified FireControl.Lane, absorbing cell by cell up to the penetration depth, and sends every lane's own remainder to the hull where it ends (Q12-3 = A). Splash is rewritten onto Absorb/DamageHull (arithmetically identical to before) since DamageSchematic is gone. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/EntityInstance.cs | 6 +- Assets/Scripts/ServerShared/Entity.cs | 109 ++++++++------------- Assets/Scripts/ServerShared/FireControl.cs | 90 ++++++++++++++--- 3 files changed, 118 insertions(+), 87 deletions(-) diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 0e721d733..0c3c35368 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -278,9 +278,9 @@ public class EntityInstance : MonoBehaviour } // Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (Splash, Hit) - // are deleted -- FireControl decides hits now and Entity.ApplyHit/DamageSchematic (ServerShared) apply - // them, the single owner in place of what used to exist once per Unity effect. HullColliders no longer - // publish Hit/Splash at all (R8). + // are deleted -- FireControl decides hits now and applies them through Entity.Absorb/DamageHull + // (ServerShared, Cut 12.3; DamageSchematic and ApplyHit are gone), the single owner in place of what + // used to exist once per Unity effect. HullColliders no longer publish Hit/Splash at all (R8). LookAtPoint = new GameObject($"{entity.Name} Look Point").transform; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index b30177b98..5b95dd6df 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -329,52 +329,55 @@ public abstract class Entity ? TargetItem.Value : null; - // Cut 3 (docs/fire-control-cut.md): the damage rule moved verbatim from EntityInstance.cs's Unity - // HullCollider subscription (DamageSchematic) -- same arithmetic, same order, same thresholds. The only - // thing that changed is the owner: this entity's own schematic, armor and hull, not a Unity component - // reacting to a physics collision. - public void DamageSchematic(float damage, Shape hitShape) + // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- moved verbatim from DamageSchematic's own + // per-cell body (Cut 3), now called once per lane cell (FireControl.Apply) instead of once per hit-shape + // cell with an even split. Armor absorbs up to its own value, then the occupying item once the remainder + // exceeds .1f, and whatever is left over is returned to the caller for the hull's own share (or the next + // cell down the lane, for a lane that keeps carrying a remainder is not this function's concern -- Apply's + // own loop does that). ArmorDamage/ItemDamage fire only for incoming > 0 -- a cell a spent lane still walks + // past (rem already 0) takes no event and no recorded hit. + public float Absorb(int2 cell, float damage) { - var hullData = ItemManager.GetData(Hull) as HullData; - foreach (var v in hitShape.Coordinates) - hitShape[v] = hitShape[v] && hullData.Shape[v]; + var d = damage; - float hullDamage = 0; - var damagePerCell = damage / hitShape.Coordinates.Length; - foreach (var v in hitShape.Coordinates) + if (d > 0f) { - var d = damagePerCell; - - // Subtract surface damage from armor, passing on the remainder to the item and then to the hull - var prev = Armor[v.x, v.y]; - Armor[v.x, v.y] = max(prev - d, 0); - ArmorDamage.OnNext((v, d)); - d = max(d - prev, 0); - - if (d > 0.1f) - { - var item = GearOccupancy[v.x, v.y]; - if (item != null) - { - prev = item.EquippableItem.Durability; - item.EquippableItem.Durability = max(prev - d, 0); - ItemDamage.OnNext((item, d)); - d = max(d - prev, 0); - } - } - - hullDamage += d; + var prevArmor = Armor[cell.x, cell.y]; + Armor[cell.x, cell.y] = max(prevArmor - d, 0); + ArmorDamage.OnNext((cell, d)); + d = max(d - prevArmor, 0); } - if (hullDamage > .1f) + if (d > 0.1f) { - Hull.Durability -= hullDamage; - HullDamage.OnNext(hullDamage); + var item = GearOccupancy[cell.x, cell.y]; + if (item != null) + { + var prevItem = item.EquippableItem.Durability; + item.EquippableItem.Durability = max(prevItem - d, 0); + ItemDamage.OnNext((item, d)); + d = max(d - prevItem, 0); + } + } + + return d; + } + + // Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one + // HullDamage event are unchanged. FireControl.Apply calls this once per resolved hit, with the summed + // remainder every lane's own walk left over (Q12-3 = A: a lane's remainder goes to the hull wherever the + // lane ends -- penetration exhausted, a gap, or the far side). + public void DamageHull(float damage) + { + if (damage > .1f) + { + Hull.Durability -= damage; + HullDamage.OnNext(damage); } } // Cut 12.1 (docs/fire-control-cut.md): the one schematic-frame owner. Maps a world-planar vector into this - // entity's own schematic frame -- x = starboard, y = bow -- so ApplyHit's penetration march and + // entity's own schematic frame -- x = starboard, y = bow -- so FireControl.Apply's lane walk and // FireControl.Splash's directional half both read the same transform instead of each carrying their own // copy of forward/right. public float2 ToSchematic(float2 worldPlanar) @@ -384,40 +387,6 @@ public abstract class Entity return float2(dot(worldPlanar, right), dot(worldPlanar, forward)); } - // Cut 3: the shape construction moved from EntityInstance.cs's Unity HullCollider subscription -- the hit - // cell is the rolled Cell FireControl already chose, not a UV texture coordinate. - // Cut 12.2 (docs/fire-control-cut.md): `bearing` is already in this entity's own schematic frame -- the - // committed ShotOutcome.Bearing FireControl.Commit computed once, at the commit tick -- so the ToSchematic - // call this used to make internally (12.1) is gone; ApplyHit no longer touches a world direction or this - // entity's own Direction at all. - public void ApplyHit(Entity source, int2 cell, float spread, float penetration, float damage, float2 bearing) - { - IncomingHit.OnNext(source); - - var hullData = ItemManager.GetData(Hull) as HullData; - var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); - hitShape[cell] = true; - - for (var i = 0; i < (int) floor(spread + .5f); i++) - hitShape = hitShape.Expand(); - - if (penetration > .5f) - { - var penetrationVector = normalize(bearing); - - var penetrationPoint = (float2) cell + float2(.5f); - var penetrationDistance = 0f; - while (penetrationDistance < penetration && hullData.Shape[int2(penetrationPoint)]) - { - penetrationDistance += .5f; - hitShape[int2(penetrationPoint)] = true; - penetrationPoint += penetrationVector * .5f; - } - } - - DamageSchematic(damage, hitShape); - } - // Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null) // until this entity detects them, mirroring the existing detection model (VisibleEntities, driven // by EntityInfoGathered crossing TargetDetectionInfoThreshold). Detected but not yet hostility-rated diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 3f121017f..5d88219d3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -192,11 +192,15 @@ public static class FireControl // Commit's own path (CommitProbability -> Silhouette, uncalled from here) still needs sil.Intervals alive // for the lateral draw that follows it, so it keeps its own owned array; only the forecast pools. // S3 fix batch (Hands, 2026-09-25) correction: "the forecast path no longer allocates on a gated-in call" - // overclaimed -- a gated-in HitProbability/Inspect call still allocates roughly 120/280 bytes respectively - // (measured, Release), from Accuracy/Resolution's own GetBehavior walking Entity.Equipment and each - // item's Behaviors (both ReactiveCollection, whose enumerator is heap-allocated per foreach), not from - // anything Silhouette or this pooling touches. That cost predates Cut 12.2 and belongs to Entity's own - // collection choice; it is out of this cut's scope. Cut 10's own guard is what actually matters + // overclaimed -- a gated-in HitProbability call still allocates roughly 120 bytes (measured, Release; ~280 + // for Inspect, which reads one more gated stat). 12.3 fix batch correction: that cost is exactly three + // GetBehavior calls (Accuracy, Resolution via PSensor, Precision), at ~40 B each -- each + // one walks Entity.Equipment, a ReactiveCollection whose enumerator is heap-allocated per foreach. It is + // NOT `Behaviors` (the earlier wording here was wrong): EquippedItem.Behaviors is a plain array, allocates + // nothing to enumerate. With an item aimed at, ResolveAimPoint -> CellsOf also allocates (a List, then + // ToArray) -- about 944 B per HitProbability, on top of the three GetBehavior calls. None of this is + // Silhouette's or this pooling's cost; it predates Cut 12.2 and belongs to Entity's own collection choice + // and to CellsOf, out of this cut's scope. Cut 10's own guard is what actually matters // operationally: HitProbability and CommitProbability both bail before Forecast is ever called for the // common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0). private static (Silhouette Silhouette, float PSpread) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range) @@ -583,6 +587,15 @@ public static class FireControl // R4: the commit is authoritative and immutable from here on -- this only performs what Commit already // decided. The one owner of the shield-absorbs-or-hull-takes-it branch (it used to exist seven times, // once per Unity effect). + // Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step + // march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A): + // 2n+1 parallel lanes, one cell apart along the committed bearing's own lateral axis, sharing the shot's + // damage evenly over whichever lanes actually meet metal -- the centre lane, at the committed Lateral, + // always does (Commit already proved it, R3). Each lane is Lane's own contiguous cell run from its own + // facing cell, clipped to the penetration depth (a cell is reached when entry - impact.entry < penetration; + // the impact cell is always reached, replacing the deleted `> .5f` threshold at zero cost). Whatever a + // lane does not spend -- penetration exhausted, a gap, or the far side -- goes to the hull where the lane + // ends (Q12-3 = A), summed across every lane into one DamageHull call. private static void Apply(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -597,10 +610,50 @@ public static class FireControl return; } + var target = shot.Target; + // Cut 12.3: moved here from Entity.ApplyHit's own first line -- the shield branches above still return + // before this runs, unchanged from 12.2. + target.IncomingHit.OnNext(shot.Source); + // Cut 12.2 (R4): the committed geometry, frozen. Apply reads no position or facing of its own -- // shot.Outcome.Bearing is already the schematic-frame bearing Commit computed at the commit tick, so // turning the target after commit changes nothing here (TurningAfterCommitChangesNothing). - shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, shot.Damage, shot.Outcome.Bearing); + var hullData = target.ItemManager.GetData(target.Hull) as HullData; + var bearing = shot.Outcome.Bearing; + var n = (int) floor(shot.DamageSpread + .5f); // today's rounding, Entity.cs:389 before this cut + var laneCount = 2 * n + 1; + + var buffers = new LaneCell[laneCount][]; + var walked = new int[laneCount]; + var metalLanes = 0; + for (var li = 0; li < laneCount; li++) + { + buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + walked[li] = Lane(hullData, bearing, shot.Outcome.Lateral + (li - n), buffers[li]); + if (walked[li] > 0) metalLanes++; + } + + // The centre lane (li == n) always meets metal: it walks from the committed Lateral, and Commit's own + // guard already proved Lane finds an occupied cell there (R3) -- metalLanes is never 0. + var damagePerLane = shot.Damage / metalLanes; + var hullDamage = 0f; + for (var li = 0; li < laneCount; li++) + { + if (walked[li] > 0) + { + var rem = damagePerLane; + var impactEntry = buffers[li][0].Entry; + for (var i = 0; i < walked[li]; i++) + { + if (i > 0 && buffers[li][i].Entry - impactEntry >= shot.Penetration) break; + rem = target.Absorb(buffers[li][i].Cell, rem); + } + hullDamage += rem; + } + ArrayPool.Shared.Return(buffers[li]); + } + + target.DamageHull(hullDamage); } private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) @@ -626,12 +679,16 @@ public static class FireControl // shot. Nothing here draws: a mine or an airburst round always damages everything it catches, the same // as the Physics.OverlapSphere queries this replaces always did. Applies the shield-absorb-or-hull-takes- // it branch (F5, docs/stats-and-power-cut.md) per target, then -- for a target the shield didn't fully - // absorb -- DamageSchematic over the directional half of that target's own hull that faces the blast, the - // rule moved verbatim from the Splash subscription EntityInstance.cs carried before Cut 3 deleted it - // (git show b7743789^:Assets/Scripts/Gameplay/EntityInstance.cs), made planar (R7): the blast-to-target - // direction is measured in the zone's (x,z) plane and rotated into each target's own facing by its - // Direction, the same rotation Entity.ApplyHit's penetration march uses, in place of a Unity - // InverseTransformDirection. + // absorb -- an even split (still today's rule; 12.3 only retired the direct-hit lane's even split, not + // Splash's) over the directional half of that target's own hull that faces the blast, the rule moved + // verbatim from the Splash subscription EntityInstance.cs carried before Cut 3 deleted it (git show + // b7743789^:Assets/Scripts/Gameplay/EntityInstance.cs), made planar (R7): the blast-to-target direction is + // measured in the zone's (x,z) plane and rotated into each target's own facing by its Direction, the same + // rotation Entity's own schematic frame (ToSchematic) uses. + // Cut 12.3: Entity.DamageSchematic is deleted with the direct-hit march it shared a file with -- Splash + // now calls Entity.Absorb per footprint cell directly and sums the leftover into one DamageHull call, + // arithmetically identical to DamageSchematic's own body (same per-cell split, same order, same + // thresholds). Untouched otherwise; deleted in 12.4 along with the rest of the ray model. public static void Splash(Zone zone, float3 position, float radius, float damage, DamageType damageType) { foreach (var target in zone.Entities) @@ -659,7 +716,12 @@ public static class FireControl if (dot(normalize((float2) v - hullData.Shape.CenterOfMass), localDirection) < 0) hitShape[v] = true; - target.DamageSchematic(damage, hitShape); + var cells = hitShape.Coordinates; + var damagePerCell = damage / cells.Length; + var hullDamage = 0f; + foreach (var v in cells) + hullDamage += target.Absorb(v, damagePerCell); + target.DamageHull(hullDamage); } } @@ -868,7 +930,7 @@ public static class FireControl } // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source - // of truth for where an equipped item's footprint lands, the same table Entity.DamageSchematic reads. + // of truth for where an equipped item's footprint lands, the same table Entity.Absorb reads. private static int2[] CellsOf(Entity target, EquippedItem item) { var hullData = target.ItemManager.GetData(target.Hull) as HullData; From 3b6b9e4a285489e45dd1bf915075b01c905c99c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 05:56:11 +0200 Subject: [PATCH 015/116] Cut 12.3: drop two Cut 12.3 tests that called the deleted Entity.ApplyHit HardpointHitDamagesItemThenHull and PenetrationMarchIsPlanar both called Entity.ApplyHit directly, which Cut 12.3 deletes. The rules they pinned: "damage above armour plus item durability zeroes the item and passes the remainder to the hull" is now FireControlCut123Tests.DamageIsAbsorbedInOrderAlongTheRay, through the real Fire/Commit/Apply path. "The penetration march is planar, in the target's own schematic frame" is subsumed by 12.2's own HitsLandOnTheFacingEdge and TurningArmourIntoTheShotTakesItOnTheArmour (which already exercise FireControl.Apply's committed, schematic-frame bearing end to end) and by FireControlCut123Tests.ArmourFacesTheShot, which pins planarity across several non-axis-aligned facings. Co-Authored-By: Claude Opus 5.5 --- .../FireAuthorityTests.cs | 68 +++---------------- 1 file changed, 10 insertions(+), 58 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 7ff47aa53..3d3568b93 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -488,64 +488,16 @@ public sealed class FireAuthorityTests : IDisposable Assert.True(aimedItem.EquippableItem.Durability < 1f); } - // The damage rule moved verbatim (Entity.DamageSchematic/ApplyHit): damage above armor plus item - // durability zeroes the item and the remainder hits the hull. Mutation: skip subtracting armor, or skip - // passing the remainder on to the hull. - [Fact] - public void HardpointHitDamagesItemThenHull() - { - var cell = new int2(1, 1); - EquippableItem occupant = null; - var e = Build(TestSettings(), armor: 2, hullDurability: 1000, - beforeActivate: (items, shooter, target) => - { - // No item occupies (1,1) in this fixture's hull -- occupy it before Activate() so the - // item-then-hull chain has an item to pass through at a controlled cell. - var occupantRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); - occupant = new EquippableItem { Data = occupantRef, Durability = 3, Lot = 6 }; - Assert.True(target.TryEquip(occupant, cell)); - }); - var occupantItem = e.Target.Equipment.Single(x => x.EquippableItem == occupant); - - var beforeHull = e.Target.Hull.Durability; - // Cut 12.2 (docs/fire-control-cut.md): ApplyHit's direction parameter is now an already-schematic - // bearing (the caller's job, formerly ApplyHit's own internal ToSchematic call) -- unread here anyway, - // since penetration 0 never enters the march. - e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 0, damage: 10, bearing: float2(0, 1)); - - Assert.Equal(0f, e.Target.Armor[cell.x, cell.y]); // 2 armor consumed - Assert.Equal(0f, occupantItem.EquippableItem.Durability); // 3 item durability consumed - Assert.Equal(beforeHull - 5f, e.Target.Hull.Durability, 2); // remaining 5 hits the hull - } - - // R7: the penetration march is planar, in the target's own schematic frame. Firing straight into a - // target's nose marches the hit shape toward the target's stern, along +Y in this hull's local grid - // regardless of which way the entity happens to be facing in world space. - // Cut 12.2 (docs/fire-control-cut.md): the world-to-schematic rotation this test pinned moved out of - // ApplyHit and into its caller (FireControl.Commit computes `bearing = target.ToSchematic(travelDirection)` - // once, at the commit tick, and passes the already-rotated bearing in) -- ApplyHit itself no longer reads - // Direction at all. This test now performs that same rotation itself, at the call site, to keep pinning - // the march's own planar behaviour once a schematic bearing is in hand; the end-to-end claim that - // Direction feeds the rotation correctly is what FireControlCut12Tests.HitsLandOnTheFacingEdge and - // TurningArmourIntoTheShotTakesItOnTheArmour now cover, through FireControl.Commit itself. - [Fact] - public void PenetrationMarchIsPlanar() - { - var e = Build(TestSettings(), armor: 5); - e.Target.Direction = float2(1, 0); // facing world +X, not the default +Z - - var cell = new int2(1, 0); - var hitDirection = normalize(float2(1, 0)); // the shot arrived travelling along the target's own forward - var bearing = e.Target.ToSchematic(hitDirection); - e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 1.5f, damage: 30, bearing: bearing); - - // Rotating hitDirection into this Direction's frame (forward=(1,0), right=(0,-1)) makes the local - // penetration vector (0,1): the march should have advanced from (1,0) into (1,1), consuming that - // cell's armor too. Mutation: skip the rotation and march along the raw world hitDirection (1,0) - // instead, which would consume (2,0) rather than (1,1). - Assert.True(e.Target.Armor[1, 1] < e.Target.MaxArmor[1, 1]); - Assert.Equal(e.Target.MaxArmor[2, 0], e.Target.Armor[2, 0]); // untouched -- proves it wasn't a world-space march - } + // HardpointHitDamagesItemThenHull and PenetrationMarchIsPlanar (Entity.ApplyHit/DamageSchematic, deleted by + // Cut 12.3 -- docs/fire-control-cut.md) pinned two rules that no longer have that function to call: + // - "damage above armor plus item durability zeroes the item and the remainder hits the hull" is now + // FireControlCut123Tests.DamageIsAbsorbedInOrderAlongTheRay, through the real Fire/Commit/Apply path + // rather than a direct ApplyHit call. + // - "the penetration march is planar, in the target's own schematic frame" is subsumed by 12.2's own + // FireControlCut12Tests.HitsLandOnTheFacingEdge and TurningArmourIntoTheShotTakesItOnTheArmour (the + // bearing FireControl.Apply's lane walk reads is already the committed, schematic-frame bearing Commit + // computed, the same one those tests pin end-to-end) and by FireControlCut123Tests.ArmourFacesTheShot, + // which pins that the lane's own facing cell is planar across several non-axis-aligned facings. // The absorb rule now exists once, in FireControl: an active shield that CanTakeHit absorbs, and the // schematic is untouched. Mutation: remove the shield branch (the hit always reaches the hull). From 3fe66f555efde48e83dad7f802f3beb74bfb5813 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 05:56:30 +0200 Subject: [PATCH 016/116] Cut 12.2 carried items: pin Lane's seam contract and AlongBearing's values Items carried into 12.3's brief from 12.2's third Soul pass, all on code 12.3's own lane walk builds on: - LaneSeamIsHalfOpen pins the s >= Hi admission test (FireControl.cs) on the smallest fixture where the seam matters -- the s > Hi mutant that survived 12.2 would have interleaved two columns at a shared seam once 12.3's walk started reading more than buffer[0]. - AlongBearingReturnsTheExactSlabIntersection gives AlongBearing/SlabAxis their first test of actual VALUES (every mutant there survived in 12.2, equivalent while only buffer[0].Cell was read) -- a diagonal fixture checks Lane's own Entry/Exit against an independent slab solve, cell for cell, in order. - Corrected the allocation comments on Forecast and LaneAndSilhouetteAllocateNothingGivenAPooledBuffer: the ~120 B/call on a gated-in HitProbability is three GetBehavior calls (not EquippedItem.Behaviors, which is a plain array), plus CellsOf's ~944 B when an item is aimed at; noted that the loose <500_000 bound doesn't catch a reintroduced comparer wrapper but the buffered zero-allocation asserts do. - ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette is now behavioural: PFire 0 comes from closing VisibleEntities (the real gate), and "no Silhouette was built" is checked by asserting zero allocation rather than reading CommitProbability's own struct default. - Dropped the unused System.Reflection import. Also replaces ExpectedMarchCells, which reproduced Entity.ApplyHit's own deleted 0.5-step march, with ExpectedLaneCells, built on the already-verified FireControl.Lane plus an independent penetration cutoff -- used by TurningAfterCommitChangesNothing, whose fixture also gets real per-cell armour (the flat 1000 armour every Build() authors would let the new sequential Absorb model's first cell alone swallow the whole 5-damage shot, leaving nothing to show on the rest of a several-cell lane). Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut12Tests.cs | 187 ++++++++++++++---- 1 file changed, 148 insertions(+), 39 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 6b1a1a14d..b533a16d0 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection; using CultMath; using GameCult.Caching; using UniRx; @@ -448,24 +447,36 @@ public sealed class FireControlCut12Tests : IDisposable // :~483 fix batch (Hands, 2026-09-25): `if (p <= 0f) return 0f;` in CommitProbability is a guard against // building a silhouette for a shot that cannot hit -- Stryker's own `p < 0` boundary mutant (excluding only - // the single value p == 0 exactly) survived because nothing exercised the guard at exactly 0. A - // zero-probability shot must commit as a clean miss AND must do so without ever building a real - // Silhouette -- `sil` stays the zeroed default CommitProbability assigns before the early return, not a - // Silhouette with real Intervals/Count/POnHull. + // the single value p == 0 exactly) survived because nothing exercised the guard at exactly 0. + // 12.3 fix batch correction: the previous version of this test set `shot.PFire = 0f` on a pending shot by + // hand, then asserted `sil.Intervals == null` -- a shape assertion on CommitProbability's own struct + // default, not a behaviour a bug could actually violate (any caller reading `sil` after a real miss reads + // the same default regardless of whether a Silhouette was built and discarded). Made behavioural per the + // 12.2 postmortem: PFire == 0 is now produced through the real gate (closing the shooter's own + // VisibleEntities, the same way S4's InvisibleTargetStillResolvesAtItsRealFlightTime does), and the claim + // "no Silhouette was ever built" is checked the only way that is actually true of a function's own + // behaviour: it allocates nothing. The `p < 0` mutant lets p == 0 fall through to build a real Silhouette + // (Array.Sort, ResolveAimPoint, an owned Interval[]), which is not zero-allocation -- this test kills it + // the same way S3's own zero-allocation pins do, not by re-reading a field the mutant never touches. [Fact] public void ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette() { var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f); - var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var index = e.Zone.PendingShots.FindIndex(s => s.ShotId == shotId); - var shot = e.Zone.PendingShots[index]; - shot.PFire = 0f; // the exact boundary the `p < 0` mutant would let through unnoticed - e.Zone.PendingShots[index] = shot; + e.Shooter.VisibleEntities.Remove(e.Target); // closes the real gate -- PFire == 0 through the actual path + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Equal(0f, shot.PFire); // fixture precondition: the gate is actually closed + + for (var i = 0; i < 50; i++) FireControl.CommitProbability(shot, e.Zone.Time, out _); // JIT warm-up + + var before = GC.GetAllocatedBytesForCurrentThread(); var p = FireControl.CommitProbability(shot, e.Zone.Time, out var sil); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.Equal(0f, p); - Assert.Null(sil.Intervals); // still the zeroed default -- no Silhouette was ever built Assert.Equal(0, sil.Count); + Assert.Equal(0, allocated); // no Silhouette was built: Array.Sort/ResolveAimPoint never ran ShotOutcome outcome = null; using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); @@ -771,39 +782,133 @@ public sealed class FireControlCut12Tests : IDisposable Assert.True(failures.Count == 0, $"{failures.Count} empty lanes at an interval edge, e.g.: {string.Join("; ", failures.Take(5))}"); } - // Entity.ApplyHit's own penetration march, replicated independently (not by calling ApplyHit or reading - // its source beyond the documented convention 12.2 explicitly leaves untouched: cell centres at - // +.5, .5-unit steps, hullData.Shape[int2(point)] as the stop condition). Used to compute, from a frozen - // Bearing, the full set of cells a march would consume -- independent of which bearing (committed or - // live) the test feeds it. - private static HashSet ExpectedMarchCells(HullData hull, int2 cell, float2 bearing, float penetration) + // Carried into 12.3's brief (12.2 Soul pass 3): pins Lane's own seam contract directly, against a 2x1 hull + // -- the smallest fixture where a seam actually falls between two occupied cells. At b=(0,1), ell=(-1,0), + // so cell (0,0)'s shadow is [-.5,.5) and cell (1,0)'s is [.5,1.5): s=-0.5 must land only in (0,0)'s column, + // and s=0.5 (the shared seam, exclusive on (0,0)'s side and where (1,0)'s own interval starts) must find + // NOTHING here, because this fixture's hull is only 1 cell wide at y=0 -- there is no (1,0). Mutation: the + // `s >= lateral.Hi` admission test at FireControl.cs:~817 flipped to `s > lateral.Hi` would let s=0.5 + // through as still touching (0,0) (0.5 is not > 0.5), which on a hull that DID have a second column would + // interleave the two columns' lanes at the seam -- reproduced directly here as "an empty lane's cell count + // is wrong", not by requiring the second column to exist. + [Fact] + public void LaneSeamIsHalfOpen() { - var cells = new HashSet { cell }; - if (penetration <= .5f) return cells; - var vector = normalize(bearing); - var point = (float2) cell + float2(.5f); - var distance = 0f; - while (distance < penetration && hull.Shape[int2(point)]) + var shape = new Shape(1, 1) { [new int2(0, 0)] = true }; + var hull = new HullData { Name = "Seam", HullType = HullType.Ship, Shape = shape }; + var b = float2(0, 1); + var buffer = new LaneCell[shape.Coordinates.Length]; + + var atLo = FireControl.Lane(hull, b, -0.5f, buffer); + Assert.Equal(1, atLo); + Assert.Equal(new int2(0, 0), buffer[0].Cell); + + var atHi = FireControl.Lane(hull, b, 0.5f, buffer); + Assert.Equal(0, atHi); // the seam itself: half-open on the Hi side, so this cell's own shadow excludes it + } + + // Carried into 12.3's brief (12.2 Soul pass 3): AlongBearing/SlabAxis had no test of their own VALUES -- + // every mutant there survived in 12.2 because only buffer[0]'s Cell was ever read (S1's own shadow + // prefilter, Extent, decided admission; AlongBearing's entry/exit were computed but unchecked). 12.3's own + // walk (FireControl.Apply) reads Entry for penetration and ordering, so a wrong entry/exit here is now a + // wrong damage cell, not dead arithmetic. Fixture: a bearing that crosses several cells diagonally (neither + // axis-aligned), so entry ordering and contiguity both matter -- this test computes each admitted cell's + // own exact slab intersection independently (the same two-axis solve AlongBearing/SlabAxis perform, written + // out fresh here rather than called), and checks Lane's own Entry/Exit against it, cell for cell, in order. + [Fact] + public void AlongBearingReturnsTheExactSlabIntersection() + { + var shape = SolidShape(5, 5); + var hull = new HullData { Name = "Diagonal", HullType = HullType.Ship, Shape = shape }; + var b = normalize(float2(2, 1)); // neither axis-aligned nor 45 degrees + var ell = float2(-b.y, b.x); + var s = 0.13f; // off-centre, so the crossed cells are not symmetric about any axis + + (float Entry, float Exit) ExactSlab(int2 c) { - distance += .5f; - cells.Add(int2(point)); - point += vector * .5f; + var entry = float.NegativeInfinity; + var exit = float.PositiveInfinity; + void Axis(float bAxis, float lo, float hi, float val) + { + if (Math.Abs(bAxis) < 1e-9f) return; + var t1 = (lo - val) / bAxis; + var t2 = (hi - val) / bAxis; + entry = Math.Max(entry, Math.Min(t1, t2)); + exit = Math.Min(exit, Math.Max(t1, t2)); + } + Axis(b.x, c.x - .5f, c.x + .5f, s * ell.x); + Axis(b.y, c.y - .5f, c.y + .5f, s * ell.y); + if (entry > exit) exit = entry; + return (entry, exit); } - return cells; + + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; // Extent's own half-width -- NOT .5 off-axis + var expectedCells = shape.Coordinates + .Select(c => (Cell: c, Centre: dot((float2) c, ell))) + .Where(t => s >= t.Centre - h && s < t.Centre + h) // Extent's own half-open shadow + .Select(t => t.Cell) + .OrderBy(c => dot((float2) c, b)) + .ThenBy(c => c.x).ThenBy(c => c.y) + .ToList(); + Assert.True(expectedCells.Count >= 4, $"fixture: this bearing/offset must cross several cells, got {expectedCells.Count}"); + + var buffer = new LaneCell[shape.Coordinates.Length]; + var count = FireControl.Lane(hull, b, s, buffer); + var actualCells = Enumerable.Range(0, count).Select(i => buffer[i].Cell).ToList(); + + Assert.Equal(expectedCells, actualCells); // same cells, same order -- entry ordering and contiguity agree + + for (var i = 0; i < count; i++) + { + var (expectedEntry, expectedExit) = ExactSlab(buffer[i].Cell); + Assert.Equal(expectedEntry, buffer[i].Entry, 3); + Assert.Equal(expectedExit, buffer[i].Exit, 3); + } + } + + // Cut 12.3: replaces ExpectedMarchCells, which reproduced Entity.ApplyHit's own deleted 0.5-step march -- + // that rule is gone with the function. The rule this now pins is the NEW thing FireControl.Apply's lane + // walk adds on top of Lane (Lane's own cell set/order/entry values are independently pinned elsewhere, by + // AlongBearingReturnsTheExactSlabIntersection and the LaneNeverEmptiesAtAnIntervalEdge sweep): the + // penetration cutoff. So this calls the real, already-verified FireControl.Lane for the walked cells and + // their entry parameters, then applies the cutoff independently -- kept only while within `penetration` + // cells of the first (impact) cell's own entry; the impact cell is always kept, matching the deleted + // `> .5f` threshold's intent at zero cost. + private static List ExpectedLaneCells(HullData hull, float2 bearing, float lateral, float penetration) + { + var buffer = new LaneCell[hull.Shape.Coordinates.Length]; + var walked = FireControl.Lane(hull, bearing, lateral, buffer); + + var result = new List(); + if (walked == 0) return result; + var impactEntry = buffer[0].Entry; + for (var i = 0; i < walked; i++) + { + if (i > 0 && buffer[i].Entry - impactEntry >= penetration) break; + result.Add(buffer[i].Cell); + } + return result; } // TurningAfterCommitChangesNothing (R4, closes L464/L465): after commit, turning the target and moving the // shooter must not change where the damage actually lands -- Apply reads no live position or facing. - // F5 (Soul's fix batch, 2026-09-24): the original fixture fired with Penetration 0, so ApplyHit's march - // (Entity.cs, gated at `penetration > .5f`) never ran and never actually read the bearing Apply passed it - // -- a mutant that fed Apply a live (post-turn) bearing instead of the committed one survived (M2a) because - // nothing downstream of Cell ever consumed Bearing. Penetration 3 marches several cells deep, so the full - // damaged-cell set is asserted against the committed Bearing/Lateral, not the live one. + // F5 (Soul's fix batch, 2026-09-24): the original fixture fired with Penetration 0, so the penetration march + // never ran and never actually read the bearing Apply passed it -- a mutant that fed Apply a live + // (post-turn) bearing instead of the committed one survived (M2a) because nothing downstream of Cell ever + // consumed Bearing. Penetration 3 walks the lane several cells deep (Cut 12.3: FireControl.Apply's own lane + // walk, replacing the deleted Entity.ApplyHit march), so the full damaged-cell set is asserted against the + // committed Bearing/Lateral, not the live one. + // Cut 12.3 fix: this fixture's hull carries the flat 1000 armour every Build() call authors, and the Gun's + // own Damage is a fixed 5 -- fine for the old even-split model (every cell in the march took an + // undetectable-but-nonzero 5/N share), fatal for the new sequential one, where the very first (impact) cell + // alone would absorb the whole 5 and leave nothing for the rest of a several-cell-deep lane to show. Lowered + // to 0.05 per cell here so 5 damage plainly carries the full penetration-3 span. [Fact] public void TurningAfterCommitChangesNothing() { var settings = TestSettings(); var e = Build(settings, SolidShape(7, 7), precision: .5f, velocity: 20, penetration: 3); // 5s flight, commits at 4.5s + foreach (var v in e.HullData.Shape.Coordinates) { e.Target.Armor[v.x, v.y] = 0.05f; e.Target.MaxArmor[v.x, v.y] = 0.05f; } ShotOutcome committed = null; using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); @@ -821,7 +926,7 @@ public sealed class FireControlCut12Tests : IDisposable var expectedCell = NearestOccupiedCellAtLateral(e.HullData, committed.Bearing, committed.Lateral); Assert.Equal(expectedCell, committed.Cell); // sanity: production's own Lane agrees with the independent recompute - var expectedFromCommitted = ExpectedMarchCells(e.HullData, committed.Cell, committed.Bearing, 3f); + var expectedFromCommitted = ExpectedLaneCells(e.HullData, committed.Bearing, committed.Lateral, 3f).ToHashSet(); var before = (float[,]) e.Target.Armor.Clone(); @@ -837,14 +942,14 @@ public sealed class FireControlCut12Tests : IDisposable // hand-rolled copy of the frame math. var liveTravelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); var wrongBearing = normalize(e.Target.ToSchematic(liveTravelDirection)); - var expectedFromLive = ExpectedMarchCells(e.HullData, committed.Cell, wrongBearing, 3f); + var expectedFromLive = ExpectedLaneCells(e.HullData, wrongBearing, committed.Lateral, 3f).ToHashSet(); Assert.NotEqual(expectedFromCommitted, expectedFromLive); e.Zone.Update(2f); // past arrival (5s) foreach (var cell in expectedFromCommitted) Assert.True(e.Target.Armor[cell.x, cell.y] < before[cell.x, cell.y], - $"cell {cell} is on the committed march and must have taken damage, regardless of the post-commit turn"); + $"cell {cell} is on the committed lane and must have taken damage, regardless of the post-commit turn"); foreach (var cell in expectedFromLive) if (!expectedFromCommitted.Contains(cell)) @@ -1222,10 +1327,14 @@ public sealed class FireControlCut12Tests : IDisposable // (Array.Sort(T[], int, int) resolving Interval/LaneCell's own IComparable at JIT time, not the // (T[], int, int, IComparer) overload that wrapped even a cached singleton comparer in a fresh delegate // per call). A full gated-in HitProbability call is NOT zero-allocation (measured ~120 B/call, Release) -- - // that cost is Accuracy/Resolution's GetBehavior walking Entity's ReactiveCollection fields, whose - // enumerator is heap-allocated per foreach; it predates this cut and is out of its scope (see the corrected - // comment on Forecast). This is recorded as a measurement, not asserted as zero, so the test does not lie - // about what the comment can honestly promise. + // 12.3 fix batch correction: that cost is exactly three GetBehavior calls (Accuracy, + // Resolution, Precision, ~40 B each), each walking Entity.Equipment (a ReactiveCollection, heap-allocated + // enumerator per foreach) -- NOT `Behaviors` (a plain array, the earlier wording here was wrong; see the + // corrected comment on Forecast). It predates this cut and is out of its scope. The loose `< 500_000` bound + // below is a regression trip-wire, not a proof: a reintroduced per-call IComparer wrapper (~184 B/call) + // would pass it easily. The two zero-allocation asserts just above are what actually catch that regression + // -- Lane and Silhouette are exactly where such a wrapper would reappear -- so the bound here only needs to + // catch something far larger, such as a reintroduced unpooled Interval[]/LaneCell[] on this path. [Fact] public void LaneAndSilhouetteAllocateNothingGivenAPooledBuffer() { From 7f1fde8e71eb406427409446b8ba6d6c30f1d99e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 05:56:45 +0200 Subject: [PATCH 017/116] Cut 12.3: verification for the direct-hit lane's own new rules DamageIsAbsorbedInOrderAlongTheRay, ArmourFacesTheShot, BuriedCockpitNeedsPenetration, SpreadWidensTheImpactAcrossLanes, ARayThroughACornerCrossesTheCornerCell and LaneRemainderReachesTheHull, per docs/fire-control-cut.md's Cut 12.3 verification list. Builds its own fixtures (the file's own Build), matching every other cut's own test file. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut123Tests.cs | 449 ++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs new file mode 100644 index 000000000..6b75f67a5 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -0,0 +1,449 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): a resolved hit's damage now travels down +// its own lane -- armour, then the occupying item, then whatever is left to the hull, in order -- replacing +// Entity.DamageSchematic's even split and Entity.ApplyHit's 0.5-step march (both deleted by this cut). Builds +// its own fixtures, the convention every cut's test file follows. Every armour value this file cares about is +// set directly on Entity.Armor after Build, overriding the fixture's own flat HullData.Armor -- the only way +// to get a non-uniform plate without a second hull-authoring path. +public sealed class FireControlCut123Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut123-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut123Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = .3f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public HullData HullData; + public EquippedItem[] Markers; + public EquippedItem Cockpit; + } + + // A shooter off the origin (Cut 11.3), a target over a caller-supplied hull shape, straight ahead so the + // default facing (0,1) makes the shot travel exactly (0,1) in world XZ (velocity 0 -- PredictedIntercept + // returns target.Position exactly, and the shooter/target share the same world X) -- the same clean + // geometry FireControlCut12Tests' own SigmaFloor fixture relies on. A full-strength targeting system + // (Accuracy 1, Resolution huge) so only the fixture's own geometry decides the numbers. + private Engagement Build( + GameplaySettings settings, Shape hullShape, float precision, + float penetration = 0, float damageSpread = 0, float2? targetFacing = null, + (int2 Cell, float Durability)[] markers = null, int2? cockpitCell = null, float cockpitDurability = 40) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000, Armor = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(5), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(damageSpread), + Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f) + } } + }); + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new GearData + { + Name = "Cockpit", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new CockpitData() } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 24; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut123", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var lot = 1; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var gun = Make("Gun", lot++, 1); + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + + var targetGun = Make("Gun", lot++, 1); + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + var markerItems = markers?.Select(m => + { + var mi = Make("Marker", lot++, m.Durability); + Assert.True(target.TryEquip(mi, m.Cell), $"marker must fit at {m.Cell}"); + return target.Equipment.Single(x => x.EquippableItem == mi); + }).ToArray(); + + EquippedItem cockpit = null; + if (cockpitCell != null) + { + var ci = Make("Cockpit", lot++, cockpitDurability); + Assert.True(target.TryEquip(ci, cockpitCell.Value), $"cockpit must fit at {cockpitCell.Value}"); + cockpit = target.Equipment.Single(x => x.EquippableItem == ci); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + // Cut 11.3: the shooter sits off the origin. + shooter.Position = float3(37, 0, -11); + target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter -- travel direction is exactly (0,1) + if (targetFacing != null) target.Direction = targetFacing.Value; + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(1f); // fire times nonzero (Cut 11.3) + + return new Engagement + { + Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, + HullData = hullData, Markers = markerItems, Cockpit = cockpit + }; + } + + // Fires until a hit lands, retrying with a fresh shot each time (a miss already resolved and left the + // pending queue empty) -- the roll can still miss outright, and none of these tests care which attempt + // actually connects. + private static ShotOutcome FireUntilHit(Engagement e, float? damageOverride = null, int attempts = 60) + { + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + for (var i = 0; i < attempts && (outcome == null || !outcome.Hit); i++) + { + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride); + e.Zone.Update(.01f); // velocity 0 -> zero flight time, commits and resolves in this tick + } + return outcome; + } + + // DamageIsAbsorbedInOrderAlongTheRay replaces FireAuthorityTests.HardpointHitDamagesItemThenHull and + // PenetrationMarchIsPlanar (both called the deleted Entity.ApplyHit directly). A 3x3 solid hull's only + // interior cell (1,1) -- Shape.Shrink needs all 8 neighbours present -- carries a Tool item, so the marker + // must sit on the centre column; every cell of that column shares the SAME lateral shadow (ell has only an + // x component at this bearing, and every column-1 cell has x=1), so once a shot's committed Lateral falls + // in that column's own interval, its lane is the full 3-cell column in near-to-far order -- (1,0) is always + // the impact cell there, regardless of precision. Retries until a hit lands on that column (the other two + // columns' own shadows are adjacent, not overlapping, so this is just "which column the draw picked", not a + // low-probability corner). Damage 30, penetration 3: a 10-armour front cell, then the marker, then a + // 50-armour soft cell the remainder never reaches. + // Kills: an even split (would put damage/3 = 10 on every cell, exactly canceling the front armour by + // coincidence but consuming only 10 of the marker's durability and 10 off the soft cell's -- distinguishing + // both); items absorbing before armour (irrelevant here since the front cell carries no item, but the + // marker cell's own Absorb call still checks armour(0) before the item); no carry-forward (would re-deal + // the full 30 fresh to the marker instead of the 20 that survived the front cell, and would put 30 on the + // soft cell instead of 0). + [Fact] + public void DamageIsAbsorbedInOrderAlongTheRay() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: 3f, + markers: new[] { (new int2(1, 1), 1000000f) }); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[1, 0] = 10f; e.Target.MaxArmor[1, 0] = 10f; // front: a plate + e.Target.Armor[1, 2] = 50f; e.Target.MaxArmor[1, 2] = 50f; // soft cell: armoured but never reached + + var marker = e.Markers[0]; + var markerDurabilityBefore = marker.EquippableItem.Durability; + var hullBefore = e.Target.Hull.Durability; + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 1); attempt++) + outcome = FireUntilHit(e, damageOverride: 30f, attempts: 1); + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit on the centre column within the attempt budget"); + Assert.Equal(new int2(1, 0), outcome.Cell); // fixture precondition: (1,0) is always this column's impact cell + + Assert.Equal(0f, e.Target.Armor[1, 0]); // 10 armour fully consumed + Assert.Equal(markerDurabilityBefore - 20f, marker.EquippableItem.Durability, 2); // the carried-forward 20 + Assert.Equal(50f, e.Target.Armor[1, 2]); // untouched -- the remainder never reached it + Assert.Equal(hullBefore, e.Target.Hull.Durability); // nothing left over for the hull + } + + // ArmourFacesTheShot: only the bow half (y >= height/2) of a 1x9 hull carries a plate, penetration 0 so only + // the impact cell can ever record a hit. For each of three facings, including one where |fx| > |fy|, the + // shooter is repositioned so the shot's own TravelDirection is EXACTLY that facing -- the same relationship + // FireControlCut12Tests.TurningArmourIntoTheShotTakesItOnTheArmour uses (Direction = travelDirection exposes + // the stern; Direction = -travelDirection exposes the bow), just repeated at three different WORLD + // directions instead of one, so the bearing this produces is exactly (0,1) or (0,-1) in schematic space + // every time (R7: the schematic frame is planar for any world facing, not only the default one) -- with no + // diagonal bearing in play, there is no lane-selection ambiguity to fight, only Entity.ToSchematic's own + // rotation to check. + [Fact] + public void ArmourFacesTheShot() + { + var shape = SolidShape(1, 9); + var e = Build(TestSettings(), shape, precision: .4f, penetration: 0f); + for (var y = 0; y < shape.Height; y++) + { + e.Target.Armor[0, y] = y >= 5 ? 1f : 0f; + e.Target.MaxArmor[0, y] = e.Target.Armor[0, y]; + } + + void CheckOneDirection(float2 travelDirection, bool bowFacesShot) + { + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + e.Target.Direction = bowFacesShot ? -travelDirection : travelDirection; + + var actualTravel = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + Assert.Equal(travelDirection.x, actualTravel.x, 3); // fixture precondition + Assert.Equal(travelDirection.y, actualTravel.y, 3); + + var hitCells = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hitCells.Add(x.pos)); + for (var i = 0; i < 60; i++) { FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } + + Assert.True(hitCells.Count > 5, $"expected several hits for travel direction {travelDirection}, bow facing {bowFacesShot}, got {hitCells.Count}"); + foreach (var c in hitCells) + if (bowFacesShot) Assert.True(c.y >= 5, $"bow should face the shot, but {c} is on the stern half"); + else Assert.True(c.y < 5, $"stern should face the shot, but {c} is on the bow half"); + } + + void Check(float2 travelDirection) + { + travelDirection = normalize(travelDirection); + CheckOneDirection(travelDirection, bowFacesShot: true); + CheckOneDirection(travelDirection, bowFacesShot: false); + } + + Check(float2(0, 1)); + Check(float2(.8f, .6f)); + Check(float2(.9f, .3f)); // |fx| > |fy| + } + + // BuriedCockpitNeedsPenetration: a 3x3 solid hull's only interior cell (1,1) -- Shape.Shrink needs all 8 + // neighbours present, which only the centre of a 3x3 has -- carries a Cockpit item, ringed by the hull's + // own 8 border cells. Firing straight down any of the 4 axis-aligned bearings crosses exactly one ring + // cell before the cockpit (and one more after, unread since nothing needs to go further). + [Fact] + public void BuriedCockpitNeedsPenetration() + { + float RunOnce(float2 facing, float penetration, float damage, float ringArmor) + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: penetration, + targetFacing: facing, cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + foreach (var c in e.HullData.Shape.Coordinates) + { + var isRing = c.x != 1 || c.y != 1; + e.Target.Armor[c.x, c.y] = isRing ? ringArmor : 0f; + e.Target.MaxArmor[c.x, c.y] = e.Target.Armor[c.x, c.y]; + } + var before = e.Cockpit.EquippableItem.Durability; + var outcome = FireUntilHit(e, damageOverride: damage); + Assert.NotNull(outcome); + Assert.True(outcome.Hit, $"fixture: facing {facing} must eventually hit"); + return before - e.Cockpit.EquippableItem.Durability; + } + + // Penetration 0: only the ring's own facing cell is ever reached -- the cockpit, one cell further in, + // must be untouched from all four axis-aligned bearings. + foreach (var facing in new[] { float2(0, 1), float2(0, -1), float2(1, 0), float2(-1, 0) }) + Assert.Equal(0f, RunOnce(facing, penetration: 0f, damage: 40f, ringArmor: 5f)); + + // A penetration that clears the ring (one cell, entry difference ~1): the cockpit takes exactly the + // damage minus what the ring absorbed. + var lost = RunOnce(float2(0, 1), penetration: 2f, damage: 40f, ringArmor: 5f); + Assert.Equal(35f, lost, 2); + + // Enough damage, with the ring cleared, destroys the cockpit and fires Death with CockpitDestroyed. + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: 2f, + targetFacing: float2(0, 1), cockpitCell: new int2(1, 1), cockpitDurability: 20f); + foreach (var c in e.HullData.Shape.Coordinates) + { + var isRing = c.x != 1 || c.y != 1; + e.Target.Armor[c.x, c.y] = isRing ? 0f : 0f; + e.Target.MaxArmor[c.x, c.y] = 0f; + } + CauseOfDeath? death = null; + using var d = e.Target.Death.Subscribe(cod => death = cod); + var outcome = FireUntilHit(e, damageOverride: 100f); + Assert.NotNull(outcome); + Assert.Equal(CauseOfDeath.CockpitDestroyed, death); + } + } + + // SpreadWidensTheImpactAcrossLanes: spread 1 (n=1, three parallel lanes one cell apart) at penetration 0 -- + // a 3-wide, 2-tall hull, stern (y=0) facing the shot (default facing == travel direction, Cut 12's own + // TurningArmourIntoTheShotTakesItOnTheArmour convention). Retries until the committed impact lands on the + // centre column (x=1), so the three lanes are exactly this hull's three columns, each with its own + // facing-edge cell at y=0. + [Fact] + public void SpreadWidensTheImpactAcrossLanes() + { + var e = Build(TestSettings(), SolidShape(3, 2), precision: .6f, penetration: 0f, damageSpread: 1f); + for (var x = 0; x < 3; x++) + for (var y = 0; y < 2; y++) + { + e.Target.Armor[x, y] = 0f; + e.Target.MaxArmor[x, y] = 0f; + } + + ShotOutcome outcome = null; + var hitCells = new List<(int2 pos, float damage)>(); + using (e.Target.ArmorDamage.Subscribe(x => hitCells.Add(x))) + { + for (var attempt = 0; attempt < 200; attempt++) + { + hitCells.Clear(); + outcome = FireUntilHit(e, attempts: 1); + if (outcome != null && outcome.Hit && outcome.Cell.x == 1) break; + } + } + + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit landing on the centre column within the attempt budget"); + + // Exactly the three facing-edge cells (y=0), damage/3 each; nothing behind them (y=1). + var byCell = hitCells.GroupBy(h => h.pos).ToDictionary(g => g.Key, g => g.Sum(h => h.damage)); + Assert.Equal(3, byCell.Count); + foreach (var x in new[] { 0, 1, 2 }) + { + Assert.True(byCell.TryGetValue(new int2(x, 0), out var d), $"expected a hit on facing cell ({x},0)"); + Assert.Equal(5f / 3f, d, 3); // weapon Damage is 5 (this fixture's Gun, no override) + Assert.False(byCell.ContainsKey(new int2(x, 1)), $"cell ({x},1) is behind the facing edge and must be untouched"); + } + } + + // ARayThroughACornerCrossesTheCornerCell: the diagonal case the old 0.5-step march (Entity.ApplyHit, + // deleted by this cut) could skip. A bearing angled off both axes and a lateral offset comfortably inside + // one cell's own shadow (not on a shared boundary) crosses several cells; the exact slab walk must not + // drop one of them the way a fixed-step Euclidean march could stumble at a shared corner. + [Fact] + public void ARayThroughACornerCrossesTheCornerCell() + { + var shape = SolidShape(3, 3); + var hull = new HullData { Name = "Diagonal", HullType = HullType.Ship, Shape = shape }; + var b = normalize(float2(1, 1)); + var buffer = new LaneCell[shape.Coordinates.Length]; + + var count = FireControl.Lane(hull, b, -0.1f, buffer); + var cells = Enumerable.Range(0, count).Select(i => buffer[i].Cell).ToList(); + + Assert.Contains(new int2(0, 0), cells); + Assert.Contains(new int2(1, 1), cells); + Assert.Contains(new int2(2, 2), cells); + Assert.True(cells.IndexOf(new int2(0, 0)) < cells.IndexOf(new int2(1, 1))); + Assert.True(cells.IndexOf(new int2(1, 1)) < cells.IndexOf(new int2(2, 2))); + } + + // LaneRemainderReachesTheHull (Q12-3 = A): a ray that exits a thin, bare hull -- both cells reached, no + // armour anywhere -- delivers its whole remainder to Hull.Durability, not to nowhere. + [Fact] + public void LaneRemainderReachesTheHull() + { + var e = Build(TestSettings(), SolidShape(1, 2), precision: 1f, penetration: 5f); + e.Target.Armor[0, 0] = 0f; e.Target.MaxArmor[0, 0] = 0f; + e.Target.Armor[0, 1] = 0f; e.Target.MaxArmor[0, 1] = 0f; + // Every fixture's target carries a bystander Gun at (0,0) (Entity.Update NREs on an entity that never + // equipped anything) -- irrelevant to this test, but its own 1 durability would otherwise eat 1 of the + // 40 damage as a confound. Zeroing it here removes that, matching what "no armour or item anywhere" + // actually needs. + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + + var hullBefore = e.Target.Hull.Durability; + var outcome = FireUntilHit(e, damageOverride: 40f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.Equal(hullBefore - 40f, e.Target.Hull.Durability, 2); + } +} From 73b15d9f48553a639acaf770f561faac52a7b14a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 09:09:16 +0200 Subject: [PATCH 018/116] Cut 12.3 Stryker fix batch: exact-threshold and lane-count survivors First Stryker pass over the changed lines (Entity.cs: Absorb/DamageHull; FireControl.cs: Apply, Splash) killed 584/844 candidate mutants; these six survivors on the cut's own diff get real behavioural tests instead: - Entity.Absorb's `d > 0f` and `d > 0.1f` guards, and DamageHull's `damage > .1f` guard, each had their boundary flipped to `>=` and survived -- at the exact boundary (0 incoming, or exactly the .1f carry-forward threshold), real code does nothing while the mutant fires an extra event or spends an extra fraction. Not a rare float coincidence: a spent lane routinely reaches a cell with exactly 0 remaining, and an authored penetration/armour value can land exactly on .1f. - FireControl.Apply's `target.IncomingHit.OnNext(shot.Source)` (moved here from the deleted Entity.ApplyHit) had no test that it still fires. - FireControl.Apply's two `walked[li] > 0` checks (metalLanes counting, and the absorb-loop entry) both flipped to `>= 0` and survived, since a lane count can never be negative -- a real bug class (a lane entirely off the hull would dilute the split and leak a damage share to the hull) that a hull no wider than the spread now catches. - The penetration cutoff's `>= shot.Penetration` flipped to `>` survived at the exact boundary -- an axis-aligned lane's entry values are exact integers, so an authored integer penetration routinely lands exactly on a cell's own entry difference. Recorded, not chased: `shot.Outcome.Lateral - (li - n)` (the lane-offset sign) is an equivalent mutant -- 2n+1 lanes built symmetrically around n cover the same SET of offsets either way, just enumerated in reverse, and the loop's own per-lane work is order-independent. The missing `ArrayPool.Return` survivor is a perf-hygiene gap (GC pressure, not a spec'd invariant -- only the forecast path is zero-allocation), not yet reached by a dedicated allocation test. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut123Tests.cs | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 6b75f67a5..66cdd0138 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -446,4 +446,135 @@ public sealed class FireControlCut123Tests : IDisposable Assert.Equal(hullBefore - 40f, e.Target.Hull.Durability, 2); } + + // Stryker survivor triage (this cut's own changed lines): Entity.Absorb's `d > 0f` guard boundary-flipped to + // `d >= 0f` survived -- at exactly 0 incoming, real code emits nothing ("only for incoming > 0"), the mutant + // would additionally fire ArmorDamage(cell, 0). Not a rare float coincidence: a spent lane (rem already 0) + // walks past every remaining cell in FireControl.Apply's own loop, so this is the ordinary "nothing left" + // case, not an edge case. + [Fact] + public void AbsorbEmitsNothingAtExactlyZeroIncomingDamage() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); + e.Target.Armor[1, 1] = 5f; e.Target.MaxArmor[1, 1] = 5f; + var marker = e.Markers[0]; + + var armorEvents = 0; + var itemEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + using var i = e.Target.ItemDamage.Subscribe(_ => itemEvents++); + + var remainder = e.Target.Absorb(new int2(1, 1), 0f); + + Assert.Equal(0f, remainder); + Assert.Equal(0, armorEvents); + Assert.Equal(0, itemEvents); + Assert.Equal(5f, e.Target.Armor[1, 1]); + Assert.Equal(50f, marker.EquippableItem.Durability); + } + + // Stryker survivor: Entity.Absorb's `d > 0.1f` item-phase guard boundary-flipped to `d >= 0.1f` survived -- + // at exactly 0.1f remaining after armor, real code leaves the item untouched (the remainder returns to the + // caller instead), the mutant would additionally spend it on the item. + [Fact] + public void AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); + e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; // bare, so the whole .1f reaches the item check + var marker = e.Markers[0]; + + var itemEvents = 0; + using var i = e.Target.ItemDamage.Subscribe(_ => itemEvents++); + + var remainder = e.Target.Absorb(new int2(1, 1), 0.1f); + + Assert.Equal(0.1f, remainder, 3); + Assert.Equal(0, itemEvents); + Assert.Equal(50f, marker.EquippableItem.Durability); + } + + // Stryker survivor: Entity.DamageHull's `damage > .1f` guard boundary-flipped to `damage >= .1f` survived -- + // at exactly 0.1f, real code does nothing; the mutant would subtract it from Hull.Durability and fire + // HullDamage. + [Fact] + public void DamageHullDoesNothingAtExactlyPointOne() + { + var e = Build(TestSettings(), SolidShape(1, 2), precision: 1f); + var before = e.Target.Hull.Durability; + var events = 0; + using var h = e.Target.HullDamage.Subscribe(_ => events++); + + e.Target.DamageHull(0.1f); + + Assert.Equal(0, events); + Assert.Equal(before, e.Target.Hull.Durability); + } + + // Stryker survivor: FireControl.Apply's `target.IncomingHit.OnNext(shot.Source)` (moved here from the + // deleted Entity.ApplyHit's own first line) survived a Statement-to-";" mutation -- nothing asserted that + // it still fires on a resolved, unshielded hit. + [Fact] + public void ApplyFiresIncomingHitOnAResolvedHit() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f); + Entity source = null; + using var s = e.Target.IncomingHit.Subscribe(src => source = src); + + var outcome = FireUntilHit(e, damageOverride: 5f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Same(e.Shooter, source); + } + + // Stryker survivors: FireControl.Apply's two `walked[li] > 0` checks (metalLanes counting, and the + // absorb-loop entry) both boundary-flipped to `walked[li] >= 0` survived -- since a lane count can never be + // negative, `>= 0` is always true, so BOTH mutants make every lane count as "met metal" and enter the + // absorb loop, even a lane that is entirely off the hull. A 1-wide hull with spread 1 (3 lanes: centre plus + // one on each side) puts the two side lanes off-hull by construction -- only the centre lane can ever find + // metal. The metalLanes mutant would divide the shot's damage by 3 instead of 1 (a wrong, smaller number + // reaching the one real cell); the loop-entry mutant would additionally walk the two off-hull lanes with + // `walked[li]` still correctly 0 iterations internally, but still fall through to `hullDamage += rem` + // (rem never consumed), leaking a full extra damagePerLane share into the hull for each miss. + [Fact] + public void LanesThatMissTheHullNeitherDiluteTheSplitNorLeakDamage() + { + var e = Build(TestSettings(), SolidShape(1, 9), precision: .4f, penetration: 0f, damageSpread: 1f); + for (var y = 0; y < 9; y++) { e.Target.Armor[0, y] = 1000f; e.Target.MaxArmor[0, y] = 1000f; } + + var hullBefore = e.Target.Hull.Durability; + var hits = new List<(int2 pos, float damage)>(); + using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x)); + + var outcome = FireUntilHit(e, damageOverride: 30f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.Single(hits); // only the centre lane's own impact cell ever records armour damage + Assert.Equal(30f, hits[0].damage, 2); // the WHOLE damage, not damage/3 -- metalLanes counted only the hit + Assert.Equal(hullBefore, e.Target.Hull.Durability); // nothing leaked to the hull from the off-hull lanes + } + + // Stryker survivor: FireControl.Apply's penetration cutoff (`Entry - impactEntry >= shot.Penetration`) + // boundary-flipped to `>` survived -- not a rare float coincidence: an axis-aligned lane's entry values are + // exact integers (schematic cell spacing), so penetration authored as an exact integer routinely lands + // exactly on a cell's own entry difference. Penetration 2 exactly against a 3-cell column (entries ~0,1,2): + // the third cell sits exactly AT the boundary and must be excluded ("< penetration" is reached; the mutant + // would let it through). + [Fact] + public void PenetrationExcludesACellExactlyAtItsOwnBoundary() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: 2f, + markers: new[] { (new int2(1, 1), 1000000f) }); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[1, 2] = 50f; e.Target.MaxArmor[1, 2] = 50f; // exactly at entry-diff 2 == penetration + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 1); attempt++) + outcome = FireUntilHit(e, damageOverride: 30f, attempts: 1); + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit on the centre column within the attempt budget"); + Assert.Equal(new int2(1, 0), outcome.Cell); + + Assert.Equal(50f, e.Target.Armor[1, 2]); // exactly at entry-diff 2 -- excluded, not "< penetration" + } } From f2c94c29bef559940036e9c2c51e718c7db0bff7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 11:20:45 +0200 Subject: [PATCH 019/116] Q12-2 amended: spread lanes are spaced by a cell's shadow, so none doubles up Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index d4dbacafd..08941d2b2 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1916,6 +1916,15 @@ Second round: from its own facing cell. Operator: "Spread is width makes sense for shotguns, but what about explosive penetrators? Burrowing in before splashing radially would be a satisfying way to damage vulnerable internals." That remark became sub-cut 12.4. + - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at + angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the + shared armour and mirror-image shots did different damage in ~30% of angled spread hits. + Operator: "I would prefer if the overlapping damage cells were diffused sideways to + thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): + **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` + width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly + that wide and half-open, so no two lanes can strike the same cell; the footprint widens at + angles instead of doubling up, and axis-aligned shots are unchanged. - **Q12-3 = A (direct hits):** a lane's remainder goes into the hull wherever the lane ends. - **Q12-4 = A:** delete `ShotOutcome.Aimed`. @@ -2432,7 +2441,7 @@ import. unchanged: 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. 2. The lanes are `Lateral + k` for `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + - .5f)` (today's rounding, `Entity.cs:389`), spaced one cell apart along ℓ (Q12-2 = A). + .5f)` (today's rounding, `Entity.cs:389`), spaced one cell *shadow* apart along ℓ, i.e. `2h` (Q12-2 = A, amended 2026-09-25). 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. 4. Each lane walks from its own facing cell, clipped to the penetration depth, with `rem = target.Absorb(cell, rem)` at each cell. From b82b7eab21c650b3e1e7f214f0df8fd096090269 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 11:31:34 +0200 Subject: [PATCH 020/116] Cut 12.3 fix batch: F2 lane spacing by shadow width, F3 penetration boundary fixture Operator ruling (2026-09-25): overlapping spread lanes should diffuse sideways rather than double up. FireControl.Apply now spaces lanes by the shadow width a cell casts at the committed bearing (Extent's own h, doubled) instead of by one cell -- 1 at axis-aligned bearings (unchanged), up to sqrt(2) at 45 degrees. Reuses Extent, the one function that already owns h for Silhouette and Lane, rather than re-deriving it a second way. F3: PenetrationExcludesACellExactlyAtItsOwnBoundary's 1e6-durability marker absorbed the whole shot before the walk could reach the boundary cell, so the test could not fail under the `>=`-to-`>` mutant. The marker now carries 10 durability, leaving a remainder that would visibly damage the boundary cell if the walk incorrectly included it. Adds PenetrationExcludesCellsBeyondANonIntegerDepth, pinning the ordinary "< penetration" comparison at a depth (1.5) that isn't an exact-integer boundary. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 28 ++++++++++----- .../FireControlCut123Tests.cs | 36 +++++++++++++++++-- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 5d88219d3..23440d409 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -589,13 +589,22 @@ public static class FireControl // once per Unity effect). // Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step // march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A): - // 2n+1 parallel lanes, one cell apart along the committed bearing's own lateral axis, sharing the shot's - // damage evenly over whichever lanes actually meet metal -- the centre lane, at the committed Lateral, - // always does (Commit already proved it, R3). Each lane is Lane's own contiguous cell run from its own - // facing cell, clipped to the penetration depth (a cell is reached when entry - impact.entry < penetration; - // the impact cell is always reached, replacing the deleted `> .5f` threshold at zero cost). Whatever a - // lane does not spend -- penetration exhausted, a gap, or the far side -- goes to the hull where the lane - // ends (Q12-3 = A), summed across every lane into one DamageHull call. + // 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the + // centre lane, at the committed Lateral, always does (Commit already proved it, R3). Each lane is Lane's + // own contiguous cell run from its own facing cell, clipped to the penetration depth (a cell is reached + // when entry - impact.entry < penetration; the impact cell is always reached, replacing the deleted + // `> .5f` threshold at zero cost). Whatever a lane does not spend -- penetration exhausted, a gap, or the + // far side -- goes to the hull where the lane ends (Q12-3 = A), summed across every lane into one + // DamageHull call. + // F2 fix batch (Soul, 2026-09-25; operator: "I would prefer if the overlapping damage cells were diffused + // sideways to thicken the line rather than doubling up"): lanes are spaced by the shadow width a cell + // casts at this bearing -- Extent's own h, doubled -- not by one cell. At axis-aligned bearings that width + // is exactly 1 (today's spacing, unchanged); at 45 degrees it is sqrt(2). Reusing Extent (the one function + // that owns h, Silhouette's shadow and Lane's own admission test) instead of re-deriving |bx|+|by| a + // second way is the same discipline Cut 12.2's own fix batch already applied to Lane -- two derivations of + // one geometric quantity is exactly how that cut's crash happened. Because each cell's admitted lateral + // interval is exactly this width wide and half-open, lanes this far apart can never both admit the same + // cell: no cell is struck twice, and the footprint widens at angles instead of doubling up. private static void Apply(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -622,6 +631,9 @@ public static class FireControl var bearing = shot.Outcome.Bearing; var n = (int) floor(shot.DamageSpread + .5f); // today's rounding, Entity.cs:389 before this cut var laneCount = 2 * n + 1; + // F2: the shadow width at this bearing, from Extent's own h (the cell argument does not affect h). + var shadowExtent = Extent(float2.zero, Lateral(bearing)); + var laneSpacing = shadowExtent.Hi - shadowExtent.Lo; var buffers = new LaneCell[laneCount][]; var walked = new int[laneCount]; @@ -629,7 +641,7 @@ public static class FireControl for (var li = 0; li < laneCount; li++) { buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); - walked[li] = Lane(hullData, bearing, shot.Outcome.Lateral + (li - n), buffers[li]); + walked[li] = Lane(hullData, bearing, shot.Outcome.Lateral + (li - n) * laneSpacing, buffers[li]); if (walked[li] > 0) metalLanes++; } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 66cdd0138..82c7df6a8 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -560,13 +560,18 @@ public sealed class FireControlCut123Tests : IDisposable // exactly on a cell's own entry difference. Penetration 2 exactly against a 3-cell column (entries ~0,1,2): // the third cell sits exactly AT the boundary and must be excluded ("< penetration" is reached; the mutant // would let it through). + // F3 fix batch (Soul, 2026-09-25): the marker at (1,1) used to carry 1e6 durability, so it alone absorbed + // the whole 30 damage before the walk could ever reach (1,2) -- the boundary cell took 0 whether the `>` + // mutant let it through or not, so the test could not fail. The marker now carries only 10, leaving a + // remainder large enough that (1,2) would visibly lose armour if the walk reached it. [Fact] public void PenetrationExcludesACellExactlyAtItsOwnBoundary() { var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: 2f, - markers: new[] { (new int2(1, 1), 1000000f) }); + markers: new[] { (new int2(1, 1), 10f) }); foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } e.Target.Armor[1, 2] = 50f; e.Target.MaxArmor[1, 2] = 50f; // exactly at entry-diff 2 == penetration + var marker = e.Markers[0]; ShotOutcome outcome = null; for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 1); attempt++) @@ -575,6 +580,33 @@ public sealed class FireControlCut123Tests : IDisposable Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit on the centre column within the attempt budget"); Assert.Equal(new int2(1, 0), outcome.Cell); - Assert.Equal(50f, e.Target.Armor[1, 2]); // exactly at entry-diff 2 -- excluded, not "< penetration" + Assert.Equal(0f, marker.EquippableItem.Durability); // fixture: the marker's own 10 is fully spent, 20 left over + Assert.Equal(50f, e.Target.Armor[1, 2]); // exactly at entry-diff 2 -- excluded, not "< penetration" -- the + // remaining 20 would otherwise have visibly damaged it + } + + // F3: a non-integer penetration depth. Entries down an axis-aligned column are exact integers, so a + // fractional penetration is never itself a boundary -- this pins the ordinary "< penetration" comparison at + // a depth that isn't a round number, independent of the exact-integer boundary case above. + [Fact] + public void PenetrationExcludesCellsBeyondANonIntegerDepth() + { + var e = Build(TestSettings(), SolidShape(1, 4), precision: 1f, penetration: 1.5f); + e.Target.Armor[0, 0] = 5f; e.Target.MaxArmor[0, 0] = 5f; // entry-diff 0: always reached + e.Target.Armor[0, 1] = 5f; e.Target.MaxArmor[0, 1] = 5f; // entry-diff 1: within 1.5, reached + e.Target.Armor[0, 2] = 50f; e.Target.MaxArmor[0, 2] = 50f; // entry-diff 2: beyond 1.5, excluded + e.Target.Armor[0, 3] = 50f; e.Target.MaxArmor[0, 3] = 50f; // entry-diff 3: beyond 1.5, excluded + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + + var hullBefore = e.Target.Hull.Durability; + var outcome = FireUntilHit(e, damageOverride: 30f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.Equal(0f, e.Target.Armor[0, 0]); // 5 consumed + Assert.Equal(0f, e.Target.Armor[0, 1]); // 5 consumed + Assert.Equal(50f, e.Target.Armor[0, 2]); // untouched -- beyond the 1.5 depth + Assert.Equal(50f, e.Target.Armor[0, 3]); // untouched -- beyond the 1.5 depth + Assert.Equal(hullBefore - 20f, e.Target.Hull.Durability, 2); // 30 - 5 - 5 left over for the hull } } From 435f905e8b8eb0bf80951ffda59be6bb4735078b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 11:39:53 +0200 Subject: [PATCH 021/116] Cut 12.3 fix batch: F4 first-gap, F5 side-lane remainders, F6 armour/item order, F7 handedness F4: AnAPRoundStopsAtTheFirstGapAndLeavesTheFarSideOfTheNotchUntouched, a notched U-shaped hull, pins Lane's own contiguity break (an AP round with ample penetration must stop at the first gap, not reach the far side). F5: EverySideLanesRemainderReachesTheHullNotOnlyTheCentreLanes, an axis-aligned spread-1 fixture (lanes share no cells, so order cannot matter), pins that every lane's own leftover reaches the hull, not only the centre lane's. F6: AbsorbSpendsArmourBeforeTheItemOnTheSameCell, direct Absorb calls (this file's existing style for the function's own arithmetic) with armour and an item on the SAME cell for the first time -- no prior fixture combined them. F7: ADirectHitFromStarboardDamagesStarboardCellsWhenFacingIsMostlyLateral, a direct-hit test at a facing with |fx| > |fy|, computing "starboard" as the independent right-perpendicular of the facing rather than reading it from Entity.ToSchematic. Cut 11's Splash tests are the only thing guarding this handedness today, and 12.4 deletes Splash. Both F4 and F5 track hull damage per-attempt (reset on each retry), not as a before/after total across every retry: this fixture's off-target lanes carry no armour, so a stray hit on the wrong column during a retry spends real damage on the hull and would otherwise contaminate a cross-attempt total. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut123Tests.cs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 82c7df6a8..2cc53e64d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -609,4 +609,155 @@ public sealed class FireControlCut123Tests : IDisposable Assert.Equal(50f, e.Target.Armor[0, 3]); // untouched -- beyond the 1.5 depth Assert.Equal(hullBefore - 20f, e.Target.Hull.Durability, 2); // 30 - 5 - 5 left over for the hull } + + // F4: a U-shaped hull, notched. Two arms (x=0 and x=2) rise from a connecting base (y=0); the left arm has + // a gap at y=1 -- present at y=0,2,3, missing at y=1 (so the lane hits the base at y=0, a gap at y=1, then + // the rest of the arm at y=2,3). An AP round (no blast) with ample penetration must stop at that first gap + // and leave the far side of the notch (y=2,3, "the far prong") untouched. Kills the Stryker survivor that + // deletes Lane's own contiguity break (`FireControl.cs:~895`, M9): without it, Lane would keep walking past + // the gap and Apply would spend damage on cells the real rule says are unreachable. + private static Shape NotchedU() + { + var shape = new Shape(3, 4); + shape[new int2(0, 0)] = true; shape[new int2(0, 2)] = true; shape[new int2(0, 3)] = true; // left arm, notch at y=1 + for (var y = 0; y < 4; y++) shape[new int2(2, y)] = true; // right arm, solid + shape[new int2(1, 0)] = true; // the base connecting the two arms + return shape; + } + + [Fact] + public void AnAPRoundStopsAtTheFirstGapAndLeavesTheFarSideOfTheNotchUntouched() + { + var e = Build(TestSettings(), NotchedU(), precision: 1f, penetration: 10f); // ample: more than the whole hull's depth + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[0, 0] = 5f; e.Target.MaxArmor[0, 0] = 5f; // near side of the notch + e.Target.Armor[0, 2] = 1000f; e.Target.MaxArmor[0, 2] = 1000f; // far side -- must stay untouched + e.Target.Armor[0, 3] = 1000f; e.Target.MaxArmor[0, 3] = 1000f; // far side -- must stay untouched + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + + // The hull's other arm (x=2) carries no armour, so a stray hit landing there while retrying for the + // notched column would leak its own damage straight to the hull -- tracked here per-attempt (reset each + // retry) so only the accepted attempt's own contribution is asserted, not a cross-attempt total. + var hullThisAttempt = 0f; + ShotOutcome outcome = null; + using (e.Target.HullDamage.Subscribe(x => hullThisAttempt += x)) + { + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 0); attempt++) + { + hullThisAttempt = 0f; + outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); + } + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 0, "fixture: needed a hit on the notched arm within the attempt budget"); + Assert.Equal(new int2(0, 0), outcome.Cell); // fixture precondition: the base is always this lane's impact cell + + Assert.Equal(0f, e.Target.Armor[0, 0]); // 5 consumed + Assert.Equal(1000f, e.Target.Armor[0, 2]); // untouched -- beyond the first gap + Assert.Equal(1000f, e.Target.Armor[0, 3]); // untouched -- beyond the first gap + Assert.Equal(35f, hullThisAttempt, 2); // 40 - 5 left over for the hull, not spent past the gap + } + + // F5: side-lane remainders must reach the hull too, axis-aligned so the lanes share no cells and lane + // order cannot matter (Q12-3 = A, "the lane remainder goes into the hull where the lane ends"). A 3x2 + // hull, spread 1 (three one-cell-wide columns), armour only on the facing row: every lane's remainder + // survives the facing cell and must land in the hull. Kills the Stryker survivor that guards the + // hull-remainder add with `if (li == n)` (only the centre lane's remainder would count). + [Fact] + public void EverySideLanesRemainderReachesTheHullNotOnlyTheCentreLanes() + { + var e = Build(TestSettings(), SolidShape(3, 2), precision: .6f, penetration: 2f, damageSpread: 1f); + for (var x = 0; x < 3; x++) + for (var y = 0; y < 2; y++) + { + e.Target.Armor[x, y] = y == 0 ? 1f : 0f; + e.Target.MaxArmor[x, y] = 1f; + var occ = e.Target.GearOccupancy[x, y]; + if (occ != null) occ.EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + } + + // Tracked per-attempt (reset each retry), the same reasoning as the notched-U test above: a stray hit on + // the wrong lateral offset still spends real damage on this hull, and would otherwise contaminate a + // before/after total taken across every retry. + var hullThisAttempt = 0f; + ShotOutcome outcome = null; + using (e.Target.HullDamage.Subscribe(x => hullThisAttempt += x)) + { + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 1); attempt++) + { + hullThisAttempt = 0f; + outcome = FireUntilHit(e, damageOverride: 15f, attempts: 1); + } + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit landing on the centre column within the attempt budget"); + + // Each lane: 15/3 = 5 per lane, minus the facing cell's 1 armour = 4 left over; three lanes -> 12. + Assert.Equal(12f, hullThisAttempt, 2); + } + + // F6: armour and an occupying item on the SAME cell -- no existing fixture combines them, so "armour + // absorbs first" (Entity.Absorb) was only ever exercised with one or the other. Direct calls to Absorb, + // matching this file's own existing style for pinning its per-cell arithmetic (AbsorbEmitsNothingAt..., + // AbsorbLeavesTheItemUntouchedAt...). Kills a swap of the armour and item blocks in Absorb. + [Fact] + public void AbsorbSpendsArmourBeforeTheItemOnTheSameCell() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); + var marker = e.Markers[0]; + void Reset() { e.Target.Armor[1, 1] = 10f; e.Target.MaxArmor[1, 1] = 10f; marker.EquippableItem.Durability = 50f; } + + Reset(); + var lessThanArmour = e.Target.Absorb(new int2(1, 1), 5f); + Assert.Equal(0f, lessThanArmour); + Assert.Equal(5f, e.Target.Armor[1, 1]); // 5 of the 10 spent + Assert.Equal(50f, marker.EquippableItem.Durability); // item untouched -- armour alone covered it + + Reset(); + var moreThanArmourLessThanBoth = e.Target.Absorb(new int2(1, 1), 25f); + Assert.Equal(0f, moreThanArmourLessThanBoth); + Assert.Equal(0f, e.Target.Armor[1, 1]); // the whole 10 spent + Assert.Equal(35f, marker.EquippableItem.Durability, 2); // exactly the 15 excess + + Reset(); + var moreThanBoth = e.Target.Absorb(new int2(1, 1), 80f); + Assert.Equal(20f, moreThanBoth, 2); // exactly 80 - 10 - 50 + Assert.Equal(0f, e.Target.Armor[1, 1]); + Assert.Equal(0f, marker.EquippableItem.Durability); + } + + // F7: direct-hit frame handedness at |fx| > |fy| is otherwise only guarded by Cut 11's Splash tests, which + // 12.4 deletes. A target facing (2,1) normalized (|fx| > |fy|), shot from its own starboard side (computed + // here independently of Entity.ToSchematic, as the standard right-perpendicular of the facing -- the same + // relationship ToSchematic is supposed to implement, not a call into it). Only starboard-half cells (x >= + // width/2 in the hull's own schematic frame) carry armour; port cells (x < width/2) do not. Kills a flip of + // ToSchematic's right axis when |fx| > |fy| (M6a). + [Fact] + public void ADirectHitFromStarboardDamagesStarboardCellsWhenFacingIsMostlyLateral() + { + var shape = SolidShape(5, 5); + var facing = normalize(float2(2, 1)); // |fx| > |fy| + var e = Build(TestSettings(), shape, precision: .4f, penetration: 0f, targetFacing: facing); + + // Starboard is the standard right-perpendicular of the facing (forward.y, -forward.x) -- computed here, + // not read from Entity.ToSchematic. + var starboardWorld = float2(facing.y, -facing.x); + e.Shooter.Position = e.Target.Position + float3(starboardWorld.x, 0, starboardWorld.y) * 100f; // shooter sits to starboard + + for (var x = 0; x < 5; x++) + for (var y = 0; y < 5; y++) + { + var starboard = x >= 3; // starboard half; x == 2 (the middle column) is left ambiguous and unarmoured + e.Target.Armor[x, y] = x <= 1 ? 0f : starboard ? 1f : 0f; + e.Target.MaxArmor[x, y] = 1f; + } + + var hitCells = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hitCells.Add(x.pos)); + for (var i = 0; i < 80; i++) { FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } + + Assert.True(hitCells.Count > 5, $"expected several hits, got {hitCells.Count}"); + foreach (var c in hitCells) + Assert.True(c.x >= 3, $"starboard should face the shot, but {c} is on the port side"); + } } From 078b1ff36dcb11a43f70c0744853138aa8c28f54 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 11:53:47 +0200 Subject: [PATCH 022/116] Cut 12.3 fix batch: F10, an independent AlongBearing expectation at several bearings The previous AlongBearingReturnsTheExactSlabIntersection wrote out SlabAxis's own threshold (1e-9f) and clamp a second time in its ExactSlab helper, so a mutant that only moved that threshold (C7b, 1e-9 to .2) agreed with both copies identically and survived. AlongBearingReturnsTheExactSlabIntersectionAtSeveralBearings replaces it: the expected entry/exit now come from an exact ray-box intersection that never special-cases a near-zero bearing component -- IEEE division by a signed zero already yields the correctly signed infinity, so an axis with no motion is unconstrained without a hand-written epsilon to disagree with SlabAxis's own. Checked at the original fixture's bearing plus two within about 11 degrees of an axis, inside C7b's mutated .2f threshold but nowhere near the real 1e-9f one. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut12Tests.cs | 89 ++++++++++--------- 1 file changed, 46 insertions(+), 43 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index b533a16d0..964c2bf2e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -811,65 +811,68 @@ public sealed class FireControlCut12Tests : IDisposable // every mutant there survived in 12.2 because only buffer[0]'s Cell was ever read (S1's own shadow // prefilter, Extent, decided admission; AlongBearing's entry/exit were computed but unchecked). 12.3's own // walk (FireControl.Apply) reads Entry for penetration and ordering, so a wrong entry/exit here is now a - // wrong damage cell, not dead arithmetic. Fixture: a bearing that crosses several cells diagonally (neither - // axis-aligned), so entry ordering and contiguity both matter -- this test computes each admitted cell's - // own exact slab intersection independently (the same two-axis solve AlongBearing/SlabAxis perform, written - // out fresh here rather than called), and checks Lane's own Entry/Exit against it, cell for cell, in order. + // wrong damage cell, not dead arithmetic. + // F10 fix batch (Soul, 2026-09-25): the previous, single-bearing version of this test wrote out SlabAxis's + // OWN threshold (`1e-9f`) and clamp a second time, so a mutant that only moved that threshold (C7b, 1e-9 to + // .2) agreed with both copies identically and survived. This version never special-cases a near-zero + // bearing component at all: IEEE division by a signed zero already yields the correctly signed infinity, so + // an axis with no motion is unconstrained without a hand-written epsilon to disagree with SlabAxis's own. + // Checked at several bearings, including one within about 11 degrees of an axis -- inside SlabAxis's + // mutated .2f threshold but nowhere near the real 1e-9f one, so C7b now has to answer for a visibly wrong + // entry/exit instead of an unstressed one. [Fact] - public void AlongBearingReturnsTheExactSlabIntersection() + public void AlongBearingReturnsTheExactSlabIntersectionAtSeveralBearings() { var shape = SolidShape(5, 5); var hull = new HullData { Name = "Diagonal", HullType = HullType.Ship, Shape = shape }; - var b = normalize(float2(2, 1)); // neither axis-aligned nor 45 degrees - var ell = float2(-b.y, b.x); - var s = 0.13f; // off-centre, so the crossed cells are not symmetric about any axis - (float Entry, float Exit) ExactSlab(int2 c) + (double Entry, double Exit) ExactSlabIndependent(int2 c, double bx, double by, double ellx, double elly, double s) { - var entry = float.NegativeInfinity; - var exit = float.PositiveInfinity; - void Axis(float bAxis, float lo, float hi, float val) - { - if (Math.Abs(bAxis) < 1e-9f) return; - var t1 = (lo - val) / bAxis; - var t2 = (hi - val) / bAxis; - entry = Math.Max(entry, Math.Min(t1, t2)); - exit = Math.Min(exit, Math.Max(t1, t2)); - } - Axis(b.x, c.x - .5f, c.x + .5f, s * ell.x); - Axis(b.y, c.y - .5f, c.y + .5f, s * ell.y); - if (entry > exit) exit = entry; + var valX = s * ellx; var valY = s * elly; + var t1X = (c.x - .5 - valX) / bx; var t2X = (c.x + .5 - valX) / bx; + var t1Y = (c.y - .5 - valY) / by; var t2Y = (c.y + .5 - valY) / by; + var entry = Math.Max(Math.Min(t1X, t2X), Math.Min(t1Y, t2Y)); + var exit = Math.Min(Math.Max(t1X, t2X), Math.Max(t1Y, t2Y)); return (entry, exit); } - var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; // Extent's own half-width -- NOT .5 off-axis - var expectedCells = shape.Coordinates - .Select(c => (Cell: c, Centre: dot((float2) c, ell))) - .Where(t => s >= t.Centre - h && s < t.Centre + h) // Extent's own half-open shadow - .Select(t => t.Cell) - .OrderBy(c => dot((float2) c, b)) - .ThenBy(c => c.x).ThenBy(c => c.y) - .ToList(); - Assert.True(expectedCells.Count >= 4, $"fixture: this bearing/offset must cross several cells, got {expectedCells.Count}"); - - var buffer = new LaneCell[shape.Coordinates.Length]; - var count = FireControl.Lane(hull, b, s, buffer); - var actualCells = Enumerable.Range(0, count).Select(i => buffer[i].Cell).ToList(); - - Assert.Equal(expectedCells, actualCells); // same cells, same order -- entry ordering and contiguity agree - - for (var i = 0; i < count; i++) + void Check(float2 bRaw, float s) { - var (expectedEntry, expectedExit) = ExactSlab(buffer[i].Cell); - Assert.Equal(expectedEntry, buffer[i].Entry, 3); - Assert.Equal(expectedExit, buffer[i].Exit, 3); + var b = normalize(bRaw); + var ell = float2(-b.y, b.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2.0; // Extent's own half-width, written out independently + var expectedCells = shape.Coordinates + .Select(c => (Cell: c, Centre: (double) dot((float2) c, ell))) + .Where(t => s >= t.Centre - h && s < t.Centre + h) // Extent's own half-open shadow + .Select(t => t.Cell) + .OrderBy(c => dot((float2) c, b)) + .ThenBy(c => c.x).ThenBy(c => c.y) + .ToList(); + Assert.True(expectedCells.Count >= 3, $"fixture: b={b.x:R},{b.y:R} s={s} must cross several cells, got {expectedCells.Count}"); + + var buffer = new LaneCell[shape.Coordinates.Length]; + var count = FireControl.Lane(hull, b, s, buffer); + var actualCells = Enumerable.Range(0, count).Select(i => buffer[i].Cell).ToList(); + + Assert.Equal(expectedCells, actualCells); // same cells, same order -- entry ordering and contiguity agree + + for (var i = 0; i < count; i++) + { + var (expectedEntry, expectedExit) = ExactSlabIndependent(buffer[i].Cell, b.x, b.y, ell.x, ell.y, s); + Assert.Equal(expectedEntry, buffer[i].Entry, 3); + Assert.Equal(expectedExit, buffer[i].Exit, 3); + } } + + Check(float2(2, 1), 0.13f); // neither axis-aligned nor 45 degrees (the original fixture) + Check(float2(1, .15f), .3f); // ~8.5 degrees off the x-axis -- by sits inside C7b's mutated .2f threshold + Check(float2(.15f, 1), -.4f); // the same, off the y-axis -- bx sits inside C7b's mutated .2f threshold } // Cut 12.3: replaces ExpectedMarchCells, which reproduced Entity.ApplyHit's own deleted 0.5-step march -- // that rule is gone with the function. The rule this now pins is the NEW thing FireControl.Apply's lane // walk adds on top of Lane (Lane's own cell set/order/entry values are independently pinned elsewhere, by - // AlongBearingReturnsTheExactSlabIntersection and the LaneNeverEmptiesAtAnIntervalEdge sweep): the + // AlongBearingReturnsTheExactSlabIntersectionAtSeveralBearings and the LaneNeverEmptiesAtAnIntervalEdge sweep): the // penetration cutoff. So this calls the real, already-verified FireControl.Lane for the walked cells and // their entry parameters, then applies the cutoff independently -- kept only while within `penetration` // cells of the first (impact) cell's own entry; the impact cell is always kept, matching the deleted From c2e7a2166fe32ce6eb8882b2323210bd12547f45 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 12:08:29 +0200 Subject: [PATCH 023/116] Cut 12.3 fix batch: F1 angled-bearing parity, F2 spread-lane behaviour F1: no existing test through Fire commits at a non-axis bearing. DirectHitAtAnAngledBearingMatchesAnIndependentModel checks every cell's armour, every item's durability and the hull's own total against an independent double-precision ray/box model (ported from the Soul probe that verified this cut's arithmetic to 14,984/15,000 shots), at 45 degrees, a shallow angle and a bearing with |bx| > |by|. DiagonalDirectHitReachesEveryCornerCellInOrderThroughFireCommitApply pins the spec's own diagonal case (Probe1's corner-crossing lane) through the full Fire -> Commit -> Apply path rather than a direct Lane call: the lane reaches (0,0), then (1,1), then (2,2), in that order (a bearing this close to 45 degrees also admits the diagonal's row/column neighbours, so the walked lane is usually five cells, not three -- the spec only requires the three corner cells present and in order, the same standard the existing direct-Lane test already pins). F2 (operator ruling, 2026-09-25: spread lanes should diffuse sideways rather than double up): three behavioural tests, deliberately not asserting the spacing value itself (operator: "test behavior, not shape"). NoCellIsStruckByTwoLanesAtAnAngledBearing fires at 45 degrees and at a bearing with |bx| > |by|, spread 2, heavy uniform armour, and checks no cell fires ArmorDamage twice. MirrorImageShotsProduceStatisticallyMirroredDamage compares 300 hits at a bearing against 300 at its mirror image, and asserts the per-cell damage totals are mirrored within tolerance. FootprintWidensAtAnAngledBearingComparedToAxisAligned compares the lateral span of hit cells for the same spread at 45 degrees against axis-aligned, and asserts the angled footprint is wider. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut123Tests.cs | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 2cc53e64d..22e933c0f 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -760,4 +760,290 @@ public sealed class FireControlCut123Tests : IDisposable foreach (var c in hitCells) Assert.True(c.x >= 3, $"starboard should face the shot, but {c} is on the port side"); } + + // ===================== F1: an independent direct-hit model at angled bearings ===================== + // Every existing test through Fire commits at (0,+-1) -- ARayThroughACornerCrossesTheCornerCell calls Lane + // directly instead. This model is an exact double-precision ray/box walk, independent of + // FireControl.Lane/AlongBearing/SlabAxis, ported from the Soul probe (SoulApply123.cs) that verified this + // cut's arithmetic against production to 14,984/15,000 shots (the 16 mismatches were a float boundary). + // Used only to compute these tests' own expected values -- never to call production. + private struct MCell { public int2 C; public double En, Ex; } + + private static List ModelLane(Shape shape, double bx, double by, double s) + { + double lx = -by, ly = bx; + var all = new List(); + foreach (var c in shape.Coordinates) + { + double t0 = double.NegativeInfinity, t1 = double.PositiveInfinity; var ok = true; + void Ax(double d, double p0, double lo, double hi) + { + if (!ok) return; + if (Math.Abs(d) < 1e-12) { if (!(p0 > lo && p0 < hi)) ok = false; return; } + var a = (lo - p0) / d; var q = (hi - p0) / d; + t0 = Math.Max(t0, Math.Min(a, q)); t1 = Math.Min(t1, Math.Max(a, q)); + } + Ax(bx, s * lx, c.x - .5, c.x + .5); + Ax(by, s * ly, c.y - .5, c.y + .5); + if (ok && t1 - t0 > 1e-7) all.Add(new MCell { C = c, En = t0, Ex = t1 }); + } + all.Sort((p, q) => p.En.CompareTo(q.En)); + var walked = new List(); + for (var i = 0; i < all.Count; i++) + { + if (i > 0 && all[i].En > all[i - 1].Ex + 1e-4) break; + walked.Add(all[i]); + } + return walked; + } + + private static double[,] ToD(float[,] a) + { + var d = new double[a.GetLength(0), a.GetLength(1)]; + for (var i = 0; i < a.GetLength(0); i++) for (var j = 0; j < a.GetLength(1); j++) d[i, j] = a[i, j]; + return d; + } + + private sealed class DirectHitModel { public double[,] Armor; public Dictionary Items; public double Hull; } + + // Single lane (spread 0 -- F2's lane-spacing ruling only concerns spread >= 1): armour, then the occupying + // item, then the hull, near-to-far, clipped to the penetration depth from the impact cell's own entry. + private static DirectHitModel ModelDirectHit(Engagement e, Shape shape, double[,] armorBefore, + Dictionary itemsBefore, double bx, double by, double s, double damage, double penetration) + { + var r = new DirectHitModel { Armor = (double[,]) armorBefore.Clone(), Items = new Dictionary(itemsBefore) }; + var lane = ModelLane(shape, bx, by, s); + if (lane.Count == 0) { r.Hull = damage; return r; } + var rem = damage; var en0 = lane[0].En; + for (var i = 0; i < lane.Count; i++) + { + if (i > 0 && lane[i].En - en0 >= penetration) break; + var c = lane[i].C; + if (rem > 0) { var a = r.Armor[c.x, c.y]; r.Armor[c.x, c.y] = Math.Max(a - rem, 0); rem = Math.Max(rem - a, 0); } + if (rem > .1) + { + var item = e.Target.GearOccupancy[c.x, c.y]; + if (item != null) { var d = r.Items[item]; r.Items[item] = Math.Max(d - rem, 0); rem = Math.Max(rem - d, 0); } + } + } + r.Hull = rem; + return r; + } + + // F1: a full per-cell parity check against the independent model above, at three angled bearings -- 45 + // degrees, a shallow angle (within about 11 degrees of an axis) and one with |bx| > |by|. Kills a 0.5-cell + // walk (M4), a mirrored bearing when |bx| > |by| (M6b) and a bearing snapped to the nearest axis (M20): all + // three produce a visibly wrong set of cells or a wrong entry order against this model at these bearings. + [Fact] + public void DirectHitAtAnAngledBearingMatchesAnIndependentModel() + { + void Check(float2 travelDirection, int seed) + { + var shape = SolidShape(5, 5); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 3f); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + + var rng = new System.Random(seed); + foreach (var c in shape.Coordinates) + { + var a = (float) Math.Round(rng.NextDouble() * 6, 2); + e.Target.Armor[c.x, c.y] = a; e.Target.MaxArmor[c.x, c.y] = Math.Max(a, 1f); + } + // Equipment includes the ship's own EquippedHull -- excluded here, or randomising its durability + // would corrupt Hull.Durability itself, not a schematic-cell item. + foreach (var it in e.Target.Equipment) + if (it.EquippableItem != e.Target.Hull) + it.EquippableItem.Durability = (float) Math.Round(rng.NextDouble() * 15, 2); + + var armorBefore = ToD(e.Target.Armor); + var itemsBefore = e.Target.Equipment.Where(x => x.EquippableItem != e.Target.Hull) + .ToDictionary(x => x, x => (double) x.EquippableItem.Durability); + + ShotOutcome outcome = null; + double hullEv = 0; + using (e.Target.HullDamage.Subscribe(x => hullEv += x)) + { + for (var attempt = 0; attempt < 400 && (outcome == null || !outcome.Hit); attempt++) + outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + // fixture precondition: default facing makes the schematic bearing equal the world travel direction. + var actualTravel = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + Assert.Equal(travelDirection.x, actualTravel.x, 3); + Assert.Equal(travelDirection.y, actualTravel.y, 3); + + var model = ModelDirectHit(e, shape, armorBefore, itemsBefore, outcome.Bearing.x, outcome.Bearing.y, outcome.Lateral, 40, 3); + + foreach (var c in shape.Coordinates) + Assert.Equal(model.Armor[c.x, c.y], e.Target.Armor[c.x, c.y], 2); + foreach (var kv in model.Items) + Assert.Equal(kv.Value, (double) kv.Key.EquippableItem.Durability, 2); + Assert.Equal(model.Hull, hullEv, 2); + } + + Check(normalize(float2(1, 1)), seed: 1); // 45 degrees + Check(normalize(float2(.2f, 1)), seed: 2); // shallow: ~11 degrees off the y-axis + Check(normalize(float2(2, 1)), seed: 3); // |bx| > |by| + } + + // F1: the spec's own diagonal case (Probe1's corner-crossing lane), through the full Fire -> Commit -> + // Apply path rather than a direct Lane call. Retries until a hit's own ArmorDamage events show the lane + // walking exactly the three corner cells in order -- kills the same M4/M6b/M20 mutants as above from a + // different angle: a 0.5-cell march, a mirrored bearing or an axis-snapped bearing would each produce some + // other event sequence (or none matching), and 400 attempts would never find this exact one. + [Fact] + public void DiagonalDirectHitReachesEveryCornerCellInOrderThroughFireCommitApply() + { + var shape = SolidShape(3, 3); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 100f); // ample: never cuts the walk short + e.Shooter.Position = e.Target.Position - float3(1, 0, 1) * (100f / (float) Math.Sqrt(2)); // exactly 45 degrees + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + + // Everything bare except the three corner cells (light armour, so damage passes through every one of + // them into whatever comes next): a bearing this close to 45 degrees admits the diagonal's own row/column + // neighbours too (their shadow overlaps the corner at this angle -- confirmed by sweeping FireControl.Lane + // directly over s), so the walked lane is usually five cells, not three; the spec only requires the + // three corner cells to be present and in that relative order (Probe1's own "reaches (2,2)"), the same + // standard ARayThroughACornerCrossesTheCornerCell already pins for a direct Lane call. + void ResetArmor() + { + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[0, 0] = 5f; e.Target.MaxArmor[0, 0] = 5f; + e.Target.Armor[1, 1] = 5f; e.Target.MaxArmor[1, 1] = 5f; + e.Target.Armor[2, 2] = 5f; e.Target.MaxArmor[2, 2] = 5f; + } + ResetArmor(); + + var events = new List(); + using var sub = e.Target.ArmorDamage.Subscribe(x => events.Add(x.pos)); + var found = false; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 400 && !found; attempt++) + { + events.Clear(); + outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); + var i0 = events.IndexOf(new int2(0, 0)); + var i1 = events.IndexOf(new int2(1, 1)); + var i2 = events.IndexOf(new int2(2, 2)); + found = outcome != null && outcome.Hit && i0 >= 0 && i1 > i0 && i2 > i1; + if (!found) ResetArmor(); // a hit landing off the diagonal may have spent some of these cells' armour + } + Assert.True(found, "fixture: needed a hit whose lane reaches (0,0), then (1,1), then (2,2), in that order"); + + Assert.Equal(0f, e.Target.Armor[0, 0]); + Assert.Equal(0f, e.Target.Armor[1, 1]); + Assert.Equal(0f, e.Target.Armor[2, 2]); + } + + // ===================== F2: spread lanes diffuse sideways instead of overlapping ===================== + // Operator ruling (2026-09-25): "I would prefer if the overlapping damage cells were diffused sideways to + // thicken the line rather than doubling up." FireControl.Apply now spaces lanes by the shadow width a cell + // casts at the committed bearing rather than by one cell. These tests pin the resulting BEHAVIOUR -- no + // cell struck twice, mirrored damage, a footprint that widens at an angle -- not the spacing value itself + // (operator, correcting an earlier framing: "test behavior, not shape"). + + // No cell is struck by two lanes of one shot: heavy uniform armour so every lane's own impact cell fires + // exactly one ArmorDamage event if reached, and a cell reached by two lanes would fire twice. Reverting the + // lane spacing to one cell (Apply's pre-fix-batch spelling) fails this at both bearings, because a 45-degree + // (or |bx| > |by|) shadow is wider than one cell and adjacent one-cell-spaced lanes then share cells. + [Fact] + public void NoCellIsStruckByTwoLanesAtAnAngledBearing() + { + void Check(float2 travelDirection) + { + var shape = SolidShape(9, 9); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 0f, damageSpread: 2f); // 5 lanes + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + var hits = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x.pos)); + var outcome = FireUntilHit(e, damageOverride: 50f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.True(hits.Count > 1, $"expected several lanes to find metal, got {hits.Count}"); + Assert.Equal(hits.Count, hits.Distinct().Count()); // no cell fires ArmorDamage twice + } + + Check(normalize(float2(1, 1))); // 45 degrees + Check(normalize(float2(2, 1))); // |bx| > |by| + } + + // Mirror-image shots (opposite bearing across the hull's own vertical symmetry axis) deliver, on average, + // mirrored per-cell damage. Reverting the lane spacing to one cell fails this (Soul's own probe found about + // 30% asymmetry under it): whichever side's lanes happen to overlap eats extra armour that the mirrored + // shot's lanes, overlapping the other way, do not. + [Fact] + public void MirrorImageShotsProduceStatisticallyMirroredDamage() + { + var shape = SolidShape(9, 9); + var b = normalize(float2(1, 1)); + var bm = float2(-b.x, b.y); // mirrored across the hull's own vertical (x) symmetry axis + const int hitsWanted = 300; + + double[,] Sample(float2 travelDirection) + { + var e = Build(TestSettings(), shape, precision: 1f, penetration: 0f, damageSpread: 2f); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + var sums = new double[shape.Width, shape.Height]; + using var s = e.Target.ArmorDamage.Subscribe(x => sums[x.pos.x, x.pos.y] += x.damage); + var got = 0; + for (var attempt = 0; attempt < 3000 && got < hitsWanted; attempt++) + { + var outcome = FireUntilHit(e, damageOverride: 50f, attempts: 1); + if (outcome != null && outcome.Hit) got++; + } + Assert.True(got >= hitsWanted, $"fixture: needed {hitsWanted} hits, got {got}"); + return sums; + } + + var a = Sample(b); + var m = Sample(bm); + + double totalA = 0, totalDiff = 0; + for (var x = 0; x < shape.Width; x++) + for (var y = 0; y < shape.Height; y++) + { + totalA += a[x, y]; + totalDiff += Math.Abs(a[x, y] - m[shape.Width - 1 - x, y]); + } + Assert.True(totalDiff < 0.25 * totalA, + $"mirror shots should deliver mirrored damage on average: total {totalA}, mirror mismatch {totalDiff}"); + } + + // The footprint widens at an angled bearing rather than concentrating: the same spread, on the same hull, + // covers more lateral distance at 45 degrees than axis-aligned. Reverting the lane spacing to one cell + // removes this widening (every bearing gets the same one-cell spacing regardless of angle). + [Fact] + public void FootprintWidensAtAnAngledBearingComparedToAxisAligned() + { + double Span(float2 travelDirection) + { + var shape = SolidShape(9, 9); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 0f, damageSpread: 2f); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + var hits = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x.pos)); + var outcome = FireUntilHit(e, damageOverride: 50f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.True(hits.Count >= 3, $"fixture: needed several lanes to find metal, got {hits.Count}"); + + var b = normalize(travelDirection); + var ell = float2(-b.y, b.x); + var projections = hits.Select(c => (double) dot((float2) c, ell)).ToList(); + return projections.Max() - projections.Min(); + } + + var axisSpan = Span(float2(0, 1)); + var angledSpan = Span(normalize(float2(1, 1))); + + Assert.True(angledSpan > axisSpan * 1.2, $"the footprint should widen at an angled bearing: axis {axisSpan}, 45deg {angledSpan}"); + } } From be5382170d5c6674ab676ed48cb03073f7c6baed Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 12:17:59 +0200 Subject: [PATCH 024/116] Cut 12.3 fix batch: rework the F2 mirror test to compare hull totals, not per-cell sums MirrorImageShotsProduceStatisticallyMirroredDamage compared per-cell armour damage SUMS between a bearing and its mirror, using heavy (1000) uniform armour. That armour never depleted, so no cell's absorption ever competed with another lane's, and the test could not see the one thing the operator's ruling is about: two lanes racing for one cell's finite armour under the old one-cell spacing. Renamed to MirrorImageShotsProduceStatisticallyMirroredHullDamage, it now resets a small, finite armour value before every individual shot (so the race is live on every trial) and compares aggregate HULL damage, where a lost race's leftover actually lands. Finding, reported rather than hidden: even with this fix, the test does not fail under the reverted one-cell spacing. Reasoning checked against measured behaviour: FireControl.Apply always processes lanes in a fixed index order (li ascending, i.e. lateral increasing), never based on the bearing's sign. Over many INDEPENDENTLY random lateral draws, a bearing and its mirror sample statistically equivalent distributions of "which side's lane goes first" -- the fixed-index tie-break rule respects the mirror symmetry in aggregate, so independently-drawn trials wash out the order-dependent bias that Soul's own LaneOrderDemo probe found (that probe compares ascending vs descending processing of the SAME committed shot, not independently-drawn mirrored bearings). A production-level test that reliably fails under the old spacing for this specific property would need to force an exact, deterministic lateral for two geometrically mirrored shots, which is not reachable through Fire/Commit without reflection (Commit's lateral draw is randomised, and Apply is private). NoCellIsStruckByTwoLanesAtAnAngledBearing and FootprintWidensAtAnAngledBearingComparedToAxisAligned remain the two tests that actually discriminate the ruling behaviourally (confirmed failing under a hand-reverted one-cell spacing). The mirror test stays in the suite as a true, currently-passing invariant and useful regression coverage, not as a defense of this specific ruling. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut123Tests.cs | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 22e933c0f..9208a09eb 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -972,47 +972,46 @@ public sealed class FireControlCut123Tests : IDisposable } // Mirror-image shots (opposite bearing across the hull's own vertical symmetry axis) deliver, on average, - // mirrored per-cell damage. Reverting the lane spacing to one cell fails this (Soul's own probe found about - // 30% asymmetry under it): whichever side's lanes happen to overlap eats extra armour that the mirrored - // shot's lanes, overlapping the other way, do not. + // the same total hull damage. Two overlapping lanes are always processed in the SAME fixed index order + // (li = 0 upward, i.e. lateral increasing) regardless of which way the bearing points -- under the old + // one-cell spacing that meant whichever side's lanes happened to share a cell, the lane that lost the race + // for that cell's (finite) armour dumped its own leftover into the hull, and "lateral increasing" is the + // SAME physical side for a shot and its mirror only if the lanes never share a cell to begin with. Armour + // is reset to a small, finite value before every shot (not left to deplete across the sample) so the + // competition for a shared cell is live on every trial, and only hull damage is compared -- exactly where a + // lost race's leftover ends up, whether or not the cell itself still exists to compare position-for-position + // afterwards. [Fact] - public void MirrorImageShotsProduceStatisticallyMirroredDamage() + public void MirrorImageShotsProduceStatisticallyMirroredHullDamage() { var shape = SolidShape(9, 9); var b = normalize(float2(1, 1)); var bm = float2(-b.x, b.y); // mirrored across the hull's own vertical (x) symmetry axis - const int hitsWanted = 300; + const int hitsWanted = 400; - double[,] Sample(float2 travelDirection) + double SampleHullTotal(float2 travelDirection) { var e = Build(TestSettings(), shape, precision: 1f, penetration: 0f, damageSpread: 2f); e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; - foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } - var sums = new double[shape.Width, shape.Height]; - using var s = e.Target.ArmorDamage.Subscribe(x => sums[x.pos.x, x.pos.y] += x.damage); + double hullTotal = 0; + using var s = e.Target.HullDamage.Subscribe(x => hullTotal += x); var got = 0; - for (var attempt = 0; attempt < 3000 && got < hitsWanted; attempt++) + for (var attempt = 0; attempt < 4000 && got < hitsWanted; attempt++) { + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 8f; e.Target.MaxArmor[c.x, c.y] = 8f; } var outcome = FireUntilHit(e, damageOverride: 50f, attempts: 1); if (outcome != null && outcome.Hit) got++; } Assert.True(got >= hitsWanted, $"fixture: needed {hitsWanted} hits, got {got}"); - return sums; + return hullTotal; } - var a = Sample(b); - var m = Sample(bm); + var hullA = SampleHullTotal(b); + var hullM = SampleHullTotal(bm); - double totalA = 0, totalDiff = 0; - for (var x = 0; x < shape.Width; x++) - for (var y = 0; y < shape.Height; y++) - { - totalA += a[x, y]; - totalDiff += Math.Abs(a[x, y] - m[shape.Width - 1 - x, y]); - } - Assert.True(totalDiff < 0.25 * totalA, - $"mirror shots should deliver mirrored damage on average: total {totalA}, mirror mismatch {totalDiff}"); + Assert.True(Math.Abs(hullA - hullM) < 0.15 * Math.Max(hullA, hullM), + $"mirror shots should deliver the same total hull damage on average: {hullA} vs {hullM}"); } // The footprint widens at an angled bearing rather than concentrating: the same spread, on the same hull, From d301c0fbbde6891298434e655dab21a782b5740b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 13:22:09 +0200 Subject: [PATCH 025/116] 12.3 map: lanes are disjoint by construction, not by float spacing Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 08941d2b2..244fdcdd7 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2440,8 +2440,11 @@ import. - **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches unchanged: 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. - 2. The lanes are `Lateral + k` for `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + - .5f)` (today's rounding, `Entity.cs:389`), spaced one cell *shadow* apart along ℓ, i.e. `2h` (Q12-2 = A, amended 2026-09-25). + 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's + rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and + **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per + cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two + derivations disagreed at cell edges and struck a cell twice). 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. 4. Each lane walks from its own facing cell, clipped to the penetration depth, with `rem = target.Absorb(cell, rem)` at each cell. From 4e6495dd6393b8a4352d5f1acce5c5474f448392 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 14:06:38 +0200 Subject: [PATCH 026/116] Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 65 ++++++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 23440d409..2949ee8cd 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -605,6 +605,16 @@ public static class FireControl // one geometric quantity is exactly how that cut's crash happened. Because each cell's admitted lateral // interval is exactly this width wide and half-open, lanes this far apart can never both admit the same // cell: no cell is struck twice, and the footprint widens at angles instead of doubling up. + // F1 fix batch (Soul's second pass, 2026-09-25): the F2 spacing above is correct in exact arithmetic, but + // the loop that used to sit here still asked the wrong question in float: it called Lane() once per lane + // index, each call re-deriving that lane's own lateral s(li) = Lateral + (li - n) * laneSpacing by forward + // float addition, then testing s(li) against a cell's Extent(c, ell) interval independently of every other + // lane's test. Two lane lines are only ever exactly laneSpacing apart in real arithmetic; accumulating that + // offset by repeated addition can shift a side lane by an ulp or two, and a shifted s(li) can land inside a + // NEIGHBOURING cell's half-open interval that its own, unshifted s(li) was never meant to reach -- the same + // "two derivations of one quantity" defect Cut 12.2's own fix batch found and removed from Lane itself + // (S1 above), now recurring one layer up. See `Lanes` below, which decides each cell's lane membership with + // one computation instead of one comparison per (cell, lane) pair. private static void Apply(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -636,14 +646,12 @@ public static class FireControl var laneSpacing = shadowExtent.Hi - shadowExtent.Lo; var buffers = new LaneCell[laneCount][]; - var walked = new int[laneCount]; + for (var li = 0; li < laneCount; li++) + buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers); var metalLanes = 0; for (var li = 0; li < laneCount; li++) - { - buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); - walked[li] = Lane(hullData, bearing, shot.Outcome.Lateral + (li - n) * laneSpacing, buffers[li]); if (walked[li] > 0) metalLanes++; - } // The centre lane (li == n) always meets metal: it walks from the committed Lateral, and Commit's own // guard already proved Lane finds an occupied cell there (R3) -- metalLanes is never 0. @@ -668,6 +676,53 @@ public static class FireControl target.DamageHull(hullDamage); } + // F1 fix batch (Soul's second pass, 2026-09-25): lane membership decided ONCE per cell, replacing the old + // loop that called Lane() once per lane index and let each call test its own forward-accumulated lateral + // against a cell's interval independently of every other lane. Here every candidate cell computes its own + // lane index directly from its own (unmodified) Extent interval: k = ceil((Lo - centreLateral) / + // laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in [Lo, Hi) -- Lane's + // own half-open admission -- derived by one division and one Ceiling call from values neither multiplied + // nor summed by any other lane's arithmetic. A single Math.Ceiling call returns exactly one integer, so a + // cell can be assigned to at most one lane by construction, not by float luck: disjointness no longer + // depends on laneSpacing surviving repeated addition without drifting off a neighbour's boundary. + // When k == 0 this is the same real-valued condition Lane() tests for centreLateral itself + // (Lo <= centreLateral < Hi, i.e. Lo - centreLateral in (-laneSpacing, 0]), so the centre lane's cell set + // is unchanged from calling Lane(hull, bearing, centreLateral, ...) directly -- the 12.2 placement contract + // (HitsLandOnTheFacingEdge) still holds. laneSpacing is still used, unchanged, to place the admitted lane's + // own line for AlongBearing's slab solve -- geometry, not membership -- so a lane's entry/exit values and + // its walked-contiguity break are exactly as before. + private static int[] Lanes(HullData hull, float2 b, float centreLateral, int n, float laneSpacing, LaneCell[][] buffers) + { + var ell = Lateral(b); + var coords = hull.Shape.Coordinates; + var laneCount = 2 * n + 1; + var counts = new int[laneCount]; + for (var i = 0; i < coords.Length; i++) + { + var c = (float2) coords[i]; + var lateral = Extent(c, ell); + var k = (int) Math.Ceiling((lateral.Lo - centreLateral) / laneSpacing); + if (k < -n || k > n) continue; + var li = k + n; + var s = centreLateral + k * laneSpacing; + AlongBearing(c, b, ell, s, out var entry, out var exit); + buffers[li][counts[li]++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit, Projection = dot(c, b) }; + } + + var walked = new int[laneCount]; + for (var li = 0; li < laneCount; li++) + { + Array.Sort(buffers[li], 0, counts[li]); + walked[li] = counts[li] > 0 ? 1 : 0; + for (var i = 1; i < counts[li]; i++) + { + if (buffers[li][i].Entry > buffers[li][i - 1].Exit + 1e-4f) break; + walked[li]++; + } + } + return walked; + } + private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) { return new ShotOutcome From 742354c67a4fd4710acbae7875b073fa96630f7a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 14:07:26 +0200 Subject: [PATCH 027/116] Cut 12.3 fix batch: F3 rework, F4 delete unreachable mirror test F3 (medium, confirmed): NoCellIsStruckByTwoLanesAtAnAngledBearing used penetration 0 (impact cells only) and one fixed-seed shot per bearing, at only two bearings, so it survived R1e (one-cell spacing when |bx| > |by|), R1h (spacing 1/max(|bx|,|by|)), R1c (spacing * 0.9), and R1b (spacing * 0.99). Its own comment also falsely claimed a plain revert "fails this at both bearings" without ever checking a real revert. Reworked as NoCellIsStruckByTwoLanesInOneShotAtAnyBearing: seven bearings (exact axis, both 45-degree diagonals, |bx| > |by| and |bx| < |by| on both sides, all within the fixture's own FiringArc), 300 hits sampled per bearing at a wide targeting spread (so the committed Lateral's draw visits many points along the hull's shadow), and ample penetration. Hand-verified against fc123fix2-mut.log: reverting to one-cell spacing (R0) and R1c/R1e/R1h are all caught by the full suite -- but not by this test. F1's structural fix (this branch's earlier commit, FireControl.Lanes) assigns each cell to exactly one lane index by a single computation, so a wrong laneSpacing can misassign or drop a cell but can no longer make two lanes claim the same one; R0/R1c/R1e/R1h are instead caught by the pre-existing damage-shape tests (DirectHitAtAnAngledBearingMatchesAnIndependentModel, FootprintWidensAtAnAngledBearingComparedToAxisAligned, and others). R1b (a 1% narrowing) survives the whole 97-test FireControl suite under any of those tests, including this one, even at 5000 hits per bearing -- recorded here as an open survivor for Soul's own mutation pass, not claimed dead. This test's own job is now a standing regression guard on the disjointness invariant through the real Fire/Commit/Apply pipeline, not a killer of spacing-magnitude mutants (that job now belongs to the damage-shape tests). F4: deletes MirrorImageShotsProduceStatisticallyMirroredHullDamage. Soul showed it cannot fail on spacing -- it survives the full revert and every spacing mutant, because no per-shot mirror pair is reachable through Fire (LateralDraw's distribution reverses under mirroring, so a shot and its "mirror" are independently drawn samples, not a matched pair). It was meant to pin lane processing order under a shared-cell race; that question is folded into the F2 disjointness rule (a shared cell can no longer arise) and the pending F2 ruling on multi-cell item lane order, which remains on hold per this fix batch's brief. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut123Tests.cs | 115 +++++++++--------- 1 file changed, 56 insertions(+), 59 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 9208a09eb..0b2ebf4db 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -943,75 +943,72 @@ public sealed class FireControlCut123Tests : IDisposable // cell struck twice, mirrored damage, a footprint that widens at an angle -- not the spacing value itself // (operator, correcting an earlier framing: "test behavior, not shape"). - // No cell is struck by two lanes of one shot: heavy uniform armour so every lane's own impact cell fires - // exactly one ArmorDamage event if reached, and a cell reached by two lanes would fire twice. Reverting the - // lane spacing to one cell (Apply's pre-fix-batch spelling) fails this at both bearings, because a 45-degree - // (or |bx| > |by|) shadow is wider than one cell and adjacent one-cell-spaced lanes then share cells. + // F3 fix batch (Soul's second pass, 2026-09-25): reworked per Soul's finding that the original two-bearing, + // one-shot-per-bearing, zero-penetration version of this test could not fail on a broken lane spacing -- + // penetration 0 only ever reaches each lane's own impact cell (never a second cell along a lane, so a + // single-shot sample under-exercises the boundary), and one draw per bearing samples one point on the + // hull's shadow. (The old comment's claim that reverting the spacing "fails this at both bearings" was + // never checked against a real revert; it does not.) Now: many bearings (axis, 45 degrees both diagonals, + // and |bx| > |by| shallow angles), many hits sampled per bearing at a wide spread (so the committed + // Lateral's draw visits many different points along the hull's shadow), and ample penetration. Heavy + // uniform armour so every reached cell fires exactly one ArmorDamage event, and a cell reached by two lanes + // in the SAME shot fires twice. + // F1's own structural fix (`FireControl.Lanes`) assigns each cell to exactly one lane index by a single + // computation, so after that fix a wrong `laneSpacing` value can misassign a cell to the wrong lane or drop + // it, but can no longer make two lanes claim the SAME cell -- double-admission is not a reachable outcome + // of a bad spacing constant any more, only of a defect in the single-computation assignment itself. Hand + // verification (fc123fix2-mut.log) confirms this split: reverting the spacing to one cell (R0), or to a + // wrong formula (R1c/R1e/R1h, all `dotnet test`), is caught by the pre-existing damage-shape tests + // (`DirectHitAtAnAngledBearingMatchesAnIndependentModel`, `FootprintWidensAtAnAngledBearingComparedToAxisAligned`, + // and others) rather than by this test -- this test never failed for any of those four reverts, because the + // hull it drove genuinely was never struck twice under any of them. A 1% narrowing (R1b) survives the + // whole 97-test suite, including this one; recorded as an unresolved survivor rather than claimed dead (an + // honest gap, not a claimed kill), left for Soul's own mutation pass. This test's own job is narrower than + // that: it is a standing regression guard on the disjointness invariant itself, through the real + // Fire/Commit/Apply pipeline, so a future change that reintroduces per-lane admission testing (the shape of + // the original F1 defect) has a chance of being caught here even though it can no longer be manufactured by + // hand through a spacing-constant mutation. [Fact] - public void NoCellIsStruckByTwoLanesAtAnAngledBearing() + public void NoCellIsStruckByTwoLanesInOneShotAtAnyBearing() { - void Check(float2 travelDirection) + // Within the fixture's own FiringArc (170 degrees, i.e. +-85 of the shooter's forward): the exact axis + // (dead ahead), both 45-degree diagonals, and both |bx| > |by| and |bx| < |by| shallow angles on each + // side. A travel direction outside the arc never resolves a hit at all (the fixture's own limit, not + // this rule's), so it is not a useful bearing to sample here. + var bearings = new[] + { + float2(0, 1), // exact axis + normalize(float2(1, 1)), normalize(float2(-1, 1)), // 45 degrees + normalize(float2(2, 1)), normalize(float2(-2, 1)), // |bx| > |by| + normalize(float2(1, 2)), normalize(float2(-1, 2)) // |bx| < |by| + }; + const int hitsWanted = 300; + + foreach (var travelDirection in bearings) { var shape = SolidShape(9, 9); - var e = Build(TestSettings(), shape, precision: 1f, penetration: 0f, damageSpread: 2f); // 5 lanes - e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; - foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } - - var hits = new List(); - using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x.pos)); - var outcome = FireUntilHit(e, damageOverride: 50f); - Assert.NotNull(outcome); - Assert.True(outcome.Hit); - - Assert.True(hits.Count > 1, $"expected several lanes to find metal, got {hits.Count}"); - Assert.Equal(hits.Count, hits.Distinct().Count()); // no cell fires ArmorDamage twice - } - - Check(normalize(float2(1, 1))); // 45 degrees - Check(normalize(float2(2, 1))); // |bx| > |by| - } - - // Mirror-image shots (opposite bearing across the hull's own vertical symmetry axis) deliver, on average, - // the same total hull damage. Two overlapping lanes are always processed in the SAME fixed index order - // (li = 0 upward, i.e. lateral increasing) regardless of which way the bearing points -- under the old - // one-cell spacing that meant whichever side's lanes happened to share a cell, the lane that lost the race - // for that cell's (finite) armour dumped its own leftover into the hull, and "lateral increasing" is the - // SAME physical side for a shot and its mirror only if the lanes never share a cell to begin with. Armour - // is reset to a small, finite value before every shot (not left to deplete across the sample) so the - // competition for a shared cell is live on every trial, and only hull damage is compared -- exactly where a - // lost race's leftover ends up, whether or not the cell itself still exists to compare position-for-position - // afterwards. - [Fact] - public void MirrorImageShotsProduceStatisticallyMirroredHullDamage() - { - var shape = SolidShape(9, 9); - var b = normalize(float2(1, 1)); - var bm = float2(-b.x, b.y); // mirrored across the hull's own vertical (x) symmetry axis - const int hitsWanted = 400; - - double SampleHullTotal(float2 travelDirection) - { - var e = Build(TestSettings(), shape, precision: 1f, penetration: 0f, damageSpread: 2f); + var e = Build(TestSettings(), shape, precision: .02f, penetration: 100f, damageSpread: 2f); // ample penetration, 5 lanes; a wide spread samples many points along the shadow e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; - double hullTotal = 0; - using var s = e.Target.HullDamage.Subscribe(x => hullTotal += x); + var thisShot = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => thisShot.Add(x.pos)); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + var got = 0; - for (var attempt = 0; attempt < 4000 && got < hitsWanted; attempt++) + for (var attempt = 0; attempt < 20000 && got < hitsWanted; attempt++) { - foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 8f; e.Target.MaxArmor[c.x, c.y] = 8f; } - var outcome = FireUntilHit(e, damageOverride: 50f, attempts: 1); - if (outcome != null && outcome.Hit) got++; + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + thisShot.Clear(); + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride: 50f); + e.Zone.Update(.01f); // velocity 0 -> zero flight time, commits and resolves in this tick + if (outcome == null || !outcome.Hit) continue; + got++; + Assert.Equal(thisShot.Count, thisShot.Distinct().Count()); // no cell fires ArmorDamage twice within this shot } - Assert.True(got >= hitsWanted, $"fixture: needed {hitsWanted} hits, got {got}"); - return hullTotal; + Assert.True(got >= hitsWanted, $"fixture: needed {hitsWanted} hits at bearing ({travelDirection.x:R},{travelDirection.y:R}), got {got}"); } - - var hullA = SampleHullTotal(b); - var hullM = SampleHullTotal(bm); - - Assert.True(Math.Abs(hullA - hullM) < 0.15 * Math.Max(hullA, hullM), - $"mirror shots should deliver the same total hull damage on average: {hullA} vs {hullM}"); } // The footprint widens at an angled bearing rather than concentrating: the same spread, on the same hull, From cb71a3cfad28cc12d2d6ca55e45cd1ba2d54f18c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 14:24:55 +0200 Subject: [PATCH 028/116] Cut 12.3 fix batch: unify Lane and Lanes on one membership query Self's review found that commit 4e6495dd's fix for F1 replaced the per-lane forward-arithmetic defect with a second, still-independent membership computation: Lane's own half-open admission (Lo <= s < Hi, from a cell's own Extent) versus Lanes' ceil((cell.Lo - centreLateral) / laneSpacing) lane index. The two agree only when a cell's own (Hi - Lo) equals the externally supplied laneSpacing exactly in float -- the identical two-derivations trap Cut 12.2's own fix batch already found and removed from Lane itself, reintroduced one layer up. Confirmed reachable: on commit 4e6495dd, solid5 b=(0,1) s=0.49999997 n=1 leaves the ceil-based centre lane EMPTY while Lane(s) itself finds all 5 cells of the row -- breaking R3 ("the centre lane always meets metal", Commit's own guard) and the 12.2 placement contract. FireControl.Lanes is now built entirely out of FireControl.Lane: it calls Lane(hull, b, centreLateral + k * laneSpacing, ...) once per lane index, so Lane(s) is literally this function's n = 0 case rather than a parallel re-implementation of its rule. Commit's placement and Apply's damage split now read one function. The narrower question left -- can two individually correct Lane() calls, at two different lines, still walk the same cell? -- is resolved by ownership (the lane closest to the centre keeps a contested cell; the centre lane, k = 0, never loses one), not by a competing formula, so the centre lane's final cell set is always exactly Lane(centreLateral)'s own output. Lane() itself is unchanged (still called directly by Commit and by FireControlCut12Tests' edge-seed and interval-edge tests, all still green: HitsLandOnTheFacingEdge, LaneSeamIsHalfOpen, LaneNeverEmptiesAtAnIntervalEdge, Cut12-edge-68442709, Cut12-edge-64078070). Adds LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull: now that Lanes is a public, pure geometric query, the boundary sweep is a behavioural test of its own contract, no reflection -- over every cell's own shadow edge and float neighbours, at exact-axis, 45-degree, |bx| > |by| bearings and Soul's own two reproduction cases, spread 1-2: (a) no cell is walked by two lanes, (b) the centre lane equals a plain Lane(s) call, (c) the centre lane is never empty when the centre lateral is inside the hull's own shadow. Hand- confirmed to fail on 4e6495dd's ceil-based Lanes (property (b), the same input above) and to pass on this commit (fc123fix2-mut3.log). Adds CommittedCellIsTheFirstCellTheCentreLaneDamages: an end-to-end check through Fire -> Commit -> Apply (damageSpread 0, a single lane) that the committed outcome.Cell is the first cell Apply damages. Full suite: 286 tests, identical across 3 runs. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 93 +++++++---- .../FireControlCut123Tests.cs | 146 ++++++++++++++++-- 2 files changed, 190 insertions(+), 49 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 2949ee8cd..77250d825 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -613,8 +613,15 @@ public static class FireControl // offset by repeated addition can shift a side lane by an ulp or two, and a shifted s(li) can land inside a // NEIGHBOURING cell's half-open interval that its own, unshifted s(li) was never meant to reach -- the same // "two derivations of one quantity" defect Cut 12.2's own fix batch found and removed from Lane itself - // (S1 above), now recurring one layer up. See `Lanes` below, which decides each cell's lane membership with - // one computation instead of one comparison per (cell, lane) pair. + // (S1 above), now recurring one layer up. + // Self's review (2026-09-25): the first fix for this replaced the per-lane Lane() loop with a ceil-based + // index formula that computed lane membership from a cell's own Extent directly -- but that formula and + // Lane's own half-open test (Lo <= s < Hi) were still two separate computations, agreeing only when a + // cell's own (Hi - Lo) equalled laneSpacing exactly in float. That is the identical trap one level removed, + // not removed. `Lanes` below is the actual single owner: it calls Lane() itself once per lane index, so + // Commit's placement (Lane, the n = 0 case) and Apply's damage split (Lanes, every case) read one function, + // and resolves the resulting rare cross-lane collision by ownership rather than by a competing formula -- + // see `Lanes`. private static void Apply(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -676,49 +683,67 @@ public static class FireControl target.DamageHull(hullDamage); } - // F1 fix batch (Soul's second pass, 2026-09-25): lane membership decided ONCE per cell, replacing the old - // loop that called Lane() once per lane index and let each call test its own forward-accumulated lateral - // against a cell's interval independently of every other lane. Here every candidate cell computes its own - // lane index directly from its own (unmodified) Extent interval: k = ceil((Lo - centreLateral) / - // laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in [Lo, Hi) -- Lane's - // own half-open admission -- derived by one division and one Ceiling call from values neither multiplied - // nor summed by any other lane's arithmetic. A single Math.Ceiling call returns exactly one integer, so a - // cell can be assigned to at most one lane by construction, not by float luck: disjointness no longer - // depends on laneSpacing surviving repeated addition without drifting off a neighbour's boundary. - // When k == 0 this is the same real-valued condition Lane() tests for centreLateral itself - // (Lo <= centreLateral < Hi, i.e. Lo - centreLateral in (-laneSpacing, 0]), so the centre lane's cell set - // is unchanged from calling Lane(hull, bearing, centreLateral, ...) directly -- the 12.2 placement contract - // (HitsLandOnTheFacingEdge) still holds. laneSpacing is still used, unchanged, to place the admitted lane's - // own line for AlongBearing's slab solve -- geometry, not membership -- so a lane's entry/exit values and - // its walked-contiguity break are exactly as before. - private static int[] Lanes(HullData hull, float2 b, float centreLateral, int n, float laneSpacing, LaneCell[][] buffers) + // Self's fix (2026-09-25): the ONE public membership query behind both Commit's placement and Apply's + // damage split. Lane(s) is exactly this function's n = 0 case -- Lanes calls Lane(hull, b, centreLateral + + // k * laneSpacing, ...) once per lane index k, so a lane's own cell set can never disagree with what + // Lane(s) alone returns for that s: there is no second formula for "does this cell belong to this lane", + // only Lane's own half-open Extent test, called at 2n+1 different lines. This is what removes the trap a + // ceil-based index formula reintroduced one level up from Cut 12.2's own crash (see Apply's comment above): + // that formula and Lane's own test were still two computations, agreeing only when a cell's own (Hi - Lo) + // equalled the externally supplied laneSpacing exactly in float. + // What is left, once every lane's own result is individually exact, is a DIFFERENT and much narrower + // question: can two separate calls to Lane(), at two different (and individually correct) lines, still walk + // the SAME cell? Yes, rarely -- laneSpacing (F2's shadow width, one value shared by every lane) can differ + // by a few ulps from one specific cell's own computed (Hi - Lo), the same float drift Soul's second pass + // found. Lanes resolves that by ownership, not by a competing membership rule: a cell walked by more than + // one lane keeps the lane closest to the centre (ties are impossible -- equal |k| means the same lane), and + // the centre lane (k = 0) never loses a cell to a side lane. Its own final cell set is therefore always + // exactly Lane(centreLateral)'s own output, unmodified -- R3 (the centre lane always meets metal, Commit's + // own guard) and the 12.2 placement contract (HitsLandOnTheFacingEdge) hold structurally, not by chance. A + // side lane that cedes a cell simply omits it from its own walk; the walk's existing contiguity rule (a + // cell more than 1e-4 past the previous SURVIVING cell's exit ends the lane) then decides on its own whether + // the lane continues past the gap that leaves -- no new rule, the same one `Lane` already applies at a + // genuine gap. laneSpacing itself is untouched: it is still the one F2 value that places every lane's own + // query line. + public static int[] Lanes(HullData hull, float2 b, float centreLateral, int n, float laneSpacing, LaneCell[][] buffers) { - var ell = Lateral(b); - var coords = hull.Shape.Coordinates; var laneCount = 2 * n + 1; - var counts = new int[laneCount]; - for (var i = 0; i < coords.Length; i++) + var raw = new LaneCell[laneCount][]; + var rawCount = new int[laneCount]; + for (var li = 0; li < laneCount; li++) { - var c = (float2) coords[i]; - var lateral = Extent(c, ell); - var k = (int) Math.Ceiling((lateral.Lo - centreLateral) / laneSpacing); - if (k < -n || k > n) continue; - var li = k + n; + var k = li - n; var s = centreLateral + k * laneSpacing; - AlongBearing(c, b, ell, s, out var entry, out var exit); - buffers[li][counts[li]++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit, Projection = dot(c, b) }; + raw[li] = ArrayPool.Shared.Rent(hull.Shape.Coordinates.Length); + rawCount[li] = Lane(hull, b, s, raw[li]); + } + + // Ownership: whichever lane is closest to the centre keeps a cell two lanes both walked; the centre + // lane (k = 0, |k| = 0) can never be outranked. + var ownerLane = new Dictionary(); + for (var li = 0; li < laneCount; li++) + { + var k = Math.Abs(li - n); + for (var i = 0; i < rawCount[li]; i++) + { + var cell = raw[li][i].Cell; + if (!ownerLane.TryGetValue(cell, out var curLi) || Math.Abs(curLi - n) > k) + ownerLane[cell] = li; + } } var walked = new int[laneCount]; for (var li = 0; li < laneCount; li++) { - Array.Sort(buffers[li], 0, counts[li]); - walked[li] = counts[li] > 0 ? 1 : 0; - for (var i = 1; i < counts[li]; i++) + var w = 0; + for (var i = 0; i < rawCount[li]; i++) { - if (buffers[li][i].Entry > buffers[li][i - 1].Exit + 1e-4f) break; - walked[li]++; + if (ownerLane[raw[li][i].Cell] != li) continue; // ceded to a closer lane -- this lane's own gap + if (w > 0 && raw[li][i].Entry > buffers[li][w - 1].Exit + 1e-4f) break; + buffers[li][w++] = raw[li][i]; } + walked[li] = w; + ArrayPool.Shared.Return(raw[li]); } return walked; } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 0b2ebf4db..ea151fcf9 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -953,21 +953,137 @@ public sealed class FireControlCut123Tests : IDisposable // Lateral's draw visits many different points along the hull's shadow), and ample penetration. Heavy // uniform armour so every reached cell fires exactly one ArmorDamage event, and a cell reached by two lanes // in the SAME shot fires twice. - // F1's own structural fix (`FireControl.Lanes`) assigns each cell to exactly one lane index by a single - // computation, so after that fix a wrong `laneSpacing` value can misassign a cell to the wrong lane or drop - // it, but can no longer make two lanes claim the SAME cell -- double-admission is not a reachable outcome - // of a bad spacing constant any more, only of a defect in the single-computation assignment itself. Hand - // verification (fc123fix2-mut.log) confirms this split: reverting the spacing to one cell (R0), or to a - // wrong formula (R1c/R1e/R1h, all `dotnet test`), is caught by the pre-existing damage-shape tests - // (`DirectHitAtAnAngledBearingMatchesAnIndependentModel`, `FootprintWidensAtAnAngledBearingComparedToAxisAligned`, - // and others) rather than by this test -- this test never failed for any of those four reverts, because the - // hull it drove genuinely was never struck twice under any of them. A 1% narrowing (R1b) survives the - // whole 97-test suite, including this one; recorded as an unresolved survivor rather than claimed dead (an - // honest gap, not a claimed kill), left for Soul's own mutation pass. This test's own job is narrower than - // that: it is a standing regression guard on the disjointness invariant itself, through the real - // Fire/Commit/Apply pipeline, so a future change that reintroduces per-lane admission testing (the shape of - // the original F1 defect) has a chance of being caught here even though it can no longer be manufactured by - // hand through a spacing-constant mutation. + // F1's own structural fix (`FireControl.Lanes`, now built on top of `FireControl.Lane` itself -- see that + // function's own comment) makes double-admission unreachable from a bad `laneSpacing` value: a wrong + // spacing can misassign a cell to the wrong lane or drop it, but can no longer make two lanes claim the + // SAME cell. Hand verification (fc123fix2-mut.log) confirms this split: reverting the spacing to one cell + // (R0), or to a wrong formula (R1c/R1e/R1h, all `dotnet test`), is caught by the pre-existing damage-shape + // tests (`DirectHitAtAnAngledBearingMatchesAnIndependentModel`, + // `FootprintWidensAtAnAngledBearingComparedToAxisAligned`, and others) rather than by this test -- this test + // never failed for any of those four reverts, because the hull it drove genuinely was never struck twice + // under any of them. A 1% narrowing (R1b) survives the whole 97-test suite, including this one; recorded as + // an unresolved survivor rather than claimed dead (an honest gap, not a claimed kill), left for Soul's own + // mutation pass. This test's own job is narrower than that: it is a standing regression guard on the + // disjointness invariant itself, through the real Fire/Commit/Apply pipeline, so a future change that + // reintroduces per-lane admission testing (the shape of the original F1 defect) has a chance of being + // caught here even though it can no longer be manufactured by hand through a spacing-constant mutation. + + // ===================== F1 (Self's review): Lane and Lanes are one membership query ===================== + // Self's finding, 2026-09-25: the first F1 fix (commit 4e6495dd) removed the per-lane forward-arithmetic + // defect but replaced it with a SECOND membership formula -- lane index k = ceil((cell.Lo - centreLateral) + // / laneSpacing), agreeing with FireControl.Lane's own half-open admission (Lo <= s < Hi) only when a + // cell's own (Hi - Lo) equals laneSpacing exactly in float. `FireControl.Lanes` is now built entirely out + // of `FireControl.Lane` -- it calls Lane() once per lane index, so Lane(s) IS the n = 0 case, not a second + // implementation of it, and any remaining cross-lane collision (still possible, since laneSpacing is one + // shared value and one specific cell's own Hi - Lo can still drift a few ulps from it) is resolved by + // ownership (closest to the centre wins; the centre lane never loses), not by a competing formula. This + // test pins that contract directly through the PUBLIC FireControl.Lanes and FireControl.Lane, no reflection + // and no production Lane/Apply used to build an expected value -- the assertions are properties of the + // functions' own output: (a) no cell is ever walked by two lanes, (b) the centre lane's cells are always + // exactly what a plain Lane(s) call returns, (c) the centre lane is never empty when the centre lateral is + // inside the hull's own shadow (Extent/Lateral's documented formula -- ell = (-by, bx), h = (|ellx|+|elly|) + // / 2, interval = [dot(c,ell)-h, dot(c,ell)+h) -- is independently recomputed here purely to build candidate + // laterals). Hand-confirmed to fail on commit 4e6495dd's ceil-based Lanes (fc123fix2-mut3.log): property (b) + // fails partway through the deterministic sweep below (solid5, b=(0,1), s=0.49999997, n=1 -- the ceil + // formula leaves the centre lane empty while Lane(s) itself finds all 5 cells of the row, the exact "centre + // lane always meets metal" break the ceil design could cause). Soul's own two reproduction cases below + // happen to still agree on that specific ceil build; the sweep is what catches it. + [Fact] + public void LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull() + { + static float2 Ell(float2 b) => float2(-b.y, b.x); + static (float Lo, float Hi) CellExtent(int2 c, float2 ell) + { + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + var centre = dot((float2) c, ell); + return (centre - h, centre + h); + } + static float Spacing(float2 b) { var ell = Ell(b); return Math.Abs(ell.x) + Math.Abs(ell.y); } + + void Check(string name, Shape shape, float2 b, float s, int n) + { + var hull = new HullData { Name = name, HullType = HullType.Ship, Shape = shape }; + var spacing = Spacing(b); + var laneCount = 2 * n + 1; + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) buffers[li] = new LaneCell[shape.Coordinates.Length]; + var walked = FireControl.Lanes(hull, b, s, n, spacing, buffers); + + // (a) no cell appears in two lanes. + var seen = new HashSet(); + for (var li = 0; li < laneCount; li++) + for (var i = 0; i < walked[li]; i++) + Assert.True(seen.Add(buffers[li][i].Cell), + $"{name} b=({b.x:R},{b.y:R}) s={s:R} n={n}: cell {buffers[li][i].Cell} walked by two lanes (lane {li - n})"); + + // (b) the centre lane equals a plain Lane(s) call. + var solo = new LaneCell[shape.Coordinates.Length]; + var soloWalked = FireControl.Lane(hull, b, s, solo); + Assert.True(soloWalked == walked[n], $"{name} b=({b.x:R},{b.y:R}) s={s:R} n={n}: centre lane walked {walked[n]}, Lane(s) walked {soloWalked}"); + for (var i = 0; i < soloWalked; i++) + Assert.Equal(solo[i].Cell, buffers[n][i].Cell); + + // (c) the centre lane is never empty when s is inside the hull's own shadow. + var ell = Ell(b); + var insideShadow = shape.Coordinates.Any(c => { var (lo, hi) = CellExtent(c, ell); return s >= lo && s < hi; }); + if (insideShadow) + Assert.True(walked[n] > 0, $"{name} b=({b.x:R},{b.y:R}) s={s:R} n={n}: centre lane empty although s is inside the hull's own shadow"); + } + + // Soul's own concrete reproductions (SoulApply123b.Disjoint, 2026-09-25). + Check("solid5", SolidShape(5, 5), float2(0, 1), -0.50000006f, 1); + Check("solid9", SolidShape(9, 9), normalize(float2(-0.69478226f, 0.7192201f)), -5.619354f, 1); + + // A deterministic sweep at every cell's own shadow edge (Lo and Hi) and its immediate float neighbours, + // across several hulls, bearings (exact axis, 45 degrees, |bx| > |by|, and Soul's own angled example), + // and spreads 1-2. + foreach (var (name, shape) in new (string, Shape)[] { ("solid5", SolidShape(5, 5)), ("solid9", SolidShape(9, 9)), ("solid3x11", SolidShape(3, 11)) }) + { + var coords = shape.Coordinates; + foreach (var b in new[] { float2(0, 1), float2(1, 0), normalize(float2(1, 1)), normalize(float2(2, 1)), normalize(float2(-0.69478226f, 0.7192201f)) }) + { + var ell = Ell(b); + foreach (var c in coords) + { + var (lo, hi) = CellExtent(c, ell); + foreach (var edge in new[] { lo, hi, MathF.BitIncrement(lo), MathF.BitDecrement(lo), MathF.BitIncrement(hi), MathF.BitDecrement(hi) }) + for (var n = 1; n <= 2; n++) + Check(name, shape, b, edge, n); + } + } + } + } + + // F1 (Self's review): the committed Cell (Commit's own placement) is the first cell Apply damages in the + // centre lane -- end to end through Fire -> Commit -> Apply, no reflection. DamageSpread 0 (a single lane, + // the centre lane) so the first ArmorDamage event of the whole shot is unambiguously the centre lane's own + // first cell -- Apply's damage loop processes lanes index 0 upward, so with a spread this check would be + // reading a SIDE lane's own first event, not the centre lane's. Heavy uniform armour and ample penetration + // so that first event is well defined. + [Fact] + public void CommittedCellIsTheFirstCellTheCentreLaneDamages() + { + var bearings = new[] + { + float2(0, 1), normalize(float2(1, 1)), normalize(float2(-1, 1)), + normalize(float2(2, 1)), normalize(float2(1, 2)) + }; + foreach (var travelDirection in bearings) + { + var shape = SolidShape(9, 9); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 100f, damageSpread: 0f); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + var firstHit = new int2?[1]; + using var s = e.Target.ArmorDamage.Subscribe(x => { if (firstHit[0] == null) firstHit[0] = x.pos; }); + var outcome = FireUntilHit(e, damageOverride: 50f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.NotNull(firstHit[0]); + Assert.Equal(outcome.Cell, firstHit[0].Value); + } + } [Fact] public void NoCellIsStruckByTwoLanesInOneShotAtAnyBearing() { From 2d13541b9f3bea4fbeeeed9d712f34c944c04316 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 15:17:25 +0200 Subject: [PATCH 029/116] Cut 12.3 status: closed apart from the multi-cell item ruling Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 244fdcdd7..1be4b4b32 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2517,6 +2517,45 @@ import. - **Operator:** a smoke with a GT 3K or pswarm launcher into a LonginusX bow, then from a flank. The schematic display should pulse the facing edge only. +**Status (2026-09-25): closed apart from one pending operator ruling** (multi-cell items shared +between lanes, below). Landed on `codex/fire-control-12`: `70c3e0cc`..`73b15d9f` (cut and first +Stryker batch), `b82b7eab`..`be538217` (Soul pass 1 fixes and the lane-spacing ruling), +`4e6495dd`, `742354c6`, `cb71a3cf` (Soul pass 2 fixes). 286 tests; Unity batchmode clean at each +stage. + +What the three Soul passes found: +- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of + 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an + angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. + It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on + (Q12-2 amendment: no doubling up, the line thickens). +- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits + struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first + structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the + centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit + with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float + drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two + derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, + the `ceil` empty centre); the cure each time was one owner. +- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two + lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by + construction. Residual float effects (a side lane starting one cell deeper, a column falling + between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage + is never lost. + +**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike +exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% +of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing +ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since +it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. + +**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the +first-processed lane drains its durability and the second carries the leftover into its own deeper +cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on +a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs +from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. +Alternative: keep the processing order and pin it. + ### Cut 12.4. The detonation primitive - **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: From d1cc047a51d1f35e8a2aa2c638c1638fd2655688 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 15:28:42 +0200 Subject: [PATCH 030/116] Cut 12.3: shared multi-cell items absorb proportionally (operator ruling) Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 1be4b4b32..45a32a6e4 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2556,6 +2556,11 @@ a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommen from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. Alternative: keep the processing order and pin it. +**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell +item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane +carries on with a leftover in proportion to what it brought. The result must not depend on the order +lanes are processed; with no shared item it equals sequential absorption exactly. + ### Cut 12.4. The detonation primitive - **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: From 567579136a76ec3012ccb7758ff504904adf1f7a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 16:16:53 +0200 Subject: [PATCH 031/116] Cut 12.3 fix batch: proportional absorption for multi-cell items shared between lanes Operator ruling (2026-09-25): "proportional absorption; go". A multi-cell item struck by several lanes of one shot now absorbs their combined incoming damage (up to its own remaining durability) and each lane's leftover is in proportion to what it brought, replacing the old behaviour where whichever lane Apply happened to visit first drained the item and the next lane inherited whatever was left. Entity.Absorb is split into ArmorAbsorb (per-cell, never shared) and ItemAbsorb (the single-lane path), so FireControl.Apply can call armour immediately and defer item resolution only for a cell whose item is reachable by more than one lane this shot. FireControl.Apply gains a geometric survey (which items are reachable by >1 lane, decided before any damage is applied) and takes the untouched sequential walk (ApplySequential) whenever nothing is shared, or a new coordinated walk (ApplyWithSharedItems) that deposits each lane's post-armour remainder into a shared item's pool and resolves it once every contributing lane has arrived, splitting the leftover proportionally (sum-then-split, order-independent by construction). A deterministic tie-break (smallest first-contact Entry) breaks the rare "opposite order" deadlock two non-convex shared items can create between two lanes. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 54 ++++-- Assets/Scripts/ServerShared/FireControl.cs | 216 ++++++++++++++++++++- 2 files changed, 251 insertions(+), 19 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 5b95dd6df..86ec06d90 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -329,14 +329,12 @@ public abstract class Entity ? TargetItem.Value : null; - // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- moved verbatim from DamageSchematic's own - // per-cell body (Cut 3), now called once per lane cell (FireControl.Apply) instead of once per hit-shape - // cell with an even split. Armor absorbs up to its own value, then the occupying item once the remainder - // exceeds .1f, and whatever is left over is returned to the caller for the hull's own share (or the next - // cell down the lane, for a lane that keeps carrying a remainder is not this function's concern -- Apply's - // own loop does that). ArmorDamage/ItemDamage fire only for incoming > 0 -- a cell a spent lane still walks - // past (rem already 0) takes no event and no recorded hit. - public float Absorb(int2 cell, float damage) + // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of + // what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so + // Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per + // cell -- always local to one lane, since armour never spans cells -- while deferring the item phase for a + // cell whose item is shared with another lane in the same shot. ArmorDamage fires only for incoming > 0. + public float ArmorAbsorb(int2 cell, float damage) { var d = damage; @@ -348,21 +346,43 @@ public abstract class Entity d = max(d - prevArmor, 0); } - if (d > 0.1f) + return d; + } + + // Cut 12.3 fix batch (proportional multi-cell absorption): the item phase, split out of Absorb's own + // second half so a caller coordinating several lanes into one shared item (FireControl.Apply) can resolve + // that item's absorption once, from the lanes' combined incoming damage, instead of once per lane against + // whatever durability the previous lane left behind. Takes the item directly (not a cell) because the + // caller may already know it is resolving a specific item for several different cells/lanes at once. + // ItemDamage fires only once the post-armour remainder exceeds .1f, same threshold as before the split. + public float ItemAbsorb(EquippedItem item, float damage) + { + var d = damage; + + if (d > 0.1f && item != null) { - var item = GearOccupancy[cell.x, cell.y]; - if (item != null) - { - var prevItem = item.EquippableItem.Durability; - item.EquippableItem.Durability = max(prevItem - d, 0); - ItemDamage.OnNext((item, d)); - d = max(d - prevItem, 0); - } + var prevItem = item.EquippableItem.Durability; + item.EquippableItem.Durability = max(prevItem - d, 0); + ItemDamage.OnNext((item, d)); + d = max(d - prevItem, 0); } return d; } + // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- armour up to its own value, then the + // occupying item, then whatever is left over is returned to the caller for the hull's own share (or the + // next cell down the lane; a lane that keeps carrying a remainder is not this function's concern -- + // Apply's own loop does that). This is the single-lane path: FireControl.Apply calls ArmorAbsorb/ItemAbsorb + // directly instead, only when a cell's item is shared between lanes and needs their combined incoming + // damage resolved together; every other caller (an unshared cell, Splash) still goes through Absorb + // unchanged, so this function's own behaviour is untouched by the fix batch. + public float Absorb(int2 cell, float damage) + { + var d = ArmorAbsorb(cell, damage); + return ItemAbsorb(GearOccupancy[cell.x, cell.y], d); + } + // Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one // HullDamage event are unchanged. FireControl.Apply calls this once per resolved hit, with the summed // remainder every lane's own walk left over (Q12-3 = A: a lane's remainder goes to the hull wherever the diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 77250d825..98ca726e6 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -663,6 +663,55 @@ public static class FireControl // The centre lane (li == n) always meets metal: it walks from the committed Lateral, and Commit's own // guard already proved Lane finds an occupied cell there (R3) -- metalLanes is never 0. var damagePerLane = shot.Damage / metalLanes; + + // Cut 12.3 fix batch (2026-09-25, operator: "proportional absorption; go"): survey which items, if + // any, are within reach of more than one lane's own (penetration-clipped) walk -- cells are already + // disjoint between lanes (Lanes' own ownership rule), so only a multi-cell item can ever be reached by + // two different lanes at all. This is a purely geometric question (GearOccupancy and the penetration + // cutoff, neither reads remaining damage), so it can be decided up front, before any Absorb call, and + // the overwhelmingly common case -- no item shared this shot -- takes the untouched sequential walk + // below with no extra allocation. + Dictionary> lanesOf = null; + Dictionary itemKey = null; + for (var li = 0; li < laneCount; li++) + { + if (walked[li] == 0) continue; + var impactEntry0 = buffers[li][0].Entry; + for (var i = 0; i < walked[li]; i++) + { + if (i > 0 && buffers[li][i].Entry - impactEntry0 >= shot.Penetration) break; + var cell = buffers[li][i].Cell; + var item = target.GearOccupancy[cell.x, cell.y]; + if (item == null) continue; + lanesOf ??= new Dictionary>(); + itemKey ??= new Dictionary(); + if (!lanesOf.TryGetValue(item, out var list)) lanesOf[item] = list = new List(); + if (!list.Contains(li)) list.Add(li); + if (!itemKey.TryGetValue(item, out var key) || buffers[li][i].Entry < key) + itemKey[item] = buffers[li][i].Entry; + } + } + List sharedItems = null; + if (lanesOf != null) + foreach (var kv in lanesOf) + if (kv.Value.Count > 1) + (sharedItems ??= new List()).Add(kv.Key); + + var hullDamage = sharedItems == null + ? ApplySequential(target, shot, buffers, walked, laneCount, damagePerLane) + : ApplyWithSharedItems(target, shot, buffers, walked, laneCount, damagePerLane, lanesOf, sharedItems, itemKey); + + for (var li = 0; li < laneCount; li++) ArrayPool.Shared.Return(buffers[li]); + + target.DamageHull(hullDamage); + } + + // Cut 12.3's original walk, untouched: every lane in isolation, near to far, clipped to its own + // penetration depth, remainder to the hull. Taken whenever no item this shot is shared between lanes + // (the fix batch's own "exact when nothing is shared" contract) -- byte-for-byte the same calls, in the + // same order, as before the fix batch. + private static float ApplySequential(Entity target, PendingShot shot, LaneCell[][] buffers, int[] walked, int laneCount, float damagePerLane) + { var hullDamage = 0f; for (var li = 0; li < laneCount; li++) { @@ -677,10 +726,173 @@ public static class FireControl } hullDamage += rem; } - ArrayPool.Shared.Return(buffers[li]); + } + return hullDamage; + } + + // Cut 12.3 fix batch (2026-09-25, operator ruling "proportional absorption; go" -- docs/fire-control-cut.md, + // Cut 12.3 status): taken only when >=1 item is reachable by more than one lane of this shot. Walks every + // lane through its own private cells exactly as ApplySequential does (armour is never shared, so it is + // never coordinated -- Entity.ArmorAbsorb runs the moment a lane reaches a cell, same as before). A lane + // that reaches a SHARED item's cell instead deposits its own post-armour remainder into that item's pool + // and stalls; once every lane that can ever reach the item has deposited, the item absorbs the pool's SUM + // (up to its own remaining durability) and each depositing lane resumes with a leftover in proportion to + // what it brought -- a sum-then-split, so the result cannot depend on which lane happened to arrive first + // (the defect this fix batch replaces: sequential Absorb let the first lane drain the item and hand the + // second lane's own damage a leftover carved from an already-emptied item). One ItemDamage event fires per + // depositing lane, its own INCOMING contribution -- the same "incoming, not the post-clamp amount" + // convention Entity.ItemAbsorb and Entity.ArmorAbsorb already use for a single lane (an overkill hit + // against a nearly-dead item still reports the whole swing, not what little durability was left to take) -- + // matching the one-event-per-Absorb-call cardinality the unshared path already has. A lane's item + // durability floor and armour-first ordering are otherwise unchanged: this function only ever changes WHEN + // an item's absorption is decided and IN WHAT PROPORTION the leftover is split, never the + // armour-then-item-then-hull order along a single lane's own path. + // + // The opposite-order case: two lanes can cross two different shared (multi-cell, non-convex) items in + // reversed relative order -- lane A reaches item1 before item2, lane B reaches item2 before item1. Neither + // item can then be resolved strictly in each lane's own causal order (item1 needs lane B's arrival, which + // needs item2 resolved first; item2 needs lane A's arrival, which needs item1 resolved first) -- every lane + // still in play is stalled on some item, a genuine deadlock, not a scheduling accident. Broken + // deterministically and order-independently by resolving the stalled, not-yet-resolved item with the + // smallest key -- the minimum Entry (along-bearing depth) at which ANY lane can reach it, fixed purely by + // hull geometry and computed once up front, never by which lane the loop below happened to visit first -- + // using only the deposits collected so far. A lane that has not yet arrived at that item resumes normally; + // when its own walk later reaches the same (now-resolved) item, it finds `resolved` already contains it + // and takes the ordinary single-lane ItemAbsorb path against whatever durability the forced resolution + // left, the same path an unshared item always takes. This one deterministic tie-break is the only place a + // lane's own local ordering can end up inverted, and only for the geometrically exotic shape that makes + // strict per-lane causal order unsatisfiable for every lane at once. + private static float ApplyWithSharedItems(Entity target, PendingShot shot, LaneCell[][] buffers, int[] walked, + int laneCount, float damagePerLane, Dictionary> lanesOf, List sharedItems, + Dictionary itemKey) + { + var sharedSet = new HashSet(sharedItems); + var pointer = new int[laneCount]; + var rem = new float[laneCount]; + var impactEntry = new float[laneCount]; + var finished = new bool[laneCount]; + var blocked = new bool[laneCount]; + for (var li = 0; li < laneCount; li++) + { + finished[li] = walked[li] == 0; + if (!finished[li]) + { + rem[li] = damagePerLane; + impactEntry[li] = buffers[li][0].Entry; + } } - target.DamageHull(hullDamage); + var remainingContributors = new Dictionary(); + var pool = new Dictionary>(); + var resolved = new HashSet(); + foreach (var item in sharedItems) + { + remainingContributors[item] = lanesOf[item].Count; + pool[item] = new List<(int, float)>(); + } + + var hullDamage = 0f; + + void FinishLane(int li) + { + finished[li] = true; + hullDamage += rem[li]; + } + + void Resolve(EquippedItem item) + { + var contributions = pool[item]; + var totalIncoming = 0f; + for (var k = 0; k < contributions.Count; k++) totalIncoming += contributions[k].amount; + var durabilityBefore = item.EquippableItem.Durability; + var absorbed = min(totalIncoming, durabilityBefore); + if (absorbed > 0f) item.EquippableItem.Durability = max(durabilityBefore - absorbed, 0f); + // ItemDamage reports each lane's own INCOMING contribution, not its clamped/actual-absorbed share -- + // the same "incoming, not the post-clamp amount" convention Entity.ItemAbsorb and Entity.ArmorAbsorb + // already use for a single lane (an overkill hit against a nearly-dead item still reports the whole + // swing, not what little durability was left to take). One event per contributing lane, matching + // the one-event-per-Absorb-call cardinality the unshared path already has. + var fraction = totalIncoming > 0f ? absorbed / totalIncoming : 0f; + for (var k = 0; k < contributions.Count; k++) + { + var (lane, amount) = contributions[k]; + if (amount > 0f) target.ItemDamage.OnNext((item, amount)); + rem[lane] = amount - amount * fraction; + } + resolved.Add(item); + for (var k = 0; k < contributions.Count; k++) + { + var lane = contributions[k].lane; + blocked[lane] = false; + pointer[lane]++; + if (pointer[lane] >= walked[lane]) FinishLane(lane); + } + } + + bool AdvanceLane(int li) + { + var moved = false; + while (!finished[li] && !blocked[li]) + { + var i = pointer[li]; + if (i > 0 && buffers[li][i].Entry - impactEntry[li] >= shot.Penetration) + { + FinishLane(li); + moved = true; + break; + } + var cell = buffers[li][i].Cell; + rem[li] = target.ArmorAbsorb(cell, rem[li]); + moved = true; + var item = target.GearOccupancy[cell.x, cell.y]; + if (item != null && sharedSet.Contains(item) && !resolved.Contains(item)) + { + pool[item].Add((li, rem[li])); + remainingContributors[item]--; + blocked[li] = true; + if (remainingContributors[item] == 0) Resolve(item); + break; + } + if (item != null) rem[li] = target.ItemAbsorb(item, rem[li]); + pointer[li]++; + if (pointer[li] >= walked[li]) + { + FinishLane(li); + break; + } + } + return moved; + } + + bool AllFinished() + { + for (var li = 0; li < laneCount; li++) + if (!finished[li]) return false; + return true; + } + + while (!AllFinished()) + { + var progressed = false; + for (var li = 0; li < laneCount; li++) + if (!finished[li] && !blocked[li] && AdvanceLane(li)) progressed = true; + + if (!progressed) + { + EquippedItem victim = null; + var victimKey = float.PositiveInfinity; + foreach (var item in sharedItems) + { + if (resolved.Contains(item) || pool[item].Count == 0) continue; + var key = itemKey[item]; + if (key < victimKey) { victimKey = key; victim = item; } + } + if (victim == null) break; // unreachable: every unfinished lane is blocked on some pool with >=1 deposit + Resolve(victim); + } + } + + return hullDamage; } // Self's fix (2026-09-25): the ONE public membership query behind both Commit's placement and Apply's From b55894a02a1582e17ee0a1c15c4b638316e0ae9b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 16:17:13 +0200 Subject: [PATCH 032/116] Cut 12.3 fix batch: tests for proportional multi-lane item absorption F8 tests, through the real Fire/Commit/Apply pipeline (no reflection): - ProportionalAbsorptionSplitsTheLeftoverBetweenTheLanes / ...LeavesNothingWhenTheItemAbsorbsEverything: the ruling's own literal case (two lanes bringing D1=25/D2=15 onto one 2x1 item), both the partial- and full-absorption branches. - MirrorSymmetryHoldsWithASharedMultiCellItem: a symmetric hull and its own mirror-image fixture must produce the same sorted leftover pair. Fails on the pre-fix-batch code (commit d1cc047a) at itemDurability 20 ({0,20} vs {5,15}); a durability equal to one of the two D values turned out degenerate and was rejected first (see the test's own comment). - EveryAxisAlignedSpreadShotStrikesExactlyTwoNPlusOneFacingCells: Soul's own owed regression guard on lane spacing, independent of item sharing. Killed by hand-mutating FireControl.cs's laneSpacing by *.99f (R1b): 28/400 interior-column hits drop a facing column. - ModelSweepAgreesWithProductionIncludingSharedMultiCellItems: an independent model (armour-then-item-then-hull per lane, extended with the same survey/sum-then-split shape production uses) checked against production over a sweep of two hull sizes, three bearings and both 2x1 bars and non-convex L-shaped items (Build gains lBars/LShape for this). Tool-hardpoint items (Bar, Marker) can only occupy interior cells (Entity.ItemFits), so every fixture above needed a hull at least 3-4 cells deep -- shallower hulls (used in an earlier draft) fail marker/bar equip outright. Hand-verified against the M13 "lane_order_reversed" mutant Soul recorded as surviving on the pre-fix code (FireControl.cs, Lanes' own `var k = li - n`): MirrorSymmetryHoldsWithASharedMultiCellItem fails on the pre-fix code under that mutant and passes on this cut's code under the same mutant, confirming the new resolution is order-independent of which physical lane a buffer index happens to hold, not only of Apply's own loop order. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut123Tests.cs | 424 +++++++++++++++++- 1 file changed, 422 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index ea151fcf9..ed8e5fd79 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -81,10 +81,13 @@ public sealed class FireControlCut123Tests : IDisposable // returns target.Position exactly, and the shooter/target share the same world X) -- the same clean // geometry FireControlCut12Tests' own SigmaFloor fixture relies on. A full-strength targeting system // (Accuracy 1, Resolution huge) so only the fixture's own geometry decides the numbers. + private static Shape LShape() { var s = new Shape(2, 2); s[new int2(0, 0)] = true; s[new int2(1, 0)] = true; s[new int2(0, 1)] = true; return s; } + private Engagement Build( GameplaySettings settings, Shape hullShape, float precision, float penetration = 0, float damageSpread = 0, float2? targetFacing = null, - (int2 Cell, float Durability)[] markers = null, int2? cockpitCell = null, float cockpitDurability = 40) + (int2 Cell, float Durability)[] markers = null, int2? cockpitCell = null, float cockpitDurability = 40, + (int2 Cell, float Durability)[] bars = null, (int2 Cell, float Durability)[] lBars = null) { var hullData = new HullData { @@ -134,10 +137,25 @@ public sealed class FireControlCut123Tests : IDisposable MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new CockpitData() } }); + // A multi-cell item (2x1), for the proportional-absorption fix batch's own fixtures -- the only shape + // that can ever be shared between two lanes (cells are disjoint between lanes; only an item spanning + // several cells can straddle two of them). + cache.Upsert(new GearData + { + Name = "Bar", Hardpoint = HardpointType.Tool, Shape = SolidShape(2, 1), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + // A non-convex multi-cell item (an L: three cells of a 2x2 box), for F8's model sweep -- a straight + // lane can cross an L's two arms non-contiguously, which a plain 2x1 bar can never do. + cache.Upsert(new GearData + { + Name = "LBar", Hardpoint = HardpointType.Tool, Shape = LShape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); - for (var i = 1; i <= 24; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; // F8: shared-item fixtures equip more items (bars) than earlier cuts needed var items = new ItemManager(cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut123", Owner = null }, null); @@ -170,6 +188,19 @@ public sealed class FireControlCut123Tests : IDisposable return target.Equipment.Single(x => x.EquippableItem == mi); }).ToArray(); + if (bars != null) + foreach (var bar in bars) + { + var bi = Make("Bar", lot++, bar.Durability); + Assert.True(target.TryEquip(bi, bar.Cell), $"bar must fit at {bar.Cell}"); + } + if (lBars != null) + foreach (var bar in lBars) + { + var bi = Make("LBar", lot++, bar.Durability); + Assert.True(target.TryEquip(bi, bar.Cell), $"L-bar must fit at {bar.Cell}"); + } + EquippedItem cockpit = null; if (cockpitCell != null) { @@ -1158,4 +1189,393 @@ public sealed class FireControlCut123Tests : IDisposable Assert.True(angledSpan > axisSpan * 1.2, $"the footprint should widen at an angled bearing: axis {axisSpan}, 45deg {angledSpan}"); } + + // ===================== F8: proportional absorption when a multi-cell item is shared between lanes ===================== + // Operator ruling (2026-09-25, docs/fire-control-cut.md, Cut 12.3 status): "proportional absorption; go." + // When two or more lanes of one shot reach the same multi-cell item, the item absorbs their COMBINED + // incoming damage (up to its own remaining durability) and each lane's leftover is in proportion to what + // it brought -- replacing the old behaviour where whichever lane was processed first (always the + // lower-indexed, physically left-of-travel lane) drained the item and every other lane inherited whatever + // it left behind. + + // F8: the ruling's own literal case, through Fire/Commit/Apply (no reflection). Tool-hardpoint items + // (Bar, Marker) can only occupy INTERIOR cells (Entity.ItemFits, `hullData.InteriorCells[itemCoord]` -- + // Shape.Shrink needs all 8 neighbours present, the same constraint DamageIsAbsorbedInOrderAlongTheRay's own + // comment already notes for a single-cell marker on a 3x3 hull). A marker sitting BEHIND the shared item + // needs its own interior row too, so this fixture is 4 cells deep: SolidShape(5,4)'s two interior rows are + // y=1 (the Bar, columns 1-3) and y=2 (one marker behind each of the Bar's own two cells). Note + // Entity.ItemDamage reports each lane's own INCOMING contribution, not the clamped/actual-absorbed amount + // (the same convention a single Absorb call already has -- an overkill hit against a nearly-dead item still + // reports the whole swing), so it cannot distinguish proportional from sequential absorption by itself; + // the item's own durability (the ACTUAL total absorbed) and each marker's own durability (the ACTUAL + // leftover that reached it) are the two numbers this test reads. + // With damageSpread 1 retried until the committed impact is column 2 (the middle of the three interior + // columns), the lanes are columns {1,2,3}: the item's own two cells (1,1)/(2,1) are each a DIFFERENT lane's + // own facing cell, so armour placed directly on them (5 and 15) gives the two lanes different post-armour + // remainders even though Apply splits the shot's damage evenly per lane before armour: D1 = 30-5 = 25 + // (column 1), D2 = 30-15 = 15 (column 2). Column 3 carries no item, an unrelated private lane. + private (float leftoverLow, float leftoverHigh, float absorbed) RunSharedItemCase(float itemDurability) + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 1), itemDurability) }, + markers: new[] { (new int2(1, 2), 1000f), (new int2(2, 2), 1000f) }); + var bar = e.Target.GearOccupancy[1, 1]; // (1,1) and (2,1) are the same Bar + + void ResetState() + { + for (var x = 0; x < 5; x++) + for (var y = 0; y < 4; y++) + { + e.Target.Armor[x, y] = 0f; + e.Target.MaxArmor[x, y] = 20f; + } + e.Target.Armor[1, 1] = 5f; + e.Target.Armor[2, 1] = 15f; + bar.EquippableItem.Durability = itemDurability; + e.Markers[0].EquippableItem.Durability = 1000f; // behind (1,1) + e.Markers[1].EquippableItem.Durability = 1000f; // behind (2,1) + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + } + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 400 && (outcome == null || !outcome.Hit || outcome.Cell.x != 2); attempt++) + { + ResetState(); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 2, "fixture: needed a hit on the centre interior column within the attempt budget"); + + var leftover1 = 1000f - e.Markers[0].EquippableItem.Durability; // column 1's own leftover (D1=25) + var leftover2 = 1000f - e.Markers[1].EquippableItem.Durability; // column 2's own leftover (D2=15) + var absorbed = itemDurability - bar.EquippableItem.Durability; + return leftover1 <= leftover2 ? (leftover1, leftover2, absorbed) : (leftover2, leftover1, absorbed); + } + + // Item durability 30 < D1+D2 (40): the item takes all 30 of its own durability (absorbed = min(40,30)); the + // combined 10 leftover splits 25*(10/40)=6.25 and 15*(10/40)=3.75 -- proportional to what each lane + // brought, not to which lane Apply happens to visit first. Under the pre-fix-batch code (sequential, one + // lane draining the item before the other carries on) the two leftovers are not this pair at all -- see + // MirrorSymmetryHoldsWithASharedMultiCellItem below for the hand-confirmed pre-fix numbers on this exact + // shape of fixture. + [Fact] + public void ProportionalAbsorptionSplitsTheLeftoverBetweenTheLanes() + { + var (leftoverLow, leftoverHigh, absorbed) = RunSharedItemCase(itemDurability: 30f); + Assert.Equal(3.75f, leftoverLow, 2); // 15 * (10/40) + Assert.Equal(6.25f, leftoverHigh, 2); // 25 * (10/40) + Assert.Equal(30f, absorbed, 2); // the item's own durability, fully spent + } + + // Item durability 50 >= D1+D2 (40): the item takes everything either lane brought; nothing continues past + // it into either marker. + [Fact] + public void ProportionalAbsorptionLeavesNothingWhenTheItemAbsorbsEverything() + { + var (leftoverLow, leftoverHigh, absorbed) = RunSharedItemCase(itemDurability: 50f); + Assert.Equal(0f, leftoverLow, 2); + Assert.Equal(0f, leftoverHigh, 2); + Assert.Equal(40f, absorbed, 2); + } + + // F8: mirror symmetry with a multi-cell item, deterministic. Two fixtures on SolidShape(9,4) (interior rows + // y=1, the Bar's own row, and y=2, the marker row behind it -- see RunSharedItemCase above), fired straight + // ahead (bearing (0,1)) at damageSpread 1, each retried until the committed impact is the true centre + // column (x=4, as CommittedCellIsTheFirstCellTheCentreLaneDamages already relies on for this bearing), so + // the three lanes are always columns {3,4,5}. Fixture B's item and armour are fixture A's own layout + // reflected about column 4 (mirror(x) = 8-x) -- A's Bar spans {3,4} (centre lane shares it with the LEFT + // side lane); B's spans {4,5} (centre lane shares it with the RIGHT side lane), the mirror image of A's + // placement. Column 4 is the self-mirror point and carries the same armour (15) on both; column 3 (A, + // private) mirrors column 5 (B, private), both armour 2 (irrelevant to the assertion, just present so that + // lane is metal). + // + // Compares the SORTED list of each fixture's own two marker leftovers -- not the bearing or the lateral + // draw (which would also have to mirror the float lane-spacing arithmetic to stay exact). A's two D values + // are 25 (column 3) and 15 (column 4); B's are 15 (column 4) and 25 (column 5) -- the same two D values on + // both fixtures, so the proportional rule (sum then split, independent of which physical lane is which) + // gives the SAME sorted leftover pair on both. Item durability 20 (not equal to either D -- 25 was tried + // first and turned out degenerate: durability exactly equal to D_high made both fixtures land on {0,15} by + // coincidence under the pre-fix code too, since one contributor is always driven to exactly 0 either way). + // Hand-confirmed against commit d1cc047a (this cut's own parent, before the fix batch) on this exact + // fixture shape at itemDurability 20: A's leftovers sorted {0, 20} vs B's sorted {5, 15} -- different, so + // the assertion below fails there. See this cut's own report for the raw per-fixture numbers. + [Fact] + public void MirrorSymmetryHoldsWithASharedMultiCellItem() + { + const float itemDurability = 20f; + + (float low, float high) RunFixture(bool mirrored) + { + var barAnchor = mirrored ? new int2(4, 1) : new int2(3, 1); + var markerCells = mirrored + ? new[] { (new int2(4, 2), 1000f), (new int2(5, 2), 1000f) } + : new[] { (new int2(3, 2), 1000f), (new int2(4, 2), 1000f) }; + var e = Build(TestSettings(), SolidShape(9, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (barAnchor, itemDurability) }, markers: markerCells); + var bar = e.Target.GearOccupancy[barAnchor.x, barAnchor.y]; + + void ResetState() + { + for (var x = 0; x < 9; x++) + for (var y = 0; y < 4; y++) + { + e.Target.Armor[x, y] = 0f; + e.Target.MaxArmor[x, y] = 20f; + } + if (!mirrored) { e.Target.Armor[3, 1] = 5f; e.Target.Armor[4, 1] = 15f; e.Target.Armor[5, 1] = 2f; } + else { e.Target.Armor[4, 1] = 15f; e.Target.Armor[5, 1] = 5f; e.Target.Armor[3, 1] = 2f; } + bar.EquippableItem.Durability = itemDurability; + e.Markers[0].EquippableItem.Durability = 1000f; + e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + } + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 600 && (outcome == null || !outcome.Hit || outcome.Cell.x != 4); attempt++) + { + ResetState(); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 4, "fixture: needed a hit on the true centre column within the attempt budget"); + + var l0 = 1000f - e.Markers[0].EquippableItem.Durability; + var l1 = 1000f - e.Markers[1].EquippableItem.Durability; + return l0 <= l1 ? (l0, l1) : (l1, l0); + } + + var a = RunFixture(mirrored: false); + var b = RunFixture(mirrored: true); + + Assert.Equal(a.low, b.low, 2); + Assert.Equal(a.high, b.high, 2); + } + + // F8 (Soul's owed test, "Owed with the pending ruling's batch" in docs/fire-control-cut.md, Cut 12.3 + // status): many axis-aligned spread-n shots on a wide hull, each must strike exactly 2n+1 facing cells -- + // no column dropped. This is a standing regression guard on the lane-spacing invariant itself (independent + // of the proportional-absorption fix batch's own item coordination), pinned through the real + // Fire/Commit/Apply pipeline. Hand-confirmed to fail with R1b (lane spacing narrowed by 1%, + // `laneSpacing = (shadowExtent.Hi - shadowExtent.Lo) * .99f` at FireControl.cs) because that narrowing + // eventually drops a column at a spacing-boundary lateral -- see this cut's own report for the exact + // FireControl.cs edit and count. + [Fact] + public void EveryAxisAlignedSpreadShotStrikesExactlyTwoNPlusOneFacingCells() + { + var e = Build(TestSettings(), SolidShape(15, 2), precision: .02f, penetration: 0f, damageSpread: 3f); // n=3 -> 7 lanes + for (var x = 0; x < 15; x++) + for (var y = 0; y < 2; y++) + { + e.Target.Armor[x, y] = 1000f; + e.Target.MaxArmor[x, y] = 1000f; + } + + var got = 0; + var shortfalls = 0; + for (var attempt = 0; attempt < 8000 && got < 400; attempt++) + { + var hits = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x.pos)); + var outcome = FireUntilHit(e, damageOverride: 70f, attempts: 1); + if (outcome == null || !outcome.Hit) continue; + // Only impacts safely inside the hull count: within 3 columns of either edge, a side lane + // legitimately runs off the hull (fewer than 7 facing columns is correct there, not a spacing + // defect) -- restricting to the interior keeps this test about the spacing invariant alone. + if (outcome.Cell.x < 3 || outcome.Cell.x > 11) continue; + got++; + if (hits.Select(h => h.x).Distinct().Count() != 7) shortfalls++; // 2*3+1 facing columns, one hit each + } + Assert.True(got >= 400, $"fixture: needed 400 interior-column hits, got {got}"); + Assert.Equal(0, shortfalls); + } + + // ===================== F8: the model sweep, extended with the proportional rule ===================== + // Ports SoulApply123b's own independent model (armour, then item, then hull, near-to-far per lane, + // ModelLane above -- the exact double-precision ray/box walk F1's own DirectHitAtAnAngledBearingMatches... + // test already uses) and extends it with the proportional multi-lane item rule this cut adds: an item + // touched by more than one lane absorbs their SUMMED post-armour incoming (up to its own durability), each + // lane's leftover in proportion to what it brought. Structured the same way as FireControl.Apply's own new + // code (a survey of which items are geometrically shared, then an AdvanceLane/Resolve loop) -- not because + // a model must mirror production, but because it is the simplest correct way to get an item's resolution + // order-independent of which lane the loop visits first, which is exactly the property under test. Compares + // against production Apply through Fire/Commit/Apply (no reflection), over a sweep of shapes, bearings + // (axis and angled) and item shapes (2x1 bars and non-convex L-shaped items, LShape above). + private static (double[,] armor, Dictionary items, double hull) ModelWithSharedItems( + Entity target, Shape shape, double[,] armorBefore, Dictionary itemsBefore, + double bx, double by, double s, double damage, double penetration, int n) + { + var armor = (double[,]) armorBefore.Clone(); + var items = new Dictionary(itemsBefore); + var laneSpacing = Math.Abs(bx) + Math.Abs(by); + var laneCount = 2 * n + 1; + var lanes = new List[laneCount]; + for (var li = 0; li < laneCount; li++) lanes[li] = ModelLane(shape, bx, by, s + (li - n) * laneSpacing); + var metal = lanes.Count(l => l.Count > 0); + var per = damage / metal; + + var pointer = new int[laneCount]; + var rem = new double[laneCount]; + var finished = new bool[laneCount]; + var blocked = new bool[laneCount]; + for (var li = 0; li < laneCount; li++) { finished[li] = lanes[li].Count == 0; rem[li] = per; } + + // Survey: which items are geometrically reachable by more than one distinct lane, independent of any + // remaining damage -- exactly the question FireControl.Apply's own survey asks. + var lanesOf = new Dictionary>(); + for (var li = 0; li < laneCount; li++) + { + if (lanes[li].Count == 0) continue; + var en0 = lanes[li][0].En; + for (var i = 0; i < lanes[li].Count; i++) + { + if (i > 0 && lanes[li][i].En - en0 >= penetration) break; + var item = target.GearOccupancy[lanes[li][i].C.x, lanes[li][i].C.y]; + if (item == null) continue; + if (!lanesOf.TryGetValue(item, out var list)) lanesOf[item] = list = new List(); + if (!list.Contains(li)) list.Add(li); + } + } + var sharedSet = new HashSet(lanesOf.Where(kv => kv.Value.Count > 1).Select(kv => kv.Key)); + var remainingContributors = sharedSet.ToDictionary(it => it, it => lanesOf[it].Count); + var pool = sharedSet.ToDictionary(it => it, it => new List<(int lane, double amount)>()); + var resolved = new HashSet(); + var hull = 0.0; + + void FinishLane(int li) { finished[li] = true; hull += rem[li]; } + + void Resolve(EquippedItem item) + { + var contributions = pool[item]; + var total = contributions.Sum(c => c.amount); + var before = items[item]; + var absorbed = Math.Min(total, before); + items[item] = Math.Max(before - absorbed, 0); + var fraction = total > 0 ? absorbed / total : 0; + foreach (var (lane, amount) in contributions) rem[lane] = amount - amount * fraction; + resolved.Add(item); + foreach (var (lane, _) in contributions) + { + blocked[lane] = false; + pointer[lane]++; + if (pointer[lane] >= lanes[lane].Count) FinishLane(lane); + } + } + + bool AdvanceLane(int li) + { + var moved = false; + while (!finished[li] && !blocked[li]) + { + var i = pointer[li]; + if (i > 0 && lanes[li][i].En - lanes[li][0].En >= penetration) { FinishLane(li); moved = true; break; } + var c = lanes[li][i].C; + if (rem[li] > 0) { var a = armor[c.x, c.y]; armor[c.x, c.y] = Math.Max(a - rem[li], 0); rem[li] = Math.Max(rem[li] - a, 0); } + moved = true; + var item = target.GearOccupancy[c.x, c.y]; + if (item != null && rem[li] > .1 && sharedSet.Contains(item) && !resolved.Contains(item)) + { + pool[item].Add((li, rem[li])); + remainingContributors[item]--; + blocked[li] = true; + if (remainingContributors[item] == 0) Resolve(item); + break; + } + if (item != null && rem[li] > .1) + { + var d = items[item]; + items[item] = Math.Max(d - rem[li], 0); + rem[li] = Math.Max(rem[li] - d, 0); + } + pointer[li]++; + if (pointer[li] >= lanes[li].Count) { FinishLane(li); break; } + } + return moved; + } + + bool AllFinished() { for (var li = 0; li < laneCount; li++) if (!finished[li]) return false; return true; } + + while (!AllFinished()) + { + var progressed = false; + for (var li = 0; li < laneCount; li++) + if (!finished[li] && !blocked[li] && AdvanceLane(li)) progressed = true; + if (!progressed) + { + // Deadlock fallback (the "opposite order" case, docs/fire-control-cut.md Cut 12.3 status): not + // exercised by this sweep's own single-shared-item fixtures, kept only for parity with + // production's own tie-break so an unexpectedly non-convex draw cannot hang the model. + EquippedItem victim = null; + foreach (var item in sharedSet) + if (!resolved.Contains(item) && pool[item].Count > 0) { victim = item; break; } + if (victim == null) break; + Resolve(victim); + } + } + + return (armor, items, hull); + } + + [Fact] + public void ModelSweepAgreesWithProductionIncludingSharedMultiCellItems() + { + var rng = new System.Random(20260925); + // Height 4: an L-shaped item's 2x2 bounding box needs TWO consecutive interior rows (rows 1 and 2 for + // height 4 -- Entity.ItemFits requires every one of an item's own cells to be an interior cell, the + // same constraint RunSharedItemCase's own comment explains); a plain 2x1 bar only needs one but is kept + // on the same row for a uniform itemY across both item shapes. + var shapes = new[] { SolidShape(9, 4), SolidShape(7, 4) }; + var bearings = new[] { float2(0, 1), normalize(float2(1, 1)), normalize(float2(2, 1)) }; + var trials = 0; + + foreach (var shape in shapes) + foreach (var travelDirection in bearings) + { + for (var t = 0; t < 15; t++) + { + var useLBar = t % 2 == 0; + var itemX = 1 + rng.Next(shape.Width - 3); + const int itemY = 1; // the only row that is interior with room for a 2-row-tall L above it (height 4) + var durability = (float) (5 + rng.NextDouble() * 20); + var e = useLBar + ? Build(TestSettings(), shape, precision: 1f, penetration: 3f, damageSpread: 2f, + lBars: new[] { (new int2(itemX, itemY), durability) }) + : Build(TestSettings(), shape, precision: 1f, penetration: 3f, damageSpread: 2f, + bars: new[] { (new int2(itemX, itemY), durability) }); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + + foreach (var c in shape.Coordinates) + { + var a = (float) Math.Round(rng.NextDouble() * 6, 2); + e.Target.Armor[c.x, c.y] = a; e.Target.MaxArmor[c.x, c.y] = Math.Max(a, 1f); + } + + var armorBefore = ToD(e.Target.Armor); + var itemsBefore = e.Target.Equipment.Where(x => x.EquippableItem != e.Target.Hull) + .ToDictionary(x => x, x => (double) x.EquippableItem.Durability); + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 400 && (outcome == null || !outcome.Hit); attempt++) + outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); + if (outcome == null || !outcome.Hit) continue; // fixture: rare miss exhaustion, skip this draw + trials++; + + const int n = 2; // damageSpread 2 -> floor(2.5) = 2 + var model = ModelWithSharedItems(e.Target, shape, armorBefore, itemsBefore, + outcome.Bearing.x, outcome.Bearing.y, outcome.Lateral, 40, 3, n); + + // Absolute tolerance rather than a fixed decimal-place rounding: the model runs in double and + // production in float, so a value that lands within a float ULP or two of a rounding boundary + // (e.g. 1.54999995 vs 1.55000019) can round to different digits at 1-2 decimal places despite + // agreeing far more closely than that -- exactly the kind of float-vs-double drift F1's own + // DirectHitAtAnAngledBearingMatchesAnIndependentModel test already tolerates with its own `, 2`. + foreach (var c in shape.Coordinates) + Assert.True(Math.Abs(model.armor[c.x, c.y] - e.Target.Armor[c.x, c.y]) < .1, + $"armor{c}: model {model.armor[c.x, c.y]:F4} production {e.Target.Armor[c.x, c.y]:F4}"); + foreach (var kv in model.items) + Assert.True(Math.Abs(kv.Value - (double) kv.Key.EquippableItem.Durability) < .1, + $"item#{kv.Key.GetHashCode()}: model {kv.Value:F4} production {kv.Key.EquippableItem.Durability:F4}"); + } + } + Assert.True(trials >= 80, $"fixture: needed at least 80 of the 90 possible resolved trials, got {trials}"); // 2 shapes * 3 bearings * 15 draws + } } From 14ee2a52ca07d9e6ca7a3745f49b83fcb3c0442a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 16:22:44 +0200 Subject: [PATCH 033/116] Cut 12.3: one apply path for all items (operator ruling) Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 45a32a6e4..5aa4c435c 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2559,7 +2559,14 @@ Alternative: keep the processing order and pin it. **Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane carries on with a leftover in proportion to what it brought. The result must not depend on the order -lanes are processed; with no shared item it equals sequential absorption exactly. +lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. + +**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an +exception, this should be one code path." The first implementation (`56757913`) surveyed each shot +for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split +authority, and the second reachability derivation it needed is the same failure as the 12.2 empty +lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of +the lanes that reach it, a pool of one lane included. The survey and the sequential path die. ### Cut 12.4. The detonation primitive From 3cb6b1546edc4109bc629f8bcf1134cf79fcc3af Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 16:33:24 +0200 Subject: [PATCH 034/116] Cut 1: add the restore-hulls command (docs/locomotion-cut.md) One-shot restore of Longinus, Djinni and their five thruster designs from the decoded 2021-04-14 legacy record, in the shape of the existing *-migrate [apply] commands: dry run by default, writes every record in one Commit, refuses a second apply. Longinus's scalar fields and ReflectorData come from LonginusX (confirmed byte-identical against the decoded legacy record); its hardpoint layout is authored directly from the legacy decode, since LonginusX's own interior was rearranged to fit its AetherDrive. Djinni is authored entirely from the decode (no live twin exists). Every legacy PerformanceStat's [Min, Max, HeatExp, DurabilityExp, QualityExp] is translated into today's Min/Max/Terms shape, never copied. Authored post-breach fields (roles, FactionProductData, PowerSupply terms, FiringArc) are covered in the command's own header comment for operator review. Deleted in a later commit of this same cut once landed. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 361 +++++++++++++++++++++++++++++++++++++- 1 file changed, 360 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 46aae9575..89d209af5 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,8 +36,9 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); + case "restore-hulls": return RestoreHulls(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], restore-hulls [apply]"); return 1; } } @@ -540,6 +541,364 @@ public static class Program return missing.Count; } + // Cut 1 (docs/locomotion-cut.md, "Restore the legacy thruster ship hulls to the live catalog"). Restores + // Longinus and Djinni plus their five thruster designs, decoded from the git-LFS 2021-04-14 legacy record + // (docs' own probe: Longinus/Djinni byte-identical on every field this command carries forward). One-shot, + // in the shape of ShieldMigrate/BrownoutMigrate/RolesMigrate/FiringArcMigrate: dry run unless "apply", opens + // the catalog writable only then, writes every record in one Commit, and refuses to run twice (Longinus + // already present is the refusal signal). Deleted in a later commit of this same cut once landed + // (docs/locomotion-cut.md: "so its carrying cost is zero once it has run"). + // + // Authored choices the map did not name, each printed below for operator review: + // - Longinus's scalar fields (shape/mass/price/drag/durability/temp band/armor/conductivity/prefab/ + // schematic) and its ReflectorData are taken from LonginusX, confirmed byte-identical by decoding the + // legacy record directly. Its HARDPOINT LAYOUT is not: LonginusX's own interior was rearranged to fit + // its AetherDrive hardpoint (its live Radiator L/R and Reactor sit at different cells than the legacy + // Longinus's), which first surfaced as a real hardpoint-grid collision (Th.L/Th.R at (1,0)/(3,0) + // overlapping LonginusX's own Reactor at (2,1)). Longinus's 13 hardpoints are authored directly from + // the decoded 2021-04-14 record instead. + // - The map states both hulls share "50..50, legacy exponents 1, 0, 1.5" for VelocityLimitData.TopSpeed. + // The decoded legacy record contradicts this for Longinus: its own TopSpeed is 100..100, exponents + // [heat 1, durability 0, quality 0]. Only Djinni actually carries 50..50/[1,0,1.5]. This command uses + // each hull's own decoded value, not the map's blanket claim. + // - Djinni's temperature band (200-400) and heat curve are legacy-authored, but the original curve's + // control points were not decoded (out of scope to reverse-engineer the historical Bezier fit); Optimal + // (250) and PlateauWidth (24) are derived by applying Longinus/LonginusX's own optimal-fraction and + // plateau/range ratio to Djinni's band, not a real re-fit of the legacy curve. + // - FactionProductData: Longinus -> Alakrita, Victoire -> Alakrita, Djinni -> Rossum & Douglas, Talaria -> + // Aeronautics Unlimited, RevvITup 2.0 -> NiteLife Energy (all per the map). Medium Drive and Small Drive + // carry no legacy maker; Lightsail Express is chosen because it already sells the catalog's other + // maker-less Thruster-hardpoint product, the closest existing precedent for an unbranded drive. + // - ItemRole/ProductRole: thrusters get ["injector", "nozzle"] with Thrust/Visibility -> nozzle and + // Heat/EnergyUsage -> injector (RolesMigrate's own ThrusterRoles map); hulls get ["plating"] with no + // stat term pointed at it (ship hulls carry no CrossSection-shaped stat to point HullShipRoles at). + // - PowerSupply: each new thruster's Thrust stat gets a PowerSupply term (exponent 1), matching + // BrownoutMigrate's existing convention for every shipped thruster. Victoire's legacy EnergyUsage is + // NOT zero ([4, 2, 0.125, 0.125, 1], i.e. a real, quality-shrinking energy draw) -- unlike the map's + // "shipped thrusters request zero energy" assumption, which holds for the other four. Victoire's + // EnergyUsage is translated as decoded rather than forced to zero. + // - Djinni's Ballistic/Launcher hardpoints all decode with ItemRotation.None (forward-facing) and get no + // FiringArc override (stay at the 0 default -> GameplaySettings.FiringArc): firing-arc-migrate's own + // rule only widens an omnidirectional turret mount to 360, and nothing about a fixed forward mount + // calls for deviating from the default every other non-turret hull's weapon hardpoint already uses. + private static PerformanceStat TranslateLegacyStat(float min, float max, float heatExp, float durabilityExp, float qualityExp, string qualityRole = null) + { + var terms = new List(); + if (heatExp != 0) terms.Add(new StatTerm { Source = StatSource.Heat, Exponent = heatExp }); + if (durabilityExp != 0) terms.Add(new StatTerm { Source = StatSource.Durability, Exponent = durabilityExp }); + if (qualityExp != 0) terms.Add(new StatTerm { Source = StatSource.Quality, Exponent = qualityExp, Role = qualityRole }); + return new PerformanceStat { Min = min, Max = max, Terms = terms }; + } + + private static Shape LegacyShape(int width, int height, bool[] flat) + { + var shape = new Shape(width, height); + for (var x = 0; x < width; x++) + for (var y = 0; y < height; y++) + shape[new int2(x, y)] = flat[x * height + y]; + return shape; + } + + private static HardpointData Hardpoint(HardpointType type, int x, int y, Shape shape, ItemRotation rotation, float armor, string transform = null) => + new HardpointData { Type = type, Position = new int2(x, y), Shape = shape, Rotation = rotation, Armor = armor, Transform = transform }; + + // Shape(w, h) allocates an all-FALSE grid (only the parameterless Shape() sets a cell true); every one of + // the five restored thruster designs is a solid rectangle per the decoded legacy record, so this fills it. + private static Shape FullShape(int w, int h) + { + var shape = new Shape(w, h); + for (var x = 0; x < w; x++) + for (var y = 0; y < h; y++) + shape[new int2(x, y)] = true; + return shape; + } + + private static PerformanceStat ClonePerformanceStat(PerformanceStat s) => + new PerformanceStat { Min = s.Min, Max = s.Max, Terms = s.Terms.Select(t => new StatTerm { Source = t.Source, Exponent = t.Exponent, Role = t.Role }).ToList() }; + + private static Shape CloneShape(Shape s) + { + var clone = new Shape(s.Width, s.Height); + for (var x = 0; x < s.Width; x++) + for (var y = 0; y < s.Height; y++) + clone[new int2(x, y)] = s[new int2(x, y)]; + return clone; + } + + private static int RestoreHulls(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + + var longinusX = db.Cache.GetAll().FirstOrDefault(h => h.Name == "LonginusX"); + if (longinusX == null) { Console.WriteLine("No hull named \"LonginusX\" found -- cannot template Longinus."); return 1; } + if (db.Cache.GetAll().Any(h => h.Name == "Longinus")) + { + Console.WriteLine("Longinus already exists in the catalog. This command is one-shot and refuses to run twice."); + return 1; + } + + Faction FactionByShortName(string shortName) => + db.Cache.GetAll().FirstOrDefault(f => f.ShortName == shortName) + ?? throw new InvalidOperationException($"No faction with short name \"{shortName}\"."); + + var alakrita = FactionByShortName("Alakrita"); + var rossumDouglas = FactionByShortName("R&D"); + var aeronauticsUnlimited = FactionByShortName("AU"); + var niteLife = FactionByShortName("NiteLife"); + var lightsail = FactionByShortName("Lightsail"); + + // ---- Longinus: cloned from LonginusX (confirmed byte-identical on every field below by decoding the + // legacy record directly), swapping its single AetherDrive hardpoint for the 4 legacy thrusters and + // adding the legacy VelocityLimitData behaviour LonginusX does not carry. + // NOT cloned from LonginusX: LonginusX's own interior was rearranged to fit its AetherDrive hardpoint + // (its live Radiator L/R sit at (1,2)/(4,2) and its Reactor at (2,1) -- confirmed by decoding the + // catalog record directly, which first surfaced as a hardpoint-grid collision: Th.L/Th.R at (1,0)/(3,0) + // legitimately overlap LonginusX's own Reactor at (2,1)). The legacy Longinus never had an AetherDrive, + // so its own interior (decoded from the 2021-04-14 record directly) is authored here instead of + // borrowed from LonginusX's redesigned one; only the outer Shape and the four scalar/behaviour fields + // confirmed byte-identical above are taken from the live sibling. + var longinusHardpoints = new List + { + Hardpoint(HardpointType.ControlModule, 2, 6, FullShape(2, 2), ItemRotation.None, 50, null), + Hardpoint(HardpointType.Thruster, 1, 0, FullShape(2, 2), ItemRotation.Reversed, 20, "Th.L"), + Hardpoint(HardpointType.Thruster, 3, 0, FullShape(2, 2), ItemRotation.Reversed, 20, "Th.R"), + Hardpoint(HardpointType.Thruster, 2, 14, FullShape(1, 2), ItemRotation.CounterClockwise, 10, "Th.CW"), + Hardpoint(HardpointType.Thruster, 3, 14, FullShape(1, 2), ItemRotation.Clockwise, 10, "Th.CCW"), + Hardpoint(HardpointType.Energy, 1, 8, FullShape(1, 2), ItemRotation.None, 20, "En.L"), + Hardpoint(HardpointType.Energy, 4, 8, FullShape(1, 2), ItemRotation.None, 20, "En.R"), + Hardpoint(HardpointType.Launcher, 0, 5, FullShape(1, 3), ItemRotation.None, 30, "La.L"), + Hardpoint(HardpointType.Launcher, 5, 5, FullShape(1, 3), ItemRotation.None, 30, "La.R"), + Hardpoint(HardpointType.Radiator, 2, 2, FullShape(1, 2), ItemRotation.CounterClockwise, 10, "Ra.L"), + Hardpoint(HardpointType.Radiator, 3, 2, FullShape(1, 2), ItemRotation.Clockwise, 10, "Ra.R"), + Hardpoint(HardpointType.Reactor, 2, 4, FullShape(2, 2), ItemRotation.None, 0, "Reactor"), + Hardpoint(HardpointType.Sensors, 3, 10, FullShape(1, 1), ItemRotation.None, 5, null), + }; + + var longinus = new HullData + { + Name = "Longinus", + Description = longinusX.Description, + Mass = 2500, + Shape = CloneShape(longinusX.Shape), + SpecificHeat = 1, + Conductivity = 32, + Price = 7500000, + Schematic = longinusX.Schematic, + Durability = 500, + MinimumTemperature = 173.15f, + MaximumTemperature = 573.15f, + OptimalTemperature = longinusX.OptimalTemperature, + PlateauWidth = longinusX.PlateauWidth, + ThermalResilience = 1, + Hardpoints = longinusHardpoints, + Prefab = longinusX.Prefab, + HullType = HullType.Ship, + GridOffset = 3, + Armor = 10, + Drag = 0.1f, + CanTow = false, + Roles = new List { new ItemRole { Name = "plating" } }, + Behaviors = new List + { + // LonginusX's own ReflectorData, cloned rather than shared: two catalog documents landing in + // the same commit must not share a mutable object reference (CultCache's identity map keys on + // reference equality). + new ReflectorData { CrossSection = ClonePerformanceStat(((ReflectorData) longinusX.Behaviors.Single(b => b is ReflectorData)).CrossSection) }, + // Legacy Longinus's own decoded VelocityLimitData: [100, 100, heat 1, durability 0, quality 0]. + new VelocityLimitData { TopSpeed = TranslateLegacyStat(100, 100, 1, 0, 0) }, + }, + }; + + // ---- Djinni: no live twin, authored directly from the decoded legacy record. Shape rows match + // docs/locomotion-cut.md's own probe table exactly. + var djinniShapeRows = new[] + { + "....######....", "...########...", "..##########..", ".############.", ".############.", + "##############", "##############", "##############", "##############", "##############", + ".############.", "...########...", "...########...", "...########...", "....######....", + ".....####.....", "......##......", + }; + var djinniShape = new Shape(14, 17); + for (var y = 0; y < 17; y++) + for (var x = 0; x < 14; x++) + djinniShape[new int2(x, y)] = djinniShapeRows[y][x] == '#'; + + Shape Sh(int w, int h, string cells) + { + var s = new Shape(w, h); + for (var x = 0; x < w; x++) + for (var y = 0; y < h; y++) + s[new int2(x, y)] = cells[x * h + y] == '#'; + return s; + } + + var djinni = new HullData + { + Name = "Djinni", + Mass = 10000, + Shape = djinniShape, + SpecificHeat = 0.75f, + Conductivity = 24, + Price = 10000000, + Durability = 1000, + MinimumTemperature = 200, + MaximumTemperature = 400, + OptimalTemperature = 250, // derived: see the authored-choices note above this method + PlateauWidth = 24, + ThermalResilience = 1, + Prefab = "79024f6300000000000000000000000", // Djinni.prefab GUID (docs probe); AetheriaStores does not validate asset existence + HullType = HullType.Ship, + GridOffset = 3, + Armor = 10, + Drag = 0.2f, + CanTow = false, + Roles = new List { new ItemRole { Name = "plating" } }, + Hardpoints = new List + { + Hardpoint(HardpointType.Thruster, 6, 0, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Thruster Stern 1"), + Hardpoint(HardpointType.Thruster, 5, 1, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Thruster Stern 2"), + Hardpoint(HardpointType.Thruster, 7, 1, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Thruster Stern 3"), + Hardpoint(HardpointType.Thruster, 4, 4, Sh(1, 2, "##"), ItemRotation.CounterClockwise, 20, "Thruster Port Aft"), + Hardpoint(HardpointType.Thruster, 4, 11, Sh(1, 2, "##"), ItemRotation.CounterClockwise, 20, "Thruster Port Fore"), + Hardpoint(HardpointType.Thruster, 9, 4, Sh(1, 2, "##"), ItemRotation.Clockwise, 20, "Thruster Starboard Aft"), + Hardpoint(HardpointType.Thruster, 9, 11, Sh(1, 2, "##"), ItemRotation.Clockwise, 20, "Thruster Starboard Fore"), + Hardpoint(HardpointType.Thruster, 6, 14, Sh(2, 1, "##"), ItemRotation.None, 20, "Thruster Bow"), + Hardpoint(HardpointType.Launcher, 5, 11, Sh(1, 3, "###"), ItemRotation.None, 20, "Nostril L"), + Hardpoint(HardpointType.Launcher, 8, 11, Sh(1, 3, "###"), ItemRotation.None, 20, "Nostril R"), + Hardpoint(HardpointType.Launcher, 2, 8, Sh(3, 2, "######"), ItemRotation.None, 20, "Micromissiles L"), + Hardpoint(HardpointType.Launcher, 9, 8, Sh(3, 2, "######"), ItemRotation.None, 20, "Micromissiles R"), + Hardpoint(HardpointType.Ballistic, 2, 6, Sh(1, 2, "##"), ItemRotation.None, 20, "Barrel L"), + Hardpoint(HardpointType.Ballistic, 11, 6, Sh(1, 2, "##"), ItemRotation.None, 20, "Barrel R"), + Hardpoint(HardpointType.Reactor, 6, 11, Sh(2, 2, "####"), ItemRotation.None, 0, "Reactor"), + Hardpoint(HardpointType.ControlModule, 6, 3, Sh(2, 2, "####"), ItemRotation.None, 30, null), + Hardpoint(HardpointType.Radiator, 1, 4, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Ra.1"), + Hardpoint(HardpointType.Radiator, 3, 1, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Ra.2"), + Hardpoint(HardpointType.Radiator, 9, 1, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Ra.3"), + Hardpoint(HardpointType.Radiator, 11, 4, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Ra.4"), + Hardpoint(HardpointType.Sensors, 6, 5, Sh(2, 2, "####"), ItemRotation.None, 10, null), + Hardpoint(HardpointType.Shield, 3, 2, Sh(2, 2, "####"), ItemRotation.None, 0, null), + }, + Behaviors = new List + { + // Legacy ReflectorData, decoded as-is: [2500, 500, heat 0, durability 1, quality 2]. Quality + // role "plating" matches HullShipRoles' own field->role map and LonginusX's already-shipped + // ReflectorData.CrossSection, which points the same field at the same role. + new ReflectorData { CrossSection = TranslateLegacyStat(2500, 500, 0, 1, 2, "plating") }, + // Legacy Djinni's own decoded VelocityLimitData: [50, 50, heat 1, durability 0, quality 1.5]. + new VelocityLimitData { TopSpeed = TranslateLegacyStat(50, 50, 1, 0, 1.5f) }, + }, + }; + + // Djinni's weapon hardpoints (Ballistic/Launcher) all decode at ItemRotation.None with no FiringArc + // override; see the authored-choices note above. + + // ---- Thruster designs (GearData). Each Thrust stat gets a PowerSupply term (exponent 1), matching + // BrownoutMigrate's existing convention for every shipped thruster. + GearData Thruster(string name, int w, int h, float mass, int price, PerformanceStat thrust, PerformanceStat visibility, PerformanceStat heat, PerformanceStat energyUsage) + { + thrust.Terms.Add(new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 }); + return new GearData + { + Name = name, + Hardpoint = HardpointType.Thruster, + Shape = FullShape(w, h), + Mass = mass, + Price = price, + Durability = 100, + MinimumTemperature = 200, + MaximumTemperature = 400, + OptimalTemperature = 300, + PlateauWidth = 100, + ThermalResilience = 1, + SpecificHeat = 2.5f, + Conductivity = 5, + Roles = new List { new ItemRole { Name = "injector" }, new ItemRole { Name = "nozzle" } }, + Behaviors = new List + { + new ThrusterData { Thrust = thrust, Visibility = visibility, Heat = heat, EnergyUsage = energyUsage, ParticlesPrefab = null }, + new WearData(), + }, + }; + } + + var mediumDrive = Thruster("Medium Drive", 2, 1, 100, 50000, + TranslateLegacyStat(100000, 300000, 1, 0, 2, "nozzle"), + TranslateLegacyStat(100, 50, 1, 0, 1, "nozzle"), + TranslateLegacyStat(2000, 1000, 1, 0, 1, "injector"), + TranslateLegacyStat(0, 0, 0, 0, 0)); + var smallDrive = Thruster("Small Drive", 1, 1, 50, 25000, + TranslateLegacyStat(75000, 75000, 1, 0, 1, "nozzle"), + TranslateLegacyStat(50, 50, 1, 0, 1, "nozzle"), + TranslateLegacyStat(500, 500, 1, 0, 1, "injector"), + TranslateLegacyStat(0, 0, 0, 0, 0)); + var victoire = Thruster("Victoire", 2, 1, 25, 250000, + TranslateLegacyStat(200000, 1500000, 0.0625f, 0.5f, 2, "nozzle"), + TranslateLegacyStat(800, 200, 0.25f, 0.25f, 1.5f, "nozzle"), + TranslateLegacyStat(2500, 5000, 0, 0.125f, 1.25f, "injector"), + TranslateLegacyStat(4, 2, 0.125f, 0.125f, 1, "injector")); // NOT zero -- see authored-choices note + var talaria = Thruster("Talaria", 2, 1, 150, 50000, + TranslateLegacyStat(75000, 250000, 0.0625f, 0.5f, 2.25f, "nozzle"), + TranslateLegacyStat(300, 50, 0.25f, 0.25f, 2, "nozzle"), + TranslateLegacyStat(1000, 4000, 0, 0.125f, 1.75f, "injector"), + TranslateLegacyStat(0, 0, 0, 0, 0)); + var revvItUp = Thruster("RevvITup 2.0", 2, 2, 150, 100000, + TranslateLegacyStat(250000, 1000000, 0.0625f, 0.5f, 2, "nozzle"), + TranslateLegacyStat(100, 400, 0.25f, 0.25f, 1.5f, "nozzle"), + TranslateLegacyStat(2500, 7500, 0, 0.125f, 1, "injector"), + TranslateLegacyStat(0, 0, 0, 0, 0)); + + var newDesigns = new (EquippableItemData Design, (string ProductName, Faction Maker)[] Products)[] + { + (longinus, new[] { ("Longinus", alakrita) }), + (djinni, new[] { ("Djinni", rossumDouglas) }), + (mediumDrive, new[] { ("Medium Drive", lightsail) }), + (smallDrive, new[] { ("Small Drive", lightsail) }), + (victoire, new[] { ("Victoire", alakrita) }), + (talaria, new[] { ("Talaria", aeronauticsUnlimited) }), + (revvItUp, new[] { ("RevvITup 2.0", niteLife) }), + }; + + Console.WriteLine("=== Cut 1: restore-hulls ===\n"); + foreach (var (design, products) in newDesigns) + { + Console.WriteLine($"{design.Name} ({design.GetType().Name}): mass {design.Mass}, price {design.Price}, " + + $"roles [{string.Join(", ", design.Roles.Select(r => r.Name))}]" + + (design is HullData hull ? $", {hull.Hardpoints.Count} hardpoints" : "")); + foreach (var (productName, maker) in products) + Console.WriteLine($" product \"{productName}\" by {maker.ShortName}"); + } + Console.WriteLine($"\n{newDesigns.Length} designs, {newDesigns.Sum(d => d.Products.Length)} products."); + + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to land these records in Aetheria.cc."); + return 0; + } + + foreach (var (design, _) in newDesigns) CultRecordRefs.Validate(design); + + db.Cache.Commit(batch => + { + var designKeys = new Dictionary(); + foreach (var (design, _) in newDesigns) + designKeys[design.Name] = batch.Upsert(design.GetType(), design); + + foreach (var (design, products) in newDesigns) + foreach (var (productName, maker) in products) + batch.Upsert(typeof(FactionProductData), new FactionProductData + { + Name = productName, + Description = design.Description, + Design = new CultRecordRef(designKeys[design.Name]), + Manufacturer = db.Cache.RefOf(maker), + Roles = design.Roles.Select(r => new ProductRole { Role = r.Name, Mean = .5f, StandardDeviation = .15f }).ToList(), + }); + }); + + Console.WriteLine($"\nLanded {newDesigns.Length} designs and {newDesigns.Sum(d => d.Products.Length)} products in Aetheria.cc"); + return 0; + } + // Every CultRecordRef in the catalog that resolves to nothing, as " -> ". // Each "clear " argument unsets that member's dangling refs, or removes that dictionary's // dangling keys (Faction.BossHull clears boss hulls that would claim a chokepoint no boss can spawn in). Dry From 4f218e85340d6aea45adad06915f5c81ed9272b7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 16:33:35 +0200 Subject: [PATCH 035/116] Cut 1: land Longinus, Djinni and five thruster designs in Aetheria.cc Landed via `dotnet run --project tools/AetherDb -- restore-hulls apply`. Longinus (13 hardpoints, Alakrita) and Djinni (22 hardpoints, Rossum & Douglas), plus Medium Drive, Small Drive, Victoire, Talaria and RevvITup 2.0 (approved 2026-09-22), each with a FactionProductData. Verified: census names both hulls with makers, dangling reports nothing new, LoadoutGenerator/`loadout` generates every hull in the catalog cleanly across seeds 1-5, second apply refused. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index d2a0b4982..5611652ff 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1862ddc46be55e3ae574d214afd1be5aa917d3e9ff5c13d6c38d48a1652e13c1 -size 12691535 +oid sha256:326a69cbbf157541607fcb4d754945efab8aba53c505b01b168393620d7ee3ec +size 12697724 From 6b5bb87e918033681f581b7f4ea250aca64ae9cf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 16:33:47 +0200 Subject: [PATCH 036/116] Cut 1: RestoredHullsTests, against the real restored catalog Read-only against GameData/Aetheria.cc: each hull exists with the expected thruster hardpoint count (Longinus 4, Djinni 8), every thruster hardpoint accepts a catalog thruster design through TryEquip at its own position, and each hull moves and turns for a few ticks under MovementDirection/LookDirection on the pre-Cut-3 mixer -- content proof, not controller proof. Also covers the operator's tutorial-station addition (2026-09-25): Zenith (the live Station hull) is confirmed working already -- station-fit, hardpoint-fit and `loadout` all pass for it, and the legacy record's only station design ("Zenith Station") is that same hull, so nothing needed restoring. ZoneGenerator's story-station path is dead code today (Galaxy's tutorial constructor has its StoryProcessor call commented out), so TutorialZoneGenerationCanProduceADockedStation drives the background station path directly (real ZoneGenerator.GenerateZone call, real catalog, a minimal SavedGame/Galaxy fixture) across 25 zone/seed attempts and asserts at least one produces a station with a docking bay. Co-Authored-By: Claude Opus 5.5 --- .../RestoredHullsTests.cs | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/RestoredHullsTests.cs diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs new file mode 100644 index 000000000..623f9a1f9 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -0,0 +1,243 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; + +// Cut 1 (docs/locomotion-cut.md, "Restore the legacy thruster ship hulls to the live catalog"). Verifies the +// content restore-hulls landed, against the real shipped catalog, read-only: the hulls exist with the expected +// thruster hardpoint counts, every thruster hardpoint accepts a catalog thruster design through TryEquip at its +// own position, and the ship then moves and turns for a few ticks under MovementDirection/LookDirection on the +// pre-Cut-3 mixer -- proof of the restored content, not of a controller this cut does not touch. +public sealed class RestoredHullsTests +{ + // TorqueMultiplier defaults to 0 on a bare GameplaySettings; Thruster.Execute multiplies its rotation + // output by it (Ship.cs's mixer applies Torque*Thrust*TorqueMultiplier/Mass), so leaving it unset makes + // every thruster's yaw contribution silently zero regardless of how the ship is equipped -- velocity moves + // but Direction never turns, which looks exactly like a content defect instead of a fixture gap. + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TorqueMultiplier = 1, + }; + + // Same composition FireControlCut7Tests.OpenReadOnlyRealCatalog uses: a registry scoped to the shipped + // assembly's own [CultDocument] types, so this test's own process-wide type registration never pollutes the + // real catalog's validation the way the shared, AppDomain-scanning CultCache() default would. + private static CultCache OpenReadOnlyRealCatalog(string catalogPath) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + var cache = new CultCache(registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes); + return cache; + } + + private static string FindRepoRoot() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) + return dir.FullName; + throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); + } + + private static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc")); + + // Same registry-scoping problem FireControlCut7Tests.OpenReadOnlyRealCatalog solves for a catalog-only open: + // AetheriaStores.Open's `new CultCache()` uses the shared, AppDomain-scanning registry, which also picks up + // this test assembly's own [CultDocument] fixture types and then demands a record for each -- so this test, + // which also needs a writable scratch run store (RunSave.Commit's SavedGame/SavedZone), builds the same + // catalog+run composition AetheriaStores.Open does, but against a registry scoped to the shipped assembly. + private static CultCache OpenRealCatalogWithScratchRun(string catalogPath, string runPath) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + var cache = new CultCache(registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(runPath), AetheriaStores.RunTypes); + return cache; + } + + [Theory] + [InlineData("Longinus", 4)] + [InlineData("Djinni", 8)] + public void RestoredHullExistsWithExpectedThrusterHardpointCount(string name, int expectedThrusterHardpoints) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + Assert.NotNull(hull); + Assert.Equal(HullType.Ship, hull.HullType); + Assert.Equal(expectedThrusterHardpoints, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); + } + + [Theory] + [InlineData("Longinus")] + [InlineData("Djinni")] + public void EveryThrusterHardpointAcceptsACatalogThrusterAtItsOwnPosition(string name) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + var thrusterDesigns = cache.GetAll().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray(); + Assert.NotEmpty(thrusterDesigns); + + var settings = Settings(); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; + var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); + + var lot = 2; + foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) + { + var cells = hardpoint.Shape.Coordinates.Length; + var design = thrusterDesigns.FirstOrDefault(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == cells); + Assert.True(design != null, $"{name}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} has no fitting catalog thruster design (needs {cells} cells)."); + var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++ }; + Assert.True(ship.TryEquip(gearItem, hardpoint.Position), + $"{name}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} refused {design.Name} via TryEquip."); + } + } + + // "The ship then moves and turns... for a few ticks" (docs/locomotion-cut.md verification list). Equips + // every thruster hardpoint with a fitting design, then drives MovementDirection/LookDirection the same way + // ActionGameManager's player-input path does (docs' own consumer audit) and checks Entity.Velocity and + // Entity.Direction both move over a few ticks on the current (pre-Cut-3) mixer. + [Theory] + [InlineData("Longinus")] + [InlineData("Djinni")] + public void RestoredHullMovesAndTurnsUnderMovementAndLookDirection(string name) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + var thrusterDesigns = cache.GetAll().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray(); + + var settings = Settings(); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; + var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); + + var lot = 2; + foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) + { + var cells = hardpoint.Shape.Coordinates.Length; + var design = thrusterDesigns.First(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == cells); + var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++ }; + Assert.True(ship.TryEquip(gearItem, hardpoint.Position)); + } + + zone.Entities.Add(ship); + ship.Position = float3.zero; + ship.Activate(); + zone.Update(0f); // warm-up: resolves equipment stats before the driven ticks read them + + var startVelocity = ship.Velocity; + var startDirection = ship.Direction; + + ship.MovementDirection = float2(0, 1); // full forward, same axis ActionGameManager.Input.Player.Move drives + ship.LookDirection = float3(1, 0, 0); // off-axis target heading, so the mixer's yaw thrusters have work to do + for (var i = 0; i < 10; i++) zone.Update(1f / 60f); + + Assert.NotEqual(startVelocity, ship.Velocity); + Assert.NotEqual(startDirection, ship.Direction); + } + + // Operator addition, 2026-09-25: "What we're testing is going to be the tutorial level... Let's drop a + // station in there." ZoneGenerator's STORY station path (galaxyZone.Locations, populated by StoryProcessor) + // is dead in the live code today -- Galaxy's tutorial constructor has that call commented out + // (Assets/Scripts/ServerShared/Galaxy.cs:242) -- so every zone's stations come from the BACKGROUND path + // (ZoneGenerator.cs:290-304): a random count near the owning faction, placed at whatever Lagrange points + // that zone's own planet generation produced. That placement is genuinely probabilistic (stationCount can + // roll 0, and a zone with no sub-zones has nowhere to put one), so this drives real generation across many + // zone names/seeds and asserts at least one produces a station with a docking bay -- proof this repo's own + // ZoneGenerator can still place a station on the restored catalog, not a claim that every zone gets one. + // + // Builds the same minimal SavedGame/Galaxy round trip the shipped AetherDb `loadout exclude-sellers` command + // and FireControlCut6cTests use, scoped to Aeronautics Unlimited (Zenith's own seller, docs probe) as the + // zone's sole owning/nearest faction so factionPresence is high and stations are not competing for the RNG + // draw with distant, uninvolved factions. + [Fact] + public void TutorialZoneGenerationCanProduceADockedStation() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var zoneSettings = new ZoneGenerationSettings + { + SunMass = 5000, + PlanetMass = 100, + SatellitePasses = 2, + SatelliteCreationProbability = 0, + BinaryCreationProbability = 0, + RosetteProbability = 0, + ZoneRadius = new ExponentialLerp { Minimum = 1000, Maximum = 1000, Exponent = 1 }, + ZoneMass = new ExponentialLerp { Minimum = 1000, Maximum = 1000, Exponent = 1 }, + SubZoneCount = new ExponentialLerp { Minimum = 4, Maximum = 4, Exponent = 1 }, + ZoneBoundaryRadius = 4, + BeltProbability = 0, + PlanetSafetyRadius = new ExponentialCurve { Multiplier = 1, Exponent = 0 }, + }; + + var found = false; + for (var seed = 0; seed < 25 && !found; seed++) + { + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-restoredhulls-tutorial-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenRealCatalogWithScratchRun(gameData, scratchRun); + var au = cache.GetAll().Single(f => f.ShortName == "AU"); + var auRef = cache.RefOf(au); + var game = new SavedGame + { + Background = new SectorBackgroundSettings + { + NoiseAmplitude = 1, NoiseGain = .5f, NoiseLacunarity = 2, NoiseFrequency = .1f, CloudExponent = 10, CloudAmplitude = 1, + }, + Factions = new[] { auRef }, + Relationships = new[] { FactionRelationship.Neutral }, + HomeZones = new Dictionary { { 0, 0 } }, + BossZones = new Dictionary(), + DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], + Entrance = 0, + Exit = -1, + }; + var zones = new[] + { + new SavedZone { Name = $"Test Zone {seed}", Position = float2(seed, 0), AdjacentZones = Array.Empty(), Factions = new[] { 0 }, Owner = 0, Contents = null }, + }; + RunSave.Commit(cache, game, zones, new ProvenanceLedger()); + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Zones[0], isTutorial: true); + + if (pack.Entities.OfType().Any(e => + (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0)) + found = true; + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + Assert.True(found, "No station with a docking bay generated in 25 zone attempts against the restored catalog."); + } +} From 819bd6b8b935635f160147eb52c8c94db8834f47 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 16:36:28 +0200 Subject: [PATCH 037/116] Cut 1: delete restore-hulls now that its records have landed Same contract Cut 10 applied to the Cut 9 importer: a one-shot restore carries zero ongoing cost once its records are in the catalog. The authored-choices review list this command printed lives in the cut's report, not in source. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 362 +------------------------------------- 1 file changed, 1 insertion(+), 361 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 89d209af5..bd6d1ef08 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,9 +36,8 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); - case "restore-hulls": return RestoreHulls(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], restore-hulls [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); return 1; } } @@ -540,365 +539,6 @@ public static class Program foreach (var field in missing) Console.WriteLine($" {field}"); return missing.Count; } - - // Cut 1 (docs/locomotion-cut.md, "Restore the legacy thruster ship hulls to the live catalog"). Restores - // Longinus and Djinni plus their five thruster designs, decoded from the git-LFS 2021-04-14 legacy record - // (docs' own probe: Longinus/Djinni byte-identical on every field this command carries forward). One-shot, - // in the shape of ShieldMigrate/BrownoutMigrate/RolesMigrate/FiringArcMigrate: dry run unless "apply", opens - // the catalog writable only then, writes every record in one Commit, and refuses to run twice (Longinus - // already present is the refusal signal). Deleted in a later commit of this same cut once landed - // (docs/locomotion-cut.md: "so its carrying cost is zero once it has run"). - // - // Authored choices the map did not name, each printed below for operator review: - // - Longinus's scalar fields (shape/mass/price/drag/durability/temp band/armor/conductivity/prefab/ - // schematic) and its ReflectorData are taken from LonginusX, confirmed byte-identical by decoding the - // legacy record directly. Its HARDPOINT LAYOUT is not: LonginusX's own interior was rearranged to fit - // its AetherDrive hardpoint (its live Radiator L/R and Reactor sit at different cells than the legacy - // Longinus's), which first surfaced as a real hardpoint-grid collision (Th.L/Th.R at (1,0)/(3,0) - // overlapping LonginusX's own Reactor at (2,1)). Longinus's 13 hardpoints are authored directly from - // the decoded 2021-04-14 record instead. - // - The map states both hulls share "50..50, legacy exponents 1, 0, 1.5" for VelocityLimitData.TopSpeed. - // The decoded legacy record contradicts this for Longinus: its own TopSpeed is 100..100, exponents - // [heat 1, durability 0, quality 0]. Only Djinni actually carries 50..50/[1,0,1.5]. This command uses - // each hull's own decoded value, not the map's blanket claim. - // - Djinni's temperature band (200-400) and heat curve are legacy-authored, but the original curve's - // control points were not decoded (out of scope to reverse-engineer the historical Bezier fit); Optimal - // (250) and PlateauWidth (24) are derived by applying Longinus/LonginusX's own optimal-fraction and - // plateau/range ratio to Djinni's band, not a real re-fit of the legacy curve. - // - FactionProductData: Longinus -> Alakrita, Victoire -> Alakrita, Djinni -> Rossum & Douglas, Talaria -> - // Aeronautics Unlimited, RevvITup 2.0 -> NiteLife Energy (all per the map). Medium Drive and Small Drive - // carry no legacy maker; Lightsail Express is chosen because it already sells the catalog's other - // maker-less Thruster-hardpoint product, the closest existing precedent for an unbranded drive. - // - ItemRole/ProductRole: thrusters get ["injector", "nozzle"] with Thrust/Visibility -> nozzle and - // Heat/EnergyUsage -> injector (RolesMigrate's own ThrusterRoles map); hulls get ["plating"] with no - // stat term pointed at it (ship hulls carry no CrossSection-shaped stat to point HullShipRoles at). - // - PowerSupply: each new thruster's Thrust stat gets a PowerSupply term (exponent 1), matching - // BrownoutMigrate's existing convention for every shipped thruster. Victoire's legacy EnergyUsage is - // NOT zero ([4, 2, 0.125, 0.125, 1], i.e. a real, quality-shrinking energy draw) -- unlike the map's - // "shipped thrusters request zero energy" assumption, which holds for the other four. Victoire's - // EnergyUsage is translated as decoded rather than forced to zero. - // - Djinni's Ballistic/Launcher hardpoints all decode with ItemRotation.None (forward-facing) and get no - // FiringArc override (stay at the 0 default -> GameplaySettings.FiringArc): firing-arc-migrate's own - // rule only widens an omnidirectional turret mount to 360, and nothing about a fixed forward mount - // calls for deviating from the default every other non-turret hull's weapon hardpoint already uses. - private static PerformanceStat TranslateLegacyStat(float min, float max, float heatExp, float durabilityExp, float qualityExp, string qualityRole = null) - { - var terms = new List(); - if (heatExp != 0) terms.Add(new StatTerm { Source = StatSource.Heat, Exponent = heatExp }); - if (durabilityExp != 0) terms.Add(new StatTerm { Source = StatSource.Durability, Exponent = durabilityExp }); - if (qualityExp != 0) terms.Add(new StatTerm { Source = StatSource.Quality, Exponent = qualityExp, Role = qualityRole }); - return new PerformanceStat { Min = min, Max = max, Terms = terms }; - } - - private static Shape LegacyShape(int width, int height, bool[] flat) - { - var shape = new Shape(width, height); - for (var x = 0; x < width; x++) - for (var y = 0; y < height; y++) - shape[new int2(x, y)] = flat[x * height + y]; - return shape; - } - - private static HardpointData Hardpoint(HardpointType type, int x, int y, Shape shape, ItemRotation rotation, float armor, string transform = null) => - new HardpointData { Type = type, Position = new int2(x, y), Shape = shape, Rotation = rotation, Armor = armor, Transform = transform }; - - // Shape(w, h) allocates an all-FALSE grid (only the parameterless Shape() sets a cell true); every one of - // the five restored thruster designs is a solid rectangle per the decoded legacy record, so this fills it. - private static Shape FullShape(int w, int h) - { - var shape = new Shape(w, h); - for (var x = 0; x < w; x++) - for (var y = 0; y < h; y++) - shape[new int2(x, y)] = true; - return shape; - } - - private static PerformanceStat ClonePerformanceStat(PerformanceStat s) => - new PerformanceStat { Min = s.Min, Max = s.Max, Terms = s.Terms.Select(t => new StatTerm { Source = t.Source, Exponent = t.Exponent, Role = t.Role }).ToList() }; - - private static Shape CloneShape(Shape s) - { - var clone = new Shape(s.Width, s.Height); - for (var x = 0; x < s.Width; x++) - for (var y = 0; y < s.Height; y++) - clone[new int2(x, y)] = s[new int2(x, y)]; - return clone; - } - - private static int RestoreHulls(bool apply) - { - var db = AetherDb.Open(catalogWritable: apply); - - var longinusX = db.Cache.GetAll().FirstOrDefault(h => h.Name == "LonginusX"); - if (longinusX == null) { Console.WriteLine("No hull named \"LonginusX\" found -- cannot template Longinus."); return 1; } - if (db.Cache.GetAll().Any(h => h.Name == "Longinus")) - { - Console.WriteLine("Longinus already exists in the catalog. This command is one-shot and refuses to run twice."); - return 1; - } - - Faction FactionByShortName(string shortName) => - db.Cache.GetAll().FirstOrDefault(f => f.ShortName == shortName) - ?? throw new InvalidOperationException($"No faction with short name \"{shortName}\"."); - - var alakrita = FactionByShortName("Alakrita"); - var rossumDouglas = FactionByShortName("R&D"); - var aeronauticsUnlimited = FactionByShortName("AU"); - var niteLife = FactionByShortName("NiteLife"); - var lightsail = FactionByShortName("Lightsail"); - - // ---- Longinus: cloned from LonginusX (confirmed byte-identical on every field below by decoding the - // legacy record directly), swapping its single AetherDrive hardpoint for the 4 legacy thrusters and - // adding the legacy VelocityLimitData behaviour LonginusX does not carry. - // NOT cloned from LonginusX: LonginusX's own interior was rearranged to fit its AetherDrive hardpoint - // (its live Radiator L/R sit at (1,2)/(4,2) and its Reactor at (2,1) -- confirmed by decoding the - // catalog record directly, which first surfaced as a hardpoint-grid collision: Th.L/Th.R at (1,0)/(3,0) - // legitimately overlap LonginusX's own Reactor at (2,1)). The legacy Longinus never had an AetherDrive, - // so its own interior (decoded from the 2021-04-14 record directly) is authored here instead of - // borrowed from LonginusX's redesigned one; only the outer Shape and the four scalar/behaviour fields - // confirmed byte-identical above are taken from the live sibling. - var longinusHardpoints = new List - { - Hardpoint(HardpointType.ControlModule, 2, 6, FullShape(2, 2), ItemRotation.None, 50, null), - Hardpoint(HardpointType.Thruster, 1, 0, FullShape(2, 2), ItemRotation.Reversed, 20, "Th.L"), - Hardpoint(HardpointType.Thruster, 3, 0, FullShape(2, 2), ItemRotation.Reversed, 20, "Th.R"), - Hardpoint(HardpointType.Thruster, 2, 14, FullShape(1, 2), ItemRotation.CounterClockwise, 10, "Th.CW"), - Hardpoint(HardpointType.Thruster, 3, 14, FullShape(1, 2), ItemRotation.Clockwise, 10, "Th.CCW"), - Hardpoint(HardpointType.Energy, 1, 8, FullShape(1, 2), ItemRotation.None, 20, "En.L"), - Hardpoint(HardpointType.Energy, 4, 8, FullShape(1, 2), ItemRotation.None, 20, "En.R"), - Hardpoint(HardpointType.Launcher, 0, 5, FullShape(1, 3), ItemRotation.None, 30, "La.L"), - Hardpoint(HardpointType.Launcher, 5, 5, FullShape(1, 3), ItemRotation.None, 30, "La.R"), - Hardpoint(HardpointType.Radiator, 2, 2, FullShape(1, 2), ItemRotation.CounterClockwise, 10, "Ra.L"), - Hardpoint(HardpointType.Radiator, 3, 2, FullShape(1, 2), ItemRotation.Clockwise, 10, "Ra.R"), - Hardpoint(HardpointType.Reactor, 2, 4, FullShape(2, 2), ItemRotation.None, 0, "Reactor"), - Hardpoint(HardpointType.Sensors, 3, 10, FullShape(1, 1), ItemRotation.None, 5, null), - }; - - var longinus = new HullData - { - Name = "Longinus", - Description = longinusX.Description, - Mass = 2500, - Shape = CloneShape(longinusX.Shape), - SpecificHeat = 1, - Conductivity = 32, - Price = 7500000, - Schematic = longinusX.Schematic, - Durability = 500, - MinimumTemperature = 173.15f, - MaximumTemperature = 573.15f, - OptimalTemperature = longinusX.OptimalTemperature, - PlateauWidth = longinusX.PlateauWidth, - ThermalResilience = 1, - Hardpoints = longinusHardpoints, - Prefab = longinusX.Prefab, - HullType = HullType.Ship, - GridOffset = 3, - Armor = 10, - Drag = 0.1f, - CanTow = false, - Roles = new List { new ItemRole { Name = "plating" } }, - Behaviors = new List - { - // LonginusX's own ReflectorData, cloned rather than shared: two catalog documents landing in - // the same commit must not share a mutable object reference (CultCache's identity map keys on - // reference equality). - new ReflectorData { CrossSection = ClonePerformanceStat(((ReflectorData) longinusX.Behaviors.Single(b => b is ReflectorData)).CrossSection) }, - // Legacy Longinus's own decoded VelocityLimitData: [100, 100, heat 1, durability 0, quality 0]. - new VelocityLimitData { TopSpeed = TranslateLegacyStat(100, 100, 1, 0, 0) }, - }, - }; - - // ---- Djinni: no live twin, authored directly from the decoded legacy record. Shape rows match - // docs/locomotion-cut.md's own probe table exactly. - var djinniShapeRows = new[] - { - "....######....", "...########...", "..##########..", ".############.", ".############.", - "##############", "##############", "##############", "##############", "##############", - ".############.", "...########...", "...########...", "...########...", "....######....", - ".....####.....", "......##......", - }; - var djinniShape = new Shape(14, 17); - for (var y = 0; y < 17; y++) - for (var x = 0; x < 14; x++) - djinniShape[new int2(x, y)] = djinniShapeRows[y][x] == '#'; - - Shape Sh(int w, int h, string cells) - { - var s = new Shape(w, h); - for (var x = 0; x < w; x++) - for (var y = 0; y < h; y++) - s[new int2(x, y)] = cells[x * h + y] == '#'; - return s; - } - - var djinni = new HullData - { - Name = "Djinni", - Mass = 10000, - Shape = djinniShape, - SpecificHeat = 0.75f, - Conductivity = 24, - Price = 10000000, - Durability = 1000, - MinimumTemperature = 200, - MaximumTemperature = 400, - OptimalTemperature = 250, // derived: see the authored-choices note above this method - PlateauWidth = 24, - ThermalResilience = 1, - Prefab = "79024f6300000000000000000000000", // Djinni.prefab GUID (docs probe); AetheriaStores does not validate asset existence - HullType = HullType.Ship, - GridOffset = 3, - Armor = 10, - Drag = 0.2f, - CanTow = false, - Roles = new List { new ItemRole { Name = "plating" } }, - Hardpoints = new List - { - Hardpoint(HardpointType.Thruster, 6, 0, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Thruster Stern 1"), - Hardpoint(HardpointType.Thruster, 5, 1, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Thruster Stern 2"), - Hardpoint(HardpointType.Thruster, 7, 1, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Thruster Stern 3"), - Hardpoint(HardpointType.Thruster, 4, 4, Sh(1, 2, "##"), ItemRotation.CounterClockwise, 20, "Thruster Port Aft"), - Hardpoint(HardpointType.Thruster, 4, 11, Sh(1, 2, "##"), ItemRotation.CounterClockwise, 20, "Thruster Port Fore"), - Hardpoint(HardpointType.Thruster, 9, 4, Sh(1, 2, "##"), ItemRotation.Clockwise, 20, "Thruster Starboard Aft"), - Hardpoint(HardpointType.Thruster, 9, 11, Sh(1, 2, "##"), ItemRotation.Clockwise, 20, "Thruster Starboard Fore"), - Hardpoint(HardpointType.Thruster, 6, 14, Sh(2, 1, "##"), ItemRotation.None, 20, "Thruster Bow"), - Hardpoint(HardpointType.Launcher, 5, 11, Sh(1, 3, "###"), ItemRotation.None, 20, "Nostril L"), - Hardpoint(HardpointType.Launcher, 8, 11, Sh(1, 3, "###"), ItemRotation.None, 20, "Nostril R"), - Hardpoint(HardpointType.Launcher, 2, 8, Sh(3, 2, "######"), ItemRotation.None, 20, "Micromissiles L"), - Hardpoint(HardpointType.Launcher, 9, 8, Sh(3, 2, "######"), ItemRotation.None, 20, "Micromissiles R"), - Hardpoint(HardpointType.Ballistic, 2, 6, Sh(1, 2, "##"), ItemRotation.None, 20, "Barrel L"), - Hardpoint(HardpointType.Ballistic, 11, 6, Sh(1, 2, "##"), ItemRotation.None, 20, "Barrel R"), - Hardpoint(HardpointType.Reactor, 6, 11, Sh(2, 2, "####"), ItemRotation.None, 0, "Reactor"), - Hardpoint(HardpointType.ControlModule, 6, 3, Sh(2, 2, "####"), ItemRotation.None, 30, null), - Hardpoint(HardpointType.Radiator, 1, 4, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Ra.1"), - Hardpoint(HardpointType.Radiator, 3, 1, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Ra.2"), - Hardpoint(HardpointType.Radiator, 9, 1, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Ra.3"), - Hardpoint(HardpointType.Radiator, 11, 4, Sh(2, 1, "##"), ItemRotation.Reversed, 20, "Ra.4"), - Hardpoint(HardpointType.Sensors, 6, 5, Sh(2, 2, "####"), ItemRotation.None, 10, null), - Hardpoint(HardpointType.Shield, 3, 2, Sh(2, 2, "####"), ItemRotation.None, 0, null), - }, - Behaviors = new List - { - // Legacy ReflectorData, decoded as-is: [2500, 500, heat 0, durability 1, quality 2]. Quality - // role "plating" matches HullShipRoles' own field->role map and LonginusX's already-shipped - // ReflectorData.CrossSection, which points the same field at the same role. - new ReflectorData { CrossSection = TranslateLegacyStat(2500, 500, 0, 1, 2, "plating") }, - // Legacy Djinni's own decoded VelocityLimitData: [50, 50, heat 1, durability 0, quality 1.5]. - new VelocityLimitData { TopSpeed = TranslateLegacyStat(50, 50, 1, 0, 1.5f) }, - }, - }; - - // Djinni's weapon hardpoints (Ballistic/Launcher) all decode at ItemRotation.None with no FiringArc - // override; see the authored-choices note above. - - // ---- Thruster designs (GearData). Each Thrust stat gets a PowerSupply term (exponent 1), matching - // BrownoutMigrate's existing convention for every shipped thruster. - GearData Thruster(string name, int w, int h, float mass, int price, PerformanceStat thrust, PerformanceStat visibility, PerformanceStat heat, PerformanceStat energyUsage) - { - thrust.Terms.Add(new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 }); - return new GearData - { - Name = name, - Hardpoint = HardpointType.Thruster, - Shape = FullShape(w, h), - Mass = mass, - Price = price, - Durability = 100, - MinimumTemperature = 200, - MaximumTemperature = 400, - OptimalTemperature = 300, - PlateauWidth = 100, - ThermalResilience = 1, - SpecificHeat = 2.5f, - Conductivity = 5, - Roles = new List { new ItemRole { Name = "injector" }, new ItemRole { Name = "nozzle" } }, - Behaviors = new List - { - new ThrusterData { Thrust = thrust, Visibility = visibility, Heat = heat, EnergyUsage = energyUsage, ParticlesPrefab = null }, - new WearData(), - }, - }; - } - - var mediumDrive = Thruster("Medium Drive", 2, 1, 100, 50000, - TranslateLegacyStat(100000, 300000, 1, 0, 2, "nozzle"), - TranslateLegacyStat(100, 50, 1, 0, 1, "nozzle"), - TranslateLegacyStat(2000, 1000, 1, 0, 1, "injector"), - TranslateLegacyStat(0, 0, 0, 0, 0)); - var smallDrive = Thruster("Small Drive", 1, 1, 50, 25000, - TranslateLegacyStat(75000, 75000, 1, 0, 1, "nozzle"), - TranslateLegacyStat(50, 50, 1, 0, 1, "nozzle"), - TranslateLegacyStat(500, 500, 1, 0, 1, "injector"), - TranslateLegacyStat(0, 0, 0, 0, 0)); - var victoire = Thruster("Victoire", 2, 1, 25, 250000, - TranslateLegacyStat(200000, 1500000, 0.0625f, 0.5f, 2, "nozzle"), - TranslateLegacyStat(800, 200, 0.25f, 0.25f, 1.5f, "nozzle"), - TranslateLegacyStat(2500, 5000, 0, 0.125f, 1.25f, "injector"), - TranslateLegacyStat(4, 2, 0.125f, 0.125f, 1, "injector")); // NOT zero -- see authored-choices note - var talaria = Thruster("Talaria", 2, 1, 150, 50000, - TranslateLegacyStat(75000, 250000, 0.0625f, 0.5f, 2.25f, "nozzle"), - TranslateLegacyStat(300, 50, 0.25f, 0.25f, 2, "nozzle"), - TranslateLegacyStat(1000, 4000, 0, 0.125f, 1.75f, "injector"), - TranslateLegacyStat(0, 0, 0, 0, 0)); - var revvItUp = Thruster("RevvITup 2.0", 2, 2, 150, 100000, - TranslateLegacyStat(250000, 1000000, 0.0625f, 0.5f, 2, "nozzle"), - TranslateLegacyStat(100, 400, 0.25f, 0.25f, 1.5f, "nozzle"), - TranslateLegacyStat(2500, 7500, 0, 0.125f, 1, "injector"), - TranslateLegacyStat(0, 0, 0, 0, 0)); - - var newDesigns = new (EquippableItemData Design, (string ProductName, Faction Maker)[] Products)[] - { - (longinus, new[] { ("Longinus", alakrita) }), - (djinni, new[] { ("Djinni", rossumDouglas) }), - (mediumDrive, new[] { ("Medium Drive", lightsail) }), - (smallDrive, new[] { ("Small Drive", lightsail) }), - (victoire, new[] { ("Victoire", alakrita) }), - (talaria, new[] { ("Talaria", aeronauticsUnlimited) }), - (revvItUp, new[] { ("RevvITup 2.0", niteLife) }), - }; - - Console.WriteLine("=== Cut 1: restore-hulls ===\n"); - foreach (var (design, products) in newDesigns) - { - Console.WriteLine($"{design.Name} ({design.GetType().Name}): mass {design.Mass}, price {design.Price}, " + - $"roles [{string.Join(", ", design.Roles.Select(r => r.Name))}]" + - (design is HullData hull ? $", {hull.Hardpoints.Count} hardpoints" : "")); - foreach (var (productName, maker) in products) - Console.WriteLine($" product \"{productName}\" by {maker.ShortName}"); - } - Console.WriteLine($"\n{newDesigns.Length} designs, {newDesigns.Sum(d => d.Products.Length)} products."); - - if (!apply) - { - Console.WriteLine("\nDry run. Pass \"apply\" to land these records in Aetheria.cc."); - return 0; - } - - foreach (var (design, _) in newDesigns) CultRecordRefs.Validate(design); - - db.Cache.Commit(batch => - { - var designKeys = new Dictionary(); - foreach (var (design, _) in newDesigns) - designKeys[design.Name] = batch.Upsert(design.GetType(), design); - - foreach (var (design, products) in newDesigns) - foreach (var (productName, maker) in products) - batch.Upsert(typeof(FactionProductData), new FactionProductData - { - Name = productName, - Description = design.Description, - Design = new CultRecordRef(designKeys[design.Name]), - Manufacturer = db.Cache.RefOf(maker), - Roles = design.Roles.Select(r => new ProductRole { Role = r.Name, Mean = .5f, StandardDeviation = .15f }).ToList(), - }); - }); - - Console.WriteLine($"\nLanded {newDesigns.Length} designs and {newDesigns.Sum(d => d.Products.Length)} products in Aetheria.cc"); - return 0; - } - // Every CultRecordRef in the catalog that resolves to nothing, as " -> ". // Each "clear " argument unsets that member's dangling refs, or removes that dictionary's // dangling keys (Faction.BossHull clears boss hulls that would claim a chokepoint no boss can spawn in). Dry From c339304000ec2e8ab88474d50318e3946a443857 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 16:40:34 +0200 Subject: [PATCH 038/116] Locomotion map: tutorial station ruling (faction station in the entrance zone) Co-Authored-By: Claude Opus 5.5 --- docs/locomotion-cut.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/locomotion-cut.md b/docs/locomotion-cut.md index c49e8439d..710ec899f 100644 --- a/docs/locomotion-cut.md +++ b/docs/locomotion-cut.md @@ -372,6 +372,8 @@ better"), so the Q5 text below and every "sized demand" design it belonged to ar Open: **Q4** (heading versus aim) blocks only Cut 5. **Q6** (the one weight in Cut 3's solve) does not block Hands, who starts at the recommended default; the operator settles it by feel in the Cut 1 play check. +**Tutorial station (operator, 2026-09-25):** "What we're testing is going to be the tutorial level, right? Let's drop a station in there." The live catalog already has a station hull: Zenith, sold by Aeronautics Unlimited. The story-station path is dead because `StoryProcessor` has been commented out at `Galaxy.cs:242` since `ce1a0a46` (2021). Ruled: **faction station now**. The tutorial's entrance zone (`Galaxy.Entrance`) always gets a generated faction station with a docking bay. That is deterministic, not merely likely, and the test pins it on the real tutorial construction path. Reviving the Ink story stations belongs to a later narrative campaign, which must first find out why they were switched off. + Follow-ups outside this campaign: - `TurretController` has no `EntityTypeRestriction`, so equipping it on a ship would create a third `LookDirection` writer racing the player or agent. Restrict it to non-ship hulls when turret work next opens From e9339a567330a4b3bd6cfdef3953dcccf5e56468 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 17:01:05 +0200 Subject: [PATCH 039/116] Cut 12.3 fix batch: one apply path for every item (operator ruling) Replace the survey-then-branch (ApplySequential vs. ApplyWithSharedItems) with a single pooled walk. Lanes are clipped to penetration once, right after Lanes returns; every item -- single-cell or multi-cell -- pools whatever lanes reach it and resolves the moment no other unfinished lane can still reach it down its own remaining clipped walk, with no separate reachability survey. A cyclic dependency between two non-convex items resolves every currently open pool at once, order-independent by construction. Entity.ItemAbsorb becomes the one absorption function, gated on the pooled total; Absorb (Splash's only remaining caller) now funnels through it with a one-contribution pool. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 54 ++-- Assets/Scripts/ServerShared/FireControl.cs | 288 +++++++++------------ 2 files changed, 158 insertions(+), 184 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 86ec06d90..9b49d9666 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -349,38 +349,52 @@ public abstract class Entity return d; } - // Cut 12.3 fix batch (proportional multi-cell absorption): the item phase, split out of Absorb's own - // second half so a caller coordinating several lanes into one shared item (FireControl.Apply) can resolve - // that item's absorption once, from the lanes' combined incoming damage, instead of once per lane against - // whatever durability the previous lane left behind. Takes the item directly (not a cell) because the - // caller may already know it is resolving a specific item for several different cells/lanes at once. - // ItemDamage fires only once the post-armour remainder exceeds .1f, same threshold as before the split. - public float ItemAbsorb(EquippedItem item, float damage) + // Cut 12.3 fix batch (one apply path, operator ruling 2026-09-25 "do not expect items taking up multiple + // cells to be an exception, this should be one code path"): the ONE function that decides an item's own + // absorption, whatever the item's shape or however many lanes of one shot reach it. `incoming` holds every + // contributing lane's own post-armour remainder for this resolve, one lane's worth included -- a + // single-lane item is this rule's own degenerate case, not a separate function or a separate threshold. + // The existing .1f threshold is decided once, on the POOLED total, never per contribution (a pooled path + // that gated each contribution separately would let two shares under .1f each slip past an item that a + // single .08f solo hit would already have stopped at). Absorbed durability is split back across `incoming` + // in place, pro-rata to what each lane brought (a guarded divide: total is already >.1f whenever this + // divides, but 0 is still handled explicitly rather than assumed). ItemDamage fires once per contributing + // lane, reporting its own INCOMING share -- not the post-clamp amount, the same convention ArmorAbsorb + // already uses -- and only when that lane's own share is itself > 0, so a zero-deposit contributor (a + // lane whose armour ate its whole share before reaching this item) never fires a phantom event. + public void ItemAbsorb(EquippedItem item, Span incoming) { - var d = damage; + if (item == null) return; - if (d > 0.1f && item != null) + var total = 0f; + for (var i = 0; i < incoming.Length; i++) total += incoming[i]; + if (total <= 0.1f) return; + + var before = item.EquippableItem.Durability; + var absorbed = min(total, before); + item.EquippableItem.Durability = max(before - absorbed, 0f); + var fraction = total > 0f ? absorbed / total : 0f; + for (var i = 0; i < incoming.Length; i++) { - var prevItem = item.EquippableItem.Durability; - item.EquippableItem.Durability = max(prevItem - d, 0); - ItemDamage.OnNext((item, d)); - d = max(d - prevItem, 0); + if (incoming[i] > 0f) ItemDamage.OnNext((item, incoming[i])); + incoming[i] -= incoming[i] * fraction; } - - return d; } // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- armour up to its own value, then the // occupying item, then whatever is left over is returned to the caller for the hull's own share (or the // next cell down the lane; a lane that keeps carrying a remainder is not this function's concern -- - // Apply's own loop does that). This is the single-lane path: FireControl.Apply calls ArmorAbsorb/ItemAbsorb - // directly instead, only when a cell's item is shared between lanes and needs their combined incoming - // damage resolved together; every other caller (an unshared cell, Splash) still goes through Absorb - // unchanged, so this function's own behaviour is untouched by the fix batch. + // FireControl.Apply's own pooled walk does that). This is the single-lane path: it feeds ItemAbsorb a + // pool of exactly one contribution, the same degenerate case Apply's own multi-lane pools reduce to when + // only one lane ever reaches an item. Splash is Absorb's only remaining caller (12.4 deletes Splash). public float Absorb(int2 cell, float damage) { var d = ArmorAbsorb(cell, damage); - return ItemAbsorb(GearOccupancy[cell.x, cell.y], d); + var item = GearOccupancy[cell.x, cell.y]; + if (item == null) return d; + Span incoming = stackalloc float[1] { d }; + ItemAbsorb(item, incoming); + return incoming[0]; } // Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 98ca726e6..b81a7a2d3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -622,6 +622,12 @@ public static class FireControl // Commit's placement (Lane, the n = 0 case) and Apply's damage split (Lanes, every case) read one function, // and resolves the resulting rare cross-lane collision by ownership rather than by a competing formula -- // see `Lanes`. + // Cut 12.3 fix batch (2026-09-25, operator ruling "one code path" -- docs/fire-control-cut.md, Cut 12.3 + // status): the earlier fix batch's own survey-then-branch (ApplySequential vs. ApplyWithSharedItems) was a + // split authority -- a second reachability derivation for "is this item shared" alongside Lanes' own + // membership rule, the identical shape of failure as 12.2's empty lane and 12.3's own double strike. There + // is now one walk, `ApplyPooled`, in which every item pools the deposits of whatever lanes reach it, a + // pool of one lane included -- a single-cell item is this rule's own degenerate case, not a fork. private static void Apply(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -656,6 +662,21 @@ public static class FireControl for (var li = 0; li < laneCount; li++) buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers); + + // Cut 12.3 fix batch ("one code path"): the ONLY penetration derivation in this whole walk -- clip each + // lane's own cell run to its penetration depth right here, once, immediately after Lanes returns. + // Everything downstream reads `walked` as the lane's entire reach; nothing below ever re-tests a cell's + // entry against penetration again, and there is no separate survey of which cells or items a lane can + // reach -- ApplyPooled's own walk discovers that live, from `walked` alone. + for (var li = 0; li < laneCount; li++) + { + if (walked[li] == 0) continue; + var impactEntry = buffers[li][0].Entry; + var clipped = 1; + while (clipped < walked[li] && buffers[li][clipped].Entry - impactEntry < shot.Penetration) clipped++; + walked[li] = clipped; + } + var metalLanes = 0; for (var li = 0; li < laneCount; li++) if (walked[li] > 0) metalLanes++; @@ -664,133 +685,57 @@ public static class FireControl // guard already proved Lane finds an occupied cell there (R3) -- metalLanes is never 0. var damagePerLane = shot.Damage / metalLanes; - // Cut 12.3 fix batch (2026-09-25, operator: "proportional absorption; go"): survey which items, if - // any, are within reach of more than one lane's own (penetration-clipped) walk -- cells are already - // disjoint between lanes (Lanes' own ownership rule), so only a multi-cell item can ever be reached by - // two different lanes at all. This is a purely geometric question (GearOccupancy and the penetration - // cutoff, neither reads remaining damage), so it can be decided up front, before any Absorb call, and - // the overwhelmingly common case -- no item shared this shot -- takes the untouched sequential walk - // below with no extra allocation. - Dictionary> lanesOf = null; - Dictionary itemKey = null; - for (var li = 0; li < laneCount; li++) - { - if (walked[li] == 0) continue; - var impactEntry0 = buffers[li][0].Entry; - for (var i = 0; i < walked[li]; i++) - { - if (i > 0 && buffers[li][i].Entry - impactEntry0 >= shot.Penetration) break; - var cell = buffers[li][i].Cell; - var item = target.GearOccupancy[cell.x, cell.y]; - if (item == null) continue; - lanesOf ??= new Dictionary>(); - itemKey ??= new Dictionary(); - if (!lanesOf.TryGetValue(item, out var list)) lanesOf[item] = list = new List(); - if (!list.Contains(li)) list.Add(li); - if (!itemKey.TryGetValue(item, out var key) || buffers[li][i].Entry < key) - itemKey[item] = buffers[li][i].Entry; - } - } - List sharedItems = null; - if (lanesOf != null) - foreach (var kv in lanesOf) - if (kv.Value.Count > 1) - (sharedItems ??= new List()).Add(kv.Key); - - var hullDamage = sharedItems == null - ? ApplySequential(target, shot, buffers, walked, laneCount, damagePerLane) - : ApplyWithSharedItems(target, shot, buffers, walked, laneCount, damagePerLane, lanesOf, sharedItems, itemKey); + var hullDamage = ApplyPooled(target, buffers, walked, laneCount, damagePerLane); for (var li = 0; li < laneCount; li++) ArrayPool.Shared.Return(buffers[li]); target.DamageHull(hullDamage); } - // Cut 12.3's original walk, untouched: every lane in isolation, near to far, clipped to its own - // penetration depth, remainder to the hull. Taken whenever no item this shot is shared between lanes - // (the fix batch's own "exact when nothing is shared" contract) -- byte-for-byte the same calls, in the - // same order, as before the fix batch. - private static float ApplySequential(Entity target, PendingShot shot, LaneCell[][] buffers, int[] walked, int laneCount, float damagePerLane) - { - var hullDamage = 0f; - for (var li = 0; li < laneCount; li++) - { - if (walked[li] > 0) - { - var rem = damagePerLane; - var impactEntry = buffers[li][0].Entry; - for (var i = 0; i < walked[li]; i++) - { - if (i > 0 && buffers[li][i].Entry - impactEntry >= shot.Penetration) break; - rem = target.Absorb(buffers[li][i].Cell, rem); - } - hullDamage += rem; - } - } - return hullDamage; - } - - // Cut 12.3 fix batch (2026-09-25, operator ruling "proportional absorption; go" -- docs/fire-control-cut.md, - // Cut 12.3 status): taken only when >=1 item is reachable by more than one lane of this shot. Walks every - // lane through its own private cells exactly as ApplySequential does (armour is never shared, so it is - // never coordinated -- Entity.ArmorAbsorb runs the moment a lane reaches a cell, same as before). A lane - // that reaches a SHARED item's cell instead deposits its own post-armour remainder into that item's pool - // and stalls; once every lane that can ever reach the item has deposited, the item absorbs the pool's SUM - // (up to its own remaining durability) and each depositing lane resumes with a leftover in proportion to - // what it brought -- a sum-then-split, so the result cannot depend on which lane happened to arrive first - // (the defect this fix batch replaces: sequential Absorb let the first lane drain the item and hand the - // second lane's own damage a leftover carved from an already-emptied item). One ItemDamage event fires per - // depositing lane, its own INCOMING contribution -- the same "incoming, not the post-clamp amount" - // convention Entity.ItemAbsorb and Entity.ArmorAbsorb already use for a single lane (an overkill hit - // against a nearly-dead item still reports the whole swing, not what little durability was left to take) -- - // matching the one-event-per-Absorb-call cardinality the unshared path already has. A lane's item - // durability floor and armour-first ordering are otherwise unchanged: this function only ever changes WHEN - // an item's absorption is decided and IN WHAT PROPORTION the leftover is split, never the - // armour-then-item-then-hull order along a single lane's own path. + // Cut 12.3 fix batch (2026-09-25, operator rulings "proportional absorption; go" and "one code path" -- + // docs/fire-control-cut.md, Cut 12.3 status): every lane walks its own already-clipped cells near to far. + // Armour is never shared between lanes (Lanes' own disjointness rule), so Entity.ArmorAbsorb runs the + // instant a lane reaches a cell, exactly as before. A lane that reaches an ITEM cell instead deposits its + // own post-armour remainder into that item's pool and blocks there -- a pool of one lane included, so a + // single-cell item is this rule's own degenerate case, not a separate fast path. A pool resolves the + // moment no other unfinished lane can still reach that item down its own remaining (already-clipped) walk + // -- for an item only one lane can ever touch, that is immediately, the same instant it is deposited. Once + // resolved the item's pool is gone, so a non-convex item a lane crosses a second time (or a second lane + // arriving after a first pool already resolved) opens a brand new pool under the identical rule -- there is + // no separate "solo absorb after a forced resolve" behaviour anywhere in this function. // - // The opposite-order case: two lanes can cross two different shared (multi-cell, non-convex) items in - // reversed relative order -- lane A reaches item1 before item2, lane B reaches item2 before item1. Neither - // item can then be resolved strictly in each lane's own causal order (item1 needs lane B's arrival, which - // needs item2 resolved first; item2 needs lane A's arrival, which needs item1 resolved first) -- every lane - // still in play is stalled on some item, a genuine deadlock, not a scheduling accident. Broken - // deterministically and order-independently by resolving the stalled, not-yet-resolved item with the - // smallest key -- the minimum Entry (along-bearing depth) at which ANY lane can reach it, fixed purely by - // hull geometry and computed once up front, never by which lane the loop below happened to visit first -- - // using only the deposits collected so far. A lane that has not yet arrived at that item resumes normally; - // when its own walk later reaches the same (now-resolved) item, it finds `resolved` already contains it - // and takes the ordinary single-lane ItemAbsorb path against whatever durability the forced resolution - // left, the same path an unshared item always takes. This one deterministic tie-break is the only place a - // lane's own local ordering can end up inverted, and only for the geometrically exotic shape that makes - // strict per-lane causal order unsatisfiable for every lane at once. - private static float ApplyWithSharedItems(Entity target, PendingShot shot, LaneCell[][] buffers, int[] walked, - int laneCount, float damagePerLane, Dictionary> lanesOf, List sharedItems, - Dictionary itemKey) + // Cycle rule (Self's default, recorded as such in the map): two lanes can cross two different shared + // (multi-cell, non-convex) items in reversed relative order -- lane A reaches item1 before item2, lane B + // reaches item2 before item1 -- so neither pool can ever become ready in each lane's own causal order and + // every remaining lane ends up blocked. When that happens (no lane can advance and no pool is + // independently ready), every CURRENTLY OPEN pool resolves at once, together, from whatever deposits it + // already holds. Each blocked lane sits in exactly one open pool, so this batch always clears every blocked + // lane in one pass, needs no ordering key, and is mirror-symmetric by construction (nothing about which + // lane is "left" or "right" ever enters the tie-break). If somehow no pool is open when every lane is + // blocked, that is a real defect -- rem would otherwise be silently dropped -- so this throws instead of + // breaking out of the loop. + private static float ApplyPooled(Entity target, LaneCell[][] buffers, int[] walked, int laneCount, float damagePerLane) { - var sharedSet = new HashSet(sharedItems); - var pointer = new int[laneCount]; - var rem = new float[laneCount]; - var impactEntry = new float[laneCount]; - var finished = new bool[laneCount]; - var blocked = new bool[laneCount]; + // Pooled bookkeeping (this file's own established convention -- ArrayPool is already how Apply rents + // its own per-lane cell buffers): a shot that never crosses an item is the overwhelmingly common case, + // and must not pay a per-shot heap allocation for lane state it never needs to keep past this call. + var pointer = ArrayPool.Shared.Rent(laneCount); + var rem = ArrayPool.Shared.Rent(laneCount); + var finished = ArrayPool.Shared.Rent(laneCount); + var blocked = ArrayPool.Shared.Rent(laneCount); + try + { for (var li = 0; li < laneCount; li++) { + pointer[li] = 0; + blocked[li] = false; finished[li] = walked[li] == 0; - if (!finished[li]) - { - rem[li] = damagePerLane; - impactEntry[li] = buffers[li][0].Entry; - } - } - - var remainingContributors = new Dictionary(); - var pool = new Dictionary>(); - var resolved = new HashSet(); - foreach (var item in sharedItems) - { - remainingContributors[item] = lanesOf[item].Count; - pool[item] = new List<(int, float)>(); + rem[li] = finished[li] ? 0f : damagePerLane; } + // Lazily allocated: a shot that never crosses an item (the overwhelmingly common case) never allocates + // a pool at all. + Dictionary> pools = null; var hullDamage = 0f; void FinishLane(int li) @@ -799,67 +744,77 @@ public static class FireControl hullDamage += rem[li]; } + // "No other unfinished lane still has that item among its remaining clipped cells" -- read directly off + // each other lane's own remaining walk (from just past wherever it currently sits), never from a + // precomputed reachability table. A lane already IN this item's own pool has already arrived and does + // not need to arrive again. + bool StillReachableByAnotherLane(EquippedItem item, List<(int lane, float amount)> contributors) + { + for (var li2 = 0; li2 < laneCount; li2++) + { + if (finished[li2]) continue; + var alreadyContributed = false; + for (var k = 0; k < contributors.Count; k++) + if (contributors[k].lane == li2) { alreadyContributed = true; break; } + if (alreadyContributed) continue; + + var start = blocked[li2] ? pointer[li2] + 1 : pointer[li2]; + for (var i = start; i < walked[li2]; i++) + if (target.GearOccupancy[buffers[li2][i].Cell.x, buffers[li2][i].Cell.y] == item) return true; + } + return false; + } + void Resolve(EquippedItem item) { - var contributions = pool[item]; - var totalIncoming = 0f; - for (var k = 0; k < contributions.Count; k++) totalIncoming += contributions[k].amount; - var durabilityBefore = item.EquippableItem.Durability; - var absorbed = min(totalIncoming, durabilityBefore); - if (absorbed > 0f) item.EquippableItem.Durability = max(durabilityBefore - absorbed, 0f); - // ItemDamage reports each lane's own INCOMING contribution, not its clamped/actual-absorbed share -- - // the same "incoming, not the post-clamp amount" convention Entity.ItemAbsorb and Entity.ArmorAbsorb - // already use for a single lane (an overkill hit against a nearly-dead item still reports the whole - // swing, not what little durability was left to take). One event per contributing lane, matching - // the one-event-per-Absorb-call cardinality the unshared path already has. - var fraction = totalIncoming > 0f ? absorbed / totalIncoming : 0f; - for (var k = 0; k < contributions.Count; k++) + var contributors = pools[item]; + pools.Remove(item); // a later visit to this same item opens a fresh pool under the same rule + var amounts = ArrayPool.Shared.Rent(contributors.Count); + try { - var (lane, amount) = contributions[k]; - if (amount > 0f) target.ItemDamage.OnNext((item, amount)); - rem[lane] = amount - amount * fraction; + for (var k = 0; k < contributors.Count; k++) amounts[k] = contributors[k].amount; + target.ItemAbsorb(item, new Span(amounts, 0, contributors.Count)); + for (var k = 0; k < contributors.Count; k++) + { + var lane = contributors[k].lane; + rem[lane] = amounts[k]; + blocked[lane] = false; + pointer[lane]++; + if (pointer[lane] >= walked[lane]) FinishLane(lane); + } } - resolved.Add(item); - for (var k = 0; k < contributions.Count; k++) + finally { - var lane = contributions[k].lane; - blocked[lane] = false; - pointer[lane]++; - if (pointer[lane] >= walked[lane]) FinishLane(lane); + ArrayPool.Shared.Return(amounts); } } + void Deposit(int li, EquippedItem item) + { + pools ??= new Dictionary>(); + if (!pools.TryGetValue(item, out var list)) pools[item] = list = new List<(int, float)>(); + list.Add((li, rem[li])); + blocked[li] = true; + if (!StillReachableByAnotherLane(item, list)) Resolve(item); + } + bool AdvanceLane(int li) { var moved = false; while (!finished[li] && !blocked[li]) { - var i = pointer[li]; - if (i > 0 && buffers[li][i].Entry - impactEntry[li] >= shot.Penetration) + if (pointer[li] >= walked[li]) { FinishLane(li); moved = true; break; } - var cell = buffers[li][i].Cell; + var cell = buffers[li][pointer[li]].Cell; rem[li] = target.ArmorAbsorb(cell, rem[li]); moved = true; var item = target.GearOccupancy[cell.x, cell.y]; - if (item != null && sharedSet.Contains(item) && !resolved.Contains(item)) - { - pool[item].Add((li, rem[li])); - remainingContributors[item]--; - blocked[li] = true; - if (remainingContributors[item] == 0) Resolve(item); - break; - } - if (item != null) rem[li] = target.ItemAbsorb(item, rem[li]); - pointer[li]++; - if (pointer[li] >= walked[li]) - { - FinishLane(li); - break; - } + if (item != null) Deposit(li, item); + else pointer[li]++; } return moved; } @@ -879,20 +834,25 @@ public static class FireControl if (!progressed) { - EquippedItem victim = null; - var victimKey = float.PositiveInfinity; - foreach (var item in sharedItems) - { - if (resolved.Contains(item) || pool[item].Count == 0) continue; - var key = itemKey[item]; - if (key < victimKey) { victimKey = key; victim = item; } - } - if (victim == null) break; // unreachable: every unfinished lane is blocked on some pool with >=1 deposit - Resolve(victim); + if (pools == null || pools.Count == 0) + throw new InvalidOperationException( + "FireControl.ApplyPooled: every remaining lane is blocked but no item pool is open -- " + + "a lane's own damage would be lost rather than reaching an item or the hull."); + // Cycle rule: resolve every currently open pool at once, from whatever it already holds -- a + // snapshot of the keys, since Resolve mutates `pools` as it goes. + foreach (var item in new List(pools.Keys)) Resolve(item); } } return hullDamage; + } + finally + { + ArrayPool.Shared.Return(pointer); + ArrayPool.Shared.Return(rem); + ArrayPool.Shared.Return(finished); + ArrayPool.Shared.Return(blocked); + } } // Self's fix (2026-09-25): the ONE public membership query behind both Commit's placement and Apply's From ab14552a1ed5e10abdc3d9a9370bd90c9e86f6a6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 17:02:11 +0200 Subject: [PATCH 040/116] Cut 12.3 fix batch: tests for the one-apply-path pooling rules Commit Soul's probes as behaviour tests against the pooled walk: CycleTieBreakIsMirrorSymmetric (a genuine two-item cyclic dependency, mirror-symmetric and damage-conserving by construction), PooledPathHonoursTheItemThresholdLikeTheSoloPath (the .1f threshold is decided once on the pool total, never per contribution, and pins the pooled ItemDamage event count), and ZeroDepositDoesNotStall (a lane whose armour ate its whole share still deposits and never blocks the pool it belongs to). Adds the Custom fixture hook (an arbitrary named item shape) the cycle test needs to build two crossing non-convex items. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut123Tests.cs | 179 +++++++++++++++++- 1 file changed, 177 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index ed8e5fd79..43f595d28 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -74,6 +74,7 @@ public sealed class FireControlCut123Tests : IDisposable public HullData HullData; public EquippedItem[] Markers; public EquippedItem Cockpit; + public Dictionary Custom = new Dictionary(); } // A shooter off the origin (Cut 11.3), a target over a caller-supplied hull shape, straight ahead so the @@ -87,7 +88,8 @@ public sealed class FireControlCut123Tests : IDisposable GameplaySettings settings, Shape hullShape, float precision, float penetration = 0, float damageSpread = 0, float2? targetFacing = null, (int2 Cell, float Durability)[] markers = null, int2? cockpitCell = null, float cockpitDurability = 40, - (int2 Cell, float Durability)[] bars = null, (int2 Cell, float Durability)[] lBars = null) + (int2 Cell, float Durability)[] bars = null, (int2 Cell, float Durability)[] lBars = null, + (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null) { var hullData = new HullData { @@ -152,6 +154,10 @@ public sealed class FireControlCut123Tests : IDisposable Name = "LBar", Hardpoint = HardpointType.Tool, Shape = LShape(), Durability = 1000000, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); + if (custom != null) + foreach (var cu in custom) + cache.Upsert(new GearData { Name = cu.Name, Hardpoint = HardpointType.Tool, Shape = cu.Shape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); @@ -201,6 +207,15 @@ public sealed class FireControlCut123Tests : IDisposable Assert.True(target.TryEquip(bi, bar.Cell), $"L-bar must fit at {bar.Cell}"); } + var customItems = new Dictionary(); + if (custom != null) + foreach (var cu in custom) + { + var ci0 = Make(cu.Name, lot++, cu.Durability); + Assert.True(target.TryEquip(ci0, cu.Cell), $"custom {cu.Name} must fit at {cu.Cell}"); + customItems[cu.Name] = target.Equipment.Single(x => x.EquippableItem == ci0); + } + EquippedItem cockpit = null; if (cockpitCell != null) { @@ -227,7 +242,7 @@ public sealed class FireControlCut123Tests : IDisposable return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, - HullData = hullData, Markers = markerItems, Cockpit = cockpit + HullData = hullData, Markers = markerItems, Cockpit = cockpit, Custom = customItems }; } @@ -1578,4 +1593,164 @@ public sealed class FireControlCut123Tests : IDisposable } Assert.True(trials >= 80, $"fixture: needed at least 80 of the 90 possible resolved trials, got {trials}"); // 2 shapes * 3 bearings * 15 draws } + + // ===================== Soul's own probes (Cut 12.3, one-apply-path fix batch) ===================== + // These pin the rules the earlier pooled-but-still-branching implementation could not: the cycle tie-break + // (two non-convex items crossed by two lanes in opposite relative order), the pooled path's own threshold + // gate, and a zero deposit not stalling the pool it belongs to. + + private static Shape CellsShape(int w, int h, params (int x, int y)[] cells) + { + var s = new Shape(w, h); + foreach (var c in cells) s[new int2(c.x, c.y)] = true; + return s; + } + + private static void ZeroArmor(Engagement e, int w, int h) + { + for (var x = 0; x < w; x++) + for (var y = 0; y < h; y++) { e.Target.Armor[x, y] = 0f; e.Target.MaxArmor[x, y] = 20f; } + } + + private static ShotOutcome FireCentre(Engagement e, int centreX, Action reset, float damage) + { + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 800 && (outcome == null || !outcome.Hit || outcome.Cell.x != centreX); attempt++) + { + reset(); + outcome = FireUntilHit(e, damageOverride: damage, attempts: 1); + } + Assert.True(outcome != null && outcome.Hit && outcome.Cell.x == centreX, "fixture: centre hit"); + return outcome; + } + + // A genuine cycle: lane col2 reaches item X before item Y down its own walk; lane col3 reaches Y before X. + // Neither item can resolve in either lane's own causal order, so the cycle rule (resolve every open pool at + // once) must fire. Fixture B is fixture A reflected about the centre column -- mirror symmetry means the + // two per-lane leftovers (the outer marker's and the centre marker's) must come out identical on both, and + // total damage must still be fully conserved (armour + items + hull == the shot's own damage) on both. + [Fact] + public void CycleTieBreakIsMirrorSymmetric() + { + (float outer, float centre, float conservation) Run(bool mirrored) + { + var custom = mirrored + ? new[] + { + ("SoulX", CellsShape(3, 4, (1, 0), (2, 0), (2, 1), (2, 2), (2, 3), (1, 3), (0, 3)), new int2(3, 1), 10f), + ("SoulY", CellsShape(2, 2, (0, 0), (0, 1), (1, 1)), new int2(3, 1), 10f) + } + : new[] + { + ("SoulX", CellsShape(3, 4, (1, 0), (0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3)), new int2(1, 1), 10f), + ("SoulY", CellsShape(2, 2, (1, 0), (1, 1), (0, 1)), new int2(2, 1), 10f) + }; + var markers = mirrored + ? new[] { (new int2(4, 3), 1000f), (new int2(3, 3), 1000f) } + : new[] { (new int2(2, 3), 1000f), (new int2(3, 3), 1000f) }; + var e = Build(TestSettings(), SolidShape(7, 6), precision: .6f, penetration: 10f, damageSpread: 1f, + markers: markers, custom: custom); + var x = e.Custom["SoulX"]; + var y = e.Custom["SoulY"]; + var hullBefore = 0f; + void Reset() + { + ZeroArmor(e, 7, 6); + e.Target.Armor[mirrored ? 4 : 2, 1] = 10f; + x.EquippableItem.Durability = 10f; + y.EquippableItem.Durability = 10f; + e.Markers[0].EquippableItem.Durability = 1000f; + e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + e.Target.Hull.Durability = 1000000f; + hullBefore = e.Target.Hull.Durability; + } + FireCentre(e, 3, Reset, 90f); + var armorLost = 10f; + for (var xx = 0; xx < 7; xx++) for (var yy = 0; yy < 6; yy++) armorLost -= e.Target.Armor[xx, yy]; + var itemsLost = (10f - x.EquippableItem.Durability) + (10f - y.EquippableItem.Durability) + + (1000f - e.Markers[0].EquippableItem.Durability) + (1000f - e.Markers[1].EquippableItem.Durability); + var hullLost = hullBefore - e.Target.Hull.Durability; + var outerMarker = 1000f - e.Markers[0].EquippableItem.Durability; + var centreMarker = 1000f - e.Markers[1].EquippableItem.Durability; + return (outerMarker, centreMarker, armorLost + itemsLost + hullLost); + } + var a = Run(false); + var b = Run(true); + Assert.Equal(90f, a.conservation, 2); // nothing lost: the whole shot lands somewhere + Assert.Equal(90f, b.conservation, 2); + Assert.Equal(a.outer, b.outer, 2); // mirror symmetry: the tie-break carries no left/right bias + Assert.Equal(a.centre, b.centre, 2); + } + + // The item's own .1f threshold is decided ONCE on the pooled total, never per contribution. Two lanes + // striking a shared 2x1 bar, each depositing the SAME post-armour remainder: with 0.04 each (sum 0.08, + // still under .1f) nothing should absorb, matching two separate sub-threshold solo hits. With 0.06 each + // (sum 0.12, OVER .1f, but NEITHER contribution alone clears it) the item must still absorb -- a mutant + // that moved the threshold onto each contribution individually, instead of the pool total, would leave + // this second case untouched, since 0.06 never clears .1f by itself. + [Fact] + public void PooledPathHonoursTheItemThresholdLikeTheSoloPath() + { + (float lost, int events) RunShared(float perLanePostArmour) + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, bars: new[] { (new int2(1, 1), 50f) }); + var bar = e.Target.GearOccupancy[1, 1]; + void Reset() + { + ZeroArmor(e, 5, 4); + var armor = 30f - perLanePostArmour; // damagePerLane is 90/3 = 30 (damageSpread 1, 3 lanes) + e.Target.Armor[1, 1] = armor; e.Target.Armor[2, 1] = armor; e.Target.MaxArmor[1, 1] = 30; e.Target.MaxArmor[2, 1] = 30; + bar.EquippableItem.Durability = 50f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + var n = 0; + ShotOutcome o = null; + for (var attempt = 0; attempt < 800 && (o == null || !o.Hit || o.Cell.x != 2); attempt++) + { + Reset(); + n = 0; + using var sub = e.Target.ItemDamage.Subscribe(_ => n++); + o = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.NotNull(o); + Assert.True(o.Hit && o.Cell.x == 2, "fixture: needed a hit on the centre interior column within the attempt budget"); + return (50f - bar.EquippableItem.Durability, n); + } + + var underThreshold = RunShared(0.04f); // sum 0.08: under .1f either way + Assert.Equal(0f, underThreshold.lost, 4); + Assert.Equal(0, underThreshold.events); + + var overThreshold = RunShared(0.06f); // sum 0.12: over .1f as a pool, under .1f per contribution + Assert.Equal(0.12f, overThreshold.lost, 3); + // Soul's own event-cardinality pin: a mutation that deletes the pooled ItemDamage.OnNext call survived + // every other test in this file, because most fixtures only ever check durability. One event per + // contributing lane (both contributed > 0 here), not one event per resolve. + Assert.Equal(2, overThreshold.events); + } + + // A lane whose armour eats its entire share before ever reaching a shared item still deposits (a zero + // amount) and must not stall the pool waiting for a contribution that will never come with more than zero + // in it -- the pool resolves as soon as every OTHER lane that could still reach the item is done, whatever + // this lane itself brought. + [Fact] + public void ZeroDepositDoesNotStall() + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 1), 10f) }, markers: new[] { (new int2(1, 2), 1000f), (new int2(2, 2), 1000f) }); + var bar = e.Target.GearOccupancy[1, 1]; + void Reset() + { + ZeroArmor(e, 5, 4); + e.Target.Armor[1, 0] = 1000f; e.Target.MaxArmor[1, 0] = 1000f; e.Target.Armor[2, 1] = 15f; + bar.EquippableItem.Durability = 10f; + e.Markers[0].EquippableItem.Durability = 1000f; e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + FireCentre(e, 2, Reset, 90f); + Assert.Equal(0f, bar.EquippableItem.Durability, 3); // the bar's own 10 durability, fully spent by column 2 alone + Assert.Equal(0f, 1000 - e.Markers[0].EquippableItem.Durability, 3); // column 1's own zero deposit: nothing reaches its marker + Assert.Equal(5f, 1000 - e.Markers[1].EquippableItem.Durability, 3); // column 2's own leftover (30-15-10=5) reaches its marker + } } From 7ca2f64f434759c4f703f63032998b0fdeae324b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 17:15:19 +0200 Subject: [PATCH 041/116] Cut 1 fix batch: revert stray blank-line deletion in Program.cs The Cut 1 restore-hulls command deletion also dropped a blank line separating FitCommand from the dangling-refs command block. Restore it so the file is byte-identical to base (git diff against pre-Cut-1 now empty). Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index bd6d1ef08..46aae9575 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -539,6 +539,7 @@ public static class Program foreach (var field in missing) Console.WriteLine($" {field}"); return missing.Count; } + // Every CultRecordRef in the catalog that resolves to nothing, as " -> ". // Each "clear " argument unsets that member's dangling refs, or removes that dictionary's // dangling keys (Faction.BossHull clears boss hulls that would claim a chokepoint no boss can spawn in). Dry From 166b6202b188920eb1e97e864ba6ffe5ff4dbc06 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 17:38:08 +0200 Subject: [PATCH 042/116] Cut 12.3 fix batch: the model sweep is now an independent fixed point Rewrite the shared-item model sweep instead of leaving it a follow-up: the earlier ModelWithSharedItems copied FireControl.Apply's own survey-then-walk shape closely enough that reverting production to a plain sequential per-lane absorb (no pooling at all) still passed it. FixedPointItemResolution settles items in a different order from production: it batches every lane's own armour-only walk into full passes, then checks every currently open pool for readiness once per whole pass (a dynamic, re-derived-each-time check, not a table built once up front -- a precomputed-once version deadlocked on a non-convex item crossed twice by the same lane). Lane geometry is read from the real FireControl.Lanes instead of a second hand-rolled walk, removing a class of disagreement that had nothing to do with absorption order. Durabilities are now drawn below a lane's own share so every shared hit overflows, hull damage is compared alongside armour and items, and the tolerance is tightened now that lane geometry can no longer drift. Also fixes: rejected attempts in the retry loop were mutating real state without resetting it, corrupting the "before" snapshot; the model was missing Entity.ItemAbsorb's own pooled .1f threshold. Reran M0 (control, green) through M4: M1 (forced sequential absorb) now kills the sweep as well as the mirror/proportional tests. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut123Tests.cs | 318 ++++++++++++------ 1 file changed, 208 insertions(+), 110 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 43f595d28..5fbd879ae 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -1404,132 +1404,195 @@ public sealed class FireControlCut123Tests : IDisposable Assert.Equal(0, shortfalls); } - // ===================== F8: the model sweep, extended with the proportional rule ===================== - // Ports SoulApply123b's own independent model (armour, then item, then hull, near-to-far per lane, - // ModelLane above -- the exact double-precision ray/box walk F1's own DirectHitAtAnAngledBearingMatches... - // test already uses) and extends it with the proportional multi-lane item rule this cut adds: an item - // touched by more than one lane absorbs their SUMMED post-armour incoming (up to its own durability), each - // lane's leftover in proportion to what it brought. Structured the same way as FireControl.Apply's own new - // code (a survey of which items are geometrically shared, then an AdvanceLane/Resolve loop) -- not because - // a model must mirror production, but because it is the simplest correct way to get an item's resolution - // order-independent of which lane the loop visits first, which is exactly the property under test. Compares - // against production Apply through Fire/Commit/Apply (no reflection), over a sweep of shapes, bearings - // (axis and angled) and item shapes (2x1 bars and non-convex L-shaped items, LShape above). - private static (double[,] armor, Dictionary items, double hull) ModelWithSharedItems( - Entity target, Shape shape, double[,] armorBefore, Dictionary itemsBefore, - double bx, double by, double s, double damage, double penetration, int n) + // ===================== F8/Soul: the model sweep, rewritten as an independent fixed point ===================== + // Soul's finding (2026-09-25): the earlier sweep copied FireControl.Apply's own structure (a survey of + // shared items, then an AdvanceLane/Resolve loop, lane-driven) closely enough that reverting production to + // a plain sequential per-lane absorb (M1, no pooling at all) still passed it. A model built the same way + // production is built can share production's own blind spot. `FixedPointItemResolution` below settles items + // in a DIFFERENT order from production's own inline, per-deposit check: it repeats PASSES over every lane + // (armour only, never coupled across lanes) and then, once a WHOLE pass of every lane is done, checks every + // currently open pool for readiness in one batch (`ReadyToSettle`) -- production instead asks the readiness + // question the instant a single lane deposits, mid-walk, never waiting for a whole pass. `ReadyToSettle` + // still re-derives its answer fresh from each other lane's CURRENT position every time it is asked (not + // from a table built once up front): a precomputed-once reach table was tried first and produced a real + // deadlock on a non-convex item crossed twice by the SAME lane, because a set keyed by item identity cannot + // tell a two-lane first pool apart from the one-lane pool the item's second visit degenerates into. A + // genuine cross-item cycle (every remaining lane blocked, nothing left to converge) is broken by + // force-settling whatever is still open, in the REVERSE of first-encountered order -- the opposite + // tie-break direction from production's own cycle rule, so the two could only agree on a genuine cycle's + // numbers by actually computing the same conservation-respecting split, not by sharing a tie-break + // convention. + // + // Geometry itself is NOT reimplemented a third time: which cells belong to which lane is read directly from + // `FireControl.Lanes`, the one public function this whole cut already treats as the single geometric owner + // (`LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull` above pins its own contract independently). + // An earlier version of this rewrite reimplemented lane membership with the plain double-precision + // `ModelLane` walk (the same primitive F1's own single-lane model uses); reading the real `Lanes` output + // instead removes any chance of the model and production disagreeing about which cell belongs to which + // lane, leaving only genuine float32-vs-double arithmetic drift in the absorption chain -- small enough + // that the tolerance below could actually be tightened, rather than only claimed tightened. + private static (double[,] armor, Dictionary items, double hull) FixedPointItemResolution( + Entity target, HullData hullData, double[,] armorBefore, Dictionary itemsBefore, + float2 bearing, float lateral, float damage, float penetration, int n) { var armor = (double[,]) armorBefore.Clone(); var items = new Dictionary(itemsBefore); - var laneSpacing = Math.Abs(bx) + Math.Abs(by); var laneCount = 2 * n + 1; - var lanes = new List[laneCount]; - for (var li = 0; li < laneCount; li++) lanes[li] = ModelLane(shape, bx, by, s + (li - n) * laneSpacing); - var metal = lanes.Count(l => l.Count > 0); - var per = damage / metal; + var ell = float2(-bearing.y, bearing.x); + var laneSpacing = Math.Abs(ell.x) + Math.Abs(ell.y); // the same shadow-width formula Extent/Lateral own - var pointer = new int[laneCount]; - var rem = new double[laneCount]; - var finished = new bool[laneCount]; - var blocked = new bool[laneCount]; - for (var li = 0; li < laneCount; li++) { finished[li] = lanes[li].Count == 0; rem[li] = per; } + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) buffers[li] = new LaneCell[hullData.Shape.Coordinates.Length]; + var walked = FireControl.Lanes(hullData, bearing, lateral, n, laneSpacing, buffers); - // Survey: which items are geometrically reachable by more than one distinct lane, independent of any - // remaining damage -- exactly the question FireControl.Apply's own survey asks. - var lanesOf = new Dictionary>(); + // The one clip production performs, against `Lanes`' own real output -- a two-line geometric fact, not + // the resolution order under test. + var cells = new List[laneCount]; for (var li = 0; li < laneCount; li++) { - if (lanes[li].Count == 0) continue; - var en0 = lanes[li][0].En; - for (var i = 0; i < lanes[li].Count; i++) + var clipped = new List(); + if (walked[li] > 0) { - if (i > 0 && lanes[li][i].En - en0 >= penetration) break; - var item = target.GearOccupancy[lanes[li][i].C.x, lanes[li][i].C.y]; - if (item == null) continue; - if (!lanesOf.TryGetValue(item, out var list)) lanesOf[item] = list = new List(); - if (!list.Contains(li)) list.Add(li); + var impactEntry = buffers[li][0].Entry; + var count = 1; + while (count < walked[li] && buffers[li][count].Entry - impactEntry < penetration) count++; + for (var i = 0; i < count; i++) clipped.Add(buffers[li][i].Cell); } + cells[li] = clipped; } - var sharedSet = new HashSet(lanesOf.Where(kv => kv.Value.Count > 1).Select(kv => kv.Key)); - var remainingContributors = sharedSet.ToDictionary(it => it, it => lanesOf[it].Count); - var pool = sharedSet.ToDictionary(it => it, it => new List<(int lane, double amount)>()); - var resolved = new HashSet(); + var metal = cells.Count(c => c.Count > 0); + var per = (double) damage / metal; + + var at = new int[laneCount]; // how many of this lane's own cells are fully settled + var carry = new double[laneCount]; // damage this lane carries into cell `at[li]` + var done = new bool[laneCount]; + for (var li = 0; li < laneCount; li++) { done[li] = cells[li].Count == 0; carry[li] = done[li] ? 0 : per; } + var hull = 0.0; + var openPool = new Dictionary>(); - void FinishLane(int li) { finished[li] = true; hull += rem[li]; } + void Land(int li) { done[li] = true; hull += carry[li]; } - void Resolve(EquippedItem item) + // Every non-finished lane, at the moment this is called (always AFTER a full pass over every lane, never + // mid-pass), is either done or sitting blocked at exactly one item cell -- there is no third "still + // walking" state left once a pass completes. So "no other unfinished lane can still reach this item" + // reduces to: does `item` occur anywhere in an OTHER non-contributing lane's remaining cell list, from + // where it currently sits onward. Unlike a precomputed table keyed by item identity, this re-derives + // the answer fresh each time from each lane's CURRENT position -- which is what correctly demotes a + // non-convex item's SECOND visit by the same lane to a solo pool once every other lane has already + // passed the item for good, without needing a separate notion of "which visit number" this is. + bool ReadyToSettle(EquippedItem item, Dictionary contributors) { - var contributions = pool[item]; - var total = contributions.Sum(c => c.amount); + for (var li2 = 0; li2 < laneCount; li2++) + { + if (done[li2] || contributors.ContainsKey(li2)) continue; + for (var i = at[li2]; i < cells[li2].Count; i++) + if (target.GearOccupancy[cells[li2][i].x, cells[li2][i].y] == item) return false; + } + return true; + } + + void Settle(EquippedItem item) + { + var deposits = openPool[item]; + openPool.Remove(item); // a later visit opens a fresh pool, the same rule as production + var total = deposits.Values.Sum(); + // Entity.ItemAbsorb's own .1f gate, decided once on the pool total -- an earlier version of this + // model omitted it entirely (absorbing any positive total, however small) and it took a real + // production trace to catch: a 0.03 pooled total should pass straight through untouched, the same + // as a solo 0.03 hit always has, not vanish into an item with plenty of spare durability. var before = items[item]; - var absorbed = Math.Min(total, before); + var absorbed = total > 0.1 ? Math.Min(total, before) : 0.0; items[item] = Math.Max(before - absorbed, 0); var fraction = total > 0 ? absorbed / total : 0; - foreach (var (lane, amount) in contributions) rem[lane] = amount - amount * fraction; - resolved.Add(item); - foreach (var (lane, _) in contributions) + foreach (var (lane, amount) in deposits) { - blocked[lane] = false; - pointer[lane]++; - if (pointer[lane] >= lanes[lane].Count) FinishLane(lane); + carry[lane] = amount - amount * fraction; + at[lane]++; + if (at[lane] >= cells[lane].Count) Land(lane); } } - bool AdvanceLane(int li) + // The fixed point: repeat PASSES over lanes (armour only, never coupled across lanes) and then over + // every currently open item (settle whichever now has its whole precomputed reach set deposited) until + // a whole pass changes nothing. + var progressed = true; + while (progressed) { - var moved = false; - while (!finished[li] && !blocked[li]) - { - var i = pointer[li]; - if (i > 0 && lanes[li][i].En - lanes[li][0].En >= penetration) { FinishLane(li); moved = true; break; } - var c = lanes[li][i].C; - if (rem[li] > 0) { var a = armor[c.x, c.y]; armor[c.x, c.y] = Math.Max(a - rem[li], 0); rem[li] = Math.Max(rem[li] - a, 0); } - moved = true; - var item = target.GearOccupancy[c.x, c.y]; - if (item != null && rem[li] > .1 && sharedSet.Contains(item) && !resolved.Contains(item)) - { - pool[item].Add((li, rem[li])); - remainingContributors[item]--; - blocked[li] = true; - if (remainingContributors[item] == 0) Resolve(item); - break; - } - if (item != null && rem[li] > .1) - { - var d = items[item]; - items[item] = Math.Max(d - rem[li], 0); - rem[li] = Math.Max(rem[li] - d, 0); - } - pointer[li]++; - if (pointer[li] >= lanes[li].Count) { FinishLane(li); break; } - } - return moved; - } + progressed = false; - bool AllFinished() { for (var li = 0; li < laneCount; li++) if (!finished[li]) return false; return true; } - - while (!AllFinished()) - { - var progressed = false; for (var li = 0; li < laneCount; li++) - if (!finished[li] && !blocked[li] && AdvanceLane(li)) progressed = true; - if (!progressed) { - // Deadlock fallback (the "opposite order" case, docs/fire-control-cut.md Cut 12.3 status): not - // exercised by this sweep's own single-shared-item fixtures, kept only for parity with - // production's own tie-break so an unexpectedly non-convex draw cannot hang the model. - EquippedItem victim = null; - foreach (var item in sharedSet) - if (!resolved.Contains(item) && pool[item].Count > 0) { victim = item; break; } - if (victim == null) break; - Resolve(victim); + if (done[li]) continue; + while (at[li] < cells[li].Count) + { + var c = cells[li][at[li]]; + var item = target.GearOccupancy[c.x, c.y]; + // Already deposited here on an earlier pass, still waiting on the item to settle -- do NOT + // touch armour or deposit again; this cell is not revisited until Settle advances `at[li]`. + if (item != null && openPool.TryGetValue(item, out var waiting) && waiting.ContainsKey(li)) break; + + // Armour applies at EVERY cell this lane reaches, item or not (armour is never shared -- + // Entity.ArmorAbsorb runs unconditionally on a cell before any item check, and this model + // mirrors that ordinary per-lane physics without needing to copy production's own walk). + if (carry[li] > 0) { var a = armor[c.x, c.y]; armor[c.x, c.y] = Math.Max(a - carry[li], 0); carry[li] = Math.Max(carry[li] - a, 0); } + if (item == null) + { + at[li]++; + progressed = true; + continue; + } + if (!openPool.TryGetValue(item, out var deposits)) openPool[item] = deposits = new Dictionary(); + deposits[li] = carry[li]; + progressed = true; + break; // this lane goes no further until the item settles + } + if (!done[li] && at[li] >= cells[li].Count) Land(li); } + + foreach (var item in new List(openPool.Keys)) + if (ReadyToSettle(item, openPool[item])) { Settle(item); progressed = true; } } + // Cycle break: a genuine cyclic dependency between two non-convex items leaves pools open with no lane + // able to progress. Force-settle whatever remains, in the REVERSE of first-encountered order -- + // deliberately the opposite tie-break to production's own, so agreement on a genuine cycle's numbers + // can only come from both sides actually conserving and splitting correctly. + foreach (var item in Enumerable.Reverse(new List(openPool.Keys))) + if (openPool.ContainsKey(item)) Settle(item); + return (armor, items, hull); } + // How many of a shot's own lanes geometrically reach `item` at all, at the COMMITTED bearing/lateral -- read + // from the real `FireControl.Lanes`/clip, the same as `FixedPointItemResolution` above. Used only to gate + // the sweep below onto draws that actually exercise cross-lane sharing, the same way RunSharedItemCase and + // MirrorSymmetryHoldsWithASharedMultiCellItem retry until their own committed column lands where they need + // it. + private static int LanesReaching(HullData hullData, float2 bearing, float lateral, int n, float penetration, Entity target, EquippedItem item) + { + var laneCount = 2 * n + 1; + var ell = float2(-bearing.y, bearing.x); + var laneSpacing = Math.Abs(ell.x) + Math.Abs(ell.y); + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) buffers[li] = new LaneCell[hullData.Shape.Coordinates.Length]; + var walked = FireControl.Lanes(hullData, bearing, lateral, n, laneSpacing, buffers); + + var count = 0; + for (var li = 0; li < laneCount; li++) + { + if (walked[li] == 0) continue; + var impactEntry = buffers[li][0].Entry; + var clipped = 1; + while (clipped < walked[li] && buffers[li][clipped].Entry - impactEntry < penetration) clipped++; + var reached = false; + for (var i = 0; i < clipped; i++) + if (target.GearOccupancy[buffers[li][i].Cell.x, buffers[li][i].Cell.y] == item) { reached = true; break; } + if (reached) count++; + } + return count; + } + [Fact] public void ModelSweepAgreesWithProductionIncludingSharedMultiCellItems() { @@ -1550,13 +1613,17 @@ public sealed class FireControlCut123Tests : IDisposable var useLBar = t % 2 == 0; var itemX = 1 + rng.Next(shape.Width - 3); const int itemY = 1; // the only row that is interior with room for a 2-row-tall L above it (height 4) - var durability = (float) (5 + rng.NextDouble() * 20); + // Soul: durability BELOW a single lane's own share (damage 40 over up to 5 lanes, so roughly + // 8-13 per lane before armour) guarantees the item overflows whenever it is shared -- the + // per-lane split always matters, never a coincidental full-absorb-either-way case. + var durability = (float) (2 + rng.NextDouble() * 4); var e = useLBar ? Build(TestSettings(), shape, precision: 1f, penetration: 3f, damageSpread: 2f, lBars: new[] { (new int2(itemX, itemY), durability) }) : Build(TestSettings(), shape, precision: 1f, penetration: 3f, damageSpread: 2f, bars: new[] { (new int2(itemX, itemY), durability) }); e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + var sharedItem = e.Target.GearOccupancy[itemX, itemY]; foreach (var c in shape.Coordinates) { @@ -1567,31 +1634,62 @@ public sealed class FireControlCut123Tests : IDisposable var armorBefore = ToD(e.Target.Armor); var itemsBefore = e.Target.Equipment.Where(x => x.EquippableItem != e.Target.Hull) .ToDictionary(x => x, x => (double) x.EquippableItem.Durability); + var hullBefore = e.Target.Hull.Durability; - ShotOutcome outcome = null; - for (var attempt = 0; attempt < 400 && (outcome == null || !outcome.Hit); attempt++) - outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); - if (outcome == null || !outcome.Hit) continue; // fixture: rare miss exhaustion, skip this draw - trials++; + // Every rejected attempt below still fires a real shot and mutates real state -- reset back to + // the captured baseline before each one, or a rejected miss/mismatch would silently corrupt the + // "before" snapshot the accepted shot is later compared against (the bug that produced this + // sweep's very first false failure while writing this rewrite: armour and item durability had + // already been chipped by earlier rejected attempts by the time an accepted shot was found). + void ResetState() + { + foreach (var c in shape.Coordinates) e.Target.Armor[c.x, c.y] = (float) armorBefore[c.x, c.y]; + foreach (var kv in itemsBefore) kv.Key.EquippableItem.Durability = (float) kv.Value; + e.Target.Hull.Durability = hullBefore; + } const int n = 2; // damageSpread 2 -> floor(2.5) = 2 - var model = ModelWithSharedItems(e.Target, shape, armorBefore, itemsBefore, - outcome.Bearing.x, outcome.Bearing.y, outcome.Lateral, 40, 3, n); + ShotOutcome outcome = null; + var hullEvent = 0.0; + using (e.Target.HullDamage.Subscribe(d => hullEvent += d)) + { + // Retry until the shot both hits AND actually shares `sharedItem` between >=2 lanes -- + // the property this test exists to exercise, not left to chance. + for (var attempt = 0; attempt < 1200; attempt++) + { + ResetState(); + hullEvent = 0.0; + var o = FireUntilHit(e, damageOverride: 40f, attempts: 1); + if (o == null || !o.Hit) continue; + if (LanesReaching(e.HullData, o.Bearing, o.Lateral, n, 3f, e.Target, sharedItem) < 2) continue; + outcome = o; + break; + } + } + if (outcome == null) continue; // fixture: rare exhaustion of the shared-hit budget, skip this draw + trials++; - // Absolute tolerance rather than a fixed decimal-place rounding: the model runs in double and - // production in float, so a value that lands within a float ULP or two of a rounding boundary - // (e.g. 1.54999995 vs 1.55000019) can round to different digits at 1-2 decimal places despite - // agreeing far more closely than that -- exactly the kind of float-vs-double drift F1's own - // DirectHitAtAnAngledBearingMatchesAnIndependentModel test already tolerates with its own `, 2`. + var model = FixedPointItemResolution(e.Target, e.HullData, armorBefore, itemsBefore, + outcome.Bearing, outcome.Lateral, 40f, 3f, n); + + // Tightened from an earlier `< .1` (Soul, 2026-09-25). That wide tolerance was masking two + // different things at once: genuine float32-vs-double arithmetic drift (small, ~1e-3) AND this + // sweep's own model disagreeing with `Lanes`' rare cross-lane cell-ownership tie-break (large + // enough to read as a clean 0.03-0.09, reproducible, NOT noise -- caught and removed above by + // reading lane membership from the real `FireControl.Lanes` instead of reimplementing it). + // With that class of disagreement gone, what's left really is arithmetic noise. + const double tolerance = .01; foreach (var c in shape.Coordinates) - Assert.True(Math.Abs(model.armor[c.x, c.y] - e.Target.Armor[c.x, c.y]) < .1, + Assert.True(Math.Abs(model.armor[c.x, c.y] - e.Target.Armor[c.x, c.y]) < tolerance, $"armor{c}: model {model.armor[c.x, c.y]:F4} production {e.Target.Armor[c.x, c.y]:F4}"); foreach (var kv in model.items) - Assert.True(Math.Abs(kv.Value - (double) kv.Key.EquippableItem.Durability) < .1, + Assert.True(Math.Abs(kv.Value - (double) kv.Key.EquippableItem.Durability) < tolerance, $"item#{kv.Key.GetHashCode()}: model {kv.Value:F4} production {kv.Key.EquippableItem.Durability:F4}"); + Assert.True(Math.Abs(model.hull - hullEvent) < tolerance, + $"hull: model {model.hull:F4} production {hullEvent:F4}"); } } - Assert.True(trials >= 80, $"fixture: needed at least 80 of the 90 possible resolved trials, got {trials}"); // 2 shapes * 3 bearings * 15 draws + Assert.True(trials >= 40, $"fixture: needed at least 40 shared-item trials (of up to 90 draws), got {trials}"); } // ===================== Soul's own probes (Cut 12.3, one-apply-path fix batch) ===================== From dd4375801c6b2b1350b9531b0e755c728e95b22d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 17:38:44 +0200 Subject: [PATCH 043/116] Cut 1 fix batch: Soul's catalog findings (Djinni asset refs, thruster curves, ThermalResilience, legacy keys) Landed via `dotnet run --project tools/AetherDb -- fix-loco1 apply`, then deleted the command in the same batch (Cut 9/Cut 1 precedent). Fixes, each against the decoded 2021-04-14 legacy record or a named catalog precedent: - Djinni.Prefab was a fabricated/truncated GUID; corrected to Djinni.prefab.meta's own GUID. - All five restored thrusters' ParticlesPrefab (null) -> Thruster 1.prefab's GUID, the same one Large Drive uses (legacy record names the same prefab path for all of them). - All five thrusters' invented OptimalTemperature/PlateauWidth (300/100) -> Large Drive's own 200-400 precedent, 251/24 (docs/stats-power-cut1-migration.md). - ThermalResilience: Medium Drive/Small Drive -> 10, Victoire/Talaria/RevvITup 2.0 -> 15 (legacy record). Victoire's Durability 100 -> 30 (legacy). Victoire and Medium Drive's legacy descriptions restored (Talaria/RevvITup 2.0's legacy descriptions are the literal placeholder "[TODO]" and are not). - Djinni.Schematic (null) -> djinni_schematic_transparent.png's GUID, already in the tree, unused. - Djinni's OptimalTemperature 250 -> 251, correcting a rounding slip in restore-hulls's own "apply LonginusX's optimal-fraction to Djinni's band" derivation ((275.15-173.15)/400 = .255 of band applied to Djinni's 200-unit band rounds to 251, not 250). - All seven restored records (Longinus, Djinni, five thrusters) re-keyed from freshly minted GUIDs to their own legacy GUIDs, decoded directly from GameData/Legacy/AetherDB.2021-04-14.msgpack, matching the Cut 9 import precedent (tools/AetherDb/Import.cs, git history). Each record's own FactionProductData.Design ref repointed at the new key. Verified: dry run then apply, second apply refused (Longinus already carries its legacy key), `dangling` reports 0, `census` still names both hulls, `loadout` generates every hull cleanly across seeds 1-5. Re-keying changed GetAll enumeration order, which RestoredHullMovesAndTurnsUnderMovementAndLookDirection's design-selection (thrusterDesigns.First()) depended on implicitly: it started landing every Djinni thruster hardpoint on Victoire, the one restored thruster with a real EnergyUsage draw, and the fixture ship carries no reactor. Fixed by equipping the fixture ship's own Reactor hardpoint, which every restored hull actually has, instead of relying on incidental unpowered-item ordering. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 +- .../RestoredHullsTests.cs | 15 ++ tools/AetherDb/Program.cs | 129 +++++++++++++++++- 3 files changed, 145 insertions(+), 3 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 5611652ff..200c93e22 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:326a69cbbf157541607fcb4d754945efab8aba53c505b01b168393620d7ee3ec -size 12697724 +oid sha256:2aa79c0adca4e50b608f1c7154ad847777d62af83b918b6de19eb881e8fdd7e9 +size 12698108 diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 623f9a1f9..18cf51de5 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -144,6 +144,20 @@ public sealed class RestoredHullsTests Assert.True(ship.TryEquip(gearItem, hardpoint.Position)); } + // A reactor: Victoire (one of Cut 1's own restored thrusters, docs/locomotion-cut.md's own note on it) + // carries a real EnergyUsage draw unlike its four siblings, so an unpowered ship reads as though only + // some fitting thrusters produce thrust -- an artifact of which design TryEquip's own hardpoint search + // happens to land on, not of the restored content. Every hull here also has a Reactor hardpoint; a real + // ship always carries one, so this fixture should too. + var reactorHardpoint = hull.Hardpoints.FirstOrDefault(h => h.Type == HardpointType.Reactor); + if (reactorHardpoint != null) + { + var reactorDesign = cache.GetAll().First(g => g.Hardpoint == HardpointType.Reactor && + g.Shape.FitsWithin(reactorHardpoint.Shape, reactorHardpoint.Rotation, out _) && g.Shape.Coordinates.Length == reactorHardpoint.Shape.Coordinates.Length); + var reactorItem = new EquippableItem { Data = cache.RefOf(reactorDesign), Durability = reactorDesign.Durability, Lot = lot++ }; + Assert.True(ship.TryEquip(reactorItem, reactorHardpoint.Position)); + } + zone.Entities.Add(ship); ship.Position = float3.zero; ship.Activate(); @@ -155,6 +169,7 @@ public sealed class RestoredHullsTests ship.MovementDirection = float2(0, 1); // full forward, same axis ActionGameManager.Input.Player.Move drives ship.LookDirection = float3(1, 0, 0); // off-axis target heading, so the mixer's yaw thrusters have work to do for (var i = 0; i < 10; i++) zone.Update(1f / 60f); + System.Console.WriteLine($"DEBUG end velocity {ship.Velocity} direction {ship.Direction}"); Assert.NotEqual(startVelocity, ship.Velocity); Assert.NotEqual(startDirection, ship.Direction); diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 46aae9575..46f1fad9d 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,8 +36,9 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); + case "fix-loco1": return FixLoco1(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], fix-loco1 [apply]"); return 1; } } @@ -1474,4 +1475,130 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } + + // Cut 1 fix batch (docs/locomotion-cut.md, Soul's findings on the restore-hulls landing). One-shot, in the + // shape of the other *-migrate/fix commands here: dry run unless "apply", refuses a second apply (Longinus + // already carrying its legacy key is the refusal signal), deleted again once landed. + // + // 1. Djinni.Prefab was a fabricated GUID ("79024f6300000000000000000000000", 31 hex digits); the real one + // is Djinni.prefab.meta's own guid. + // 2. All five restored thrusters had ParticlesPrefab null; the legacy record's own thrust behaviour names + // "Assets/Resources/Prefabs/Thrusters/Thruster 1.prefab" for every one of them, the same prefab Large + // Drive already uses. + // 3. All five carried invented OptimalTemperature/PlateauWidth (300/100). docs/stats-power-cut1-migration.md's + // precedent for a 200-400 thruster is Large Drive's own 251/24; applied here to all five instead. + // 4. ThermalResilience (legacy 2021-04-14 record, decoded directly): 10 for Medium and Small Drive, 15 for + // Victoire, Talaria and RevvITup 2.0. Victoire's Durability is legacy 30, not the invented 100. Victoire + // and Medium Drive's legacy descriptions are restored (Talaria/RevvITup 2.0's legacy descriptions are the + // literal placeholder "[TODO]" and are not). + // 5. Djinni.Schematic was null; the live catalog already carries the asset, unused, at + // djinni_schematic_transparent.png's own guid. + // 6. Djinni's OptimalTemperature/PlateauWidth (250/24) round the same "apply LonginusX's own optimal-fraction + // and plateau/range ratio to Djinni's band" derivation restore-hulls's own header comment named, but round + // to 250 instead of 251; corrected to match the derivation exactly (LonginusX: (275.15-173.15)/400 = + // .255 of band applied to Djinni's 200-unit band = 251, not 250). + // 7. All seven restored records (Longinus, Djinni, five thrusters) landed under freshly minted keys. The + // Cut 9 import precedent (tools/AetherDb/Import.cs, git history) kept every legacy Guid as the record's + // own key. Re-keyed here to each record's own legacy Guid, decoded directly from + // GameData/Legacy/AetherDB.2021-04-14.msgpack, and each record's own FactionProductData.Design ref is + // repointed at the new key. + private static int FixLoco1(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var cache = db.Cache; + + // Each record's own legacy key (decoded from the 2021-04-14 msgpack record directly; the .NET Guid + // byte layout Import.cs's ReadGuid used, confirmed by this fix's own header note 7). + var legacyKeys = new Dictionary + { + ["Longinus"] = "c4b20032-8cd3-4206-807f-97b296797425", + ["Djinni"] = "bb5d9f73-612f-4060-8fb4-f72d6b93b616", + ["Medium Drive"] = "ef0f782e-9fa8-4059-bb8c-f16e1d3de100", + ["Small Drive"] = "9013c312-f37f-42a8-9efc-3d699759923b", + ["Victoire"] = "88e81a2b-45fd-49de-830f-c896adae8d5b", + ["Talaria"] = "d706ff66-c98c-4e60-a8e5-5dceb8dc3742", + ["RevvITup 2.0"] = "ebfb35b5-3905-4510-a8cf-c09528f27172", + }; + + var longinus = cache.GetByName("Longinus"); + var djinni = cache.GetByName("Djinni"); + var mediumDrive = cache.GetByName("Medium Drive"); + var smallDrive = cache.GetByName("Small Drive"); + var victoire = cache.GetByName("Victoire"); + var talaria = cache.GetByName("Talaria"); + var revvItUp = cache.GetByName("RevvITup 2.0"); + if (new EquippableItemData[] { longinus, djinni, mediumDrive, smallDrive, victoire, talaria, revvItUp }.Any(d => d == null)) + { + Console.WriteLine("Cut 1's restored records are not all present; nothing to fix."); + return 1; + } + + if (cache.RefOf(longinus).Key.Value == legacyKeys["Longinus"]) + { + Console.WriteLine("Cut 1 fix batch already applied (Longinus already carries its legacy key)."); + return 1; + } + + djinni.Prefab = "79024f635e46f5546b670b58b07fa037"; // Djinni.prefab.meta + djinni.Schematic = "66f3acdd44f2d1d4ba287c396b60a494"; // djinni_schematic_transparent.png + djinni.OptimalTemperature = 251; // LonginusX's own optimal-fraction (.255) applied to Djinni's 200-unit band + djinni.PlateauWidth = 24; + + var fiveThrusters = new[] { mediumDrive, smallDrive, victoire, talaria, revvItUp }; + foreach (var design in fiveThrusters) + { + var thruster = (ThrusterData) design.Behaviors.Single(b => b is ThrusterData); + thruster.ParticlesPrefab = "016516404b2bc1649bdf789bfc96d1cb"; // Thruster 1.prefab, same as Large Drive + design.OptimalTemperature = 251; // Large Drive's own 200-400 precedent (docs/stats-power-cut1-migration.md) + design.PlateauWidth = 24; + } + mediumDrive.ThermalResilience = 10; + smallDrive.ThermalResilience = 10; + victoire.ThermalResilience = 15; + talaria.ThermalResilience = 15; + revvItUp.ThermalResilience = 15; + victoire.Durability = 30; + victoire.Description = "The most responsive thruster on the market, for the most irresponsible of racing pilots."; + mediumDrive.Description = "Keeps you going and going and going..."; + + var designs = new EquippableItemData[] { longinus, djinni, mediumDrive, smallDrive, victoire, talaria, revvItUp }; + + Console.WriteLine("=== Cut 1 fix batch ==="); + Console.WriteLine("Djinni: Prefab, Schematic, OptimalTemperature 250->251"); + Console.WriteLine("Medium Drive, Small Drive, Victoire, Talaria, RevvITup 2.0: ParticlesPrefab, OptimalTemperature/PlateauWidth 300/100 -> 251/24"); + Console.WriteLine("ThermalResilience: Medium Drive/Small Drive -> 10, Victoire/Talaria/RevvITup 2.0 -> 15"); + Console.WriteLine("Victoire: Durability -> 30, description restored"); + Console.WriteLine("Medium Drive: description restored"); + Console.WriteLine("Re-keying to legacy Guids: " + string.Join(", ", designs.Select(d => $"{d.Name} -> {legacyKeys[d.Name]}"))); + + foreach (var design in designs) CultRecordRefs.Validate(design); + + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to land these fixes in Aetheria.cc."); + return 0; + } + + var oldKeys = designs.ToDictionary(d => d.Name, d => cache.RefOf(d).Key); + var products = cache.GetAll().Where(p => legacyKeys.ContainsKey(p.Name)).ToArray(); + var productKeys = products.ToDictionary(p => p.Name, p => cache.RefOf(p).Key); + + db.Cache.Commit(batch => + { + var newKeys = new Dictionary(); + foreach (var design in designs) + { + batch.Remove(oldKeys[design.Name]); + newKeys[design.Name] = batch.Upsert(design.GetType(), design, new CultRecordKey(legacyKeys[design.Name])); + } + foreach (var product in products) + { + product.Design = new CultRecordRef(newKeys[product.Name]); + batch.Upsert(typeof(FactionProductData), product, productKeys[product.Name]); + } + }); + + Console.WriteLine($"\nApplied Cut 1 fixes and re-keyed {designs.Length} designs to their legacy Guids in Aetheria.cc"); + return 0; + } } From 54cfe9d4ba41f778595931c2d491e42ba5deec92 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 17:40:08 +0200 Subject: [PATCH 044/116] Cut 1 fix batch: delete fix-loco1 now that its records have landed Same pattern as restore-hulls itself: the one-shot command's carrying cost is zero once it has run. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 129 +------------------------------------- 1 file changed, 1 insertion(+), 128 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 46f1fad9d..46aae9575 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,9 +36,8 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); - case "fix-loco1": return FixLoco1(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], fix-loco1 [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); return 1; } } @@ -1475,130 +1474,4 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } - - // Cut 1 fix batch (docs/locomotion-cut.md, Soul's findings on the restore-hulls landing). One-shot, in the - // shape of the other *-migrate/fix commands here: dry run unless "apply", refuses a second apply (Longinus - // already carrying its legacy key is the refusal signal), deleted again once landed. - // - // 1. Djinni.Prefab was a fabricated GUID ("79024f6300000000000000000000000", 31 hex digits); the real one - // is Djinni.prefab.meta's own guid. - // 2. All five restored thrusters had ParticlesPrefab null; the legacy record's own thrust behaviour names - // "Assets/Resources/Prefabs/Thrusters/Thruster 1.prefab" for every one of them, the same prefab Large - // Drive already uses. - // 3. All five carried invented OptimalTemperature/PlateauWidth (300/100). docs/stats-power-cut1-migration.md's - // precedent for a 200-400 thruster is Large Drive's own 251/24; applied here to all five instead. - // 4. ThermalResilience (legacy 2021-04-14 record, decoded directly): 10 for Medium and Small Drive, 15 for - // Victoire, Talaria and RevvITup 2.0. Victoire's Durability is legacy 30, not the invented 100. Victoire - // and Medium Drive's legacy descriptions are restored (Talaria/RevvITup 2.0's legacy descriptions are the - // literal placeholder "[TODO]" and are not). - // 5. Djinni.Schematic was null; the live catalog already carries the asset, unused, at - // djinni_schematic_transparent.png's own guid. - // 6. Djinni's OptimalTemperature/PlateauWidth (250/24) round the same "apply LonginusX's own optimal-fraction - // and plateau/range ratio to Djinni's band" derivation restore-hulls's own header comment named, but round - // to 250 instead of 251; corrected to match the derivation exactly (LonginusX: (275.15-173.15)/400 = - // .255 of band applied to Djinni's 200-unit band = 251, not 250). - // 7. All seven restored records (Longinus, Djinni, five thrusters) landed under freshly minted keys. The - // Cut 9 import precedent (tools/AetherDb/Import.cs, git history) kept every legacy Guid as the record's - // own key. Re-keyed here to each record's own legacy Guid, decoded directly from - // GameData/Legacy/AetherDB.2021-04-14.msgpack, and each record's own FactionProductData.Design ref is - // repointed at the new key. - private static int FixLoco1(bool apply) - { - var db = AetherDb.Open(catalogWritable: apply); - var cache = db.Cache; - - // Each record's own legacy key (decoded from the 2021-04-14 msgpack record directly; the .NET Guid - // byte layout Import.cs's ReadGuid used, confirmed by this fix's own header note 7). - var legacyKeys = new Dictionary - { - ["Longinus"] = "c4b20032-8cd3-4206-807f-97b296797425", - ["Djinni"] = "bb5d9f73-612f-4060-8fb4-f72d6b93b616", - ["Medium Drive"] = "ef0f782e-9fa8-4059-bb8c-f16e1d3de100", - ["Small Drive"] = "9013c312-f37f-42a8-9efc-3d699759923b", - ["Victoire"] = "88e81a2b-45fd-49de-830f-c896adae8d5b", - ["Talaria"] = "d706ff66-c98c-4e60-a8e5-5dceb8dc3742", - ["RevvITup 2.0"] = "ebfb35b5-3905-4510-a8cf-c09528f27172", - }; - - var longinus = cache.GetByName("Longinus"); - var djinni = cache.GetByName("Djinni"); - var mediumDrive = cache.GetByName("Medium Drive"); - var smallDrive = cache.GetByName("Small Drive"); - var victoire = cache.GetByName("Victoire"); - var talaria = cache.GetByName("Talaria"); - var revvItUp = cache.GetByName("RevvITup 2.0"); - if (new EquippableItemData[] { longinus, djinni, mediumDrive, smallDrive, victoire, talaria, revvItUp }.Any(d => d == null)) - { - Console.WriteLine("Cut 1's restored records are not all present; nothing to fix."); - return 1; - } - - if (cache.RefOf(longinus).Key.Value == legacyKeys["Longinus"]) - { - Console.WriteLine("Cut 1 fix batch already applied (Longinus already carries its legacy key)."); - return 1; - } - - djinni.Prefab = "79024f635e46f5546b670b58b07fa037"; // Djinni.prefab.meta - djinni.Schematic = "66f3acdd44f2d1d4ba287c396b60a494"; // djinni_schematic_transparent.png - djinni.OptimalTemperature = 251; // LonginusX's own optimal-fraction (.255) applied to Djinni's 200-unit band - djinni.PlateauWidth = 24; - - var fiveThrusters = new[] { mediumDrive, smallDrive, victoire, talaria, revvItUp }; - foreach (var design in fiveThrusters) - { - var thruster = (ThrusterData) design.Behaviors.Single(b => b is ThrusterData); - thruster.ParticlesPrefab = "016516404b2bc1649bdf789bfc96d1cb"; // Thruster 1.prefab, same as Large Drive - design.OptimalTemperature = 251; // Large Drive's own 200-400 precedent (docs/stats-power-cut1-migration.md) - design.PlateauWidth = 24; - } - mediumDrive.ThermalResilience = 10; - smallDrive.ThermalResilience = 10; - victoire.ThermalResilience = 15; - talaria.ThermalResilience = 15; - revvItUp.ThermalResilience = 15; - victoire.Durability = 30; - victoire.Description = "The most responsive thruster on the market, for the most irresponsible of racing pilots."; - mediumDrive.Description = "Keeps you going and going and going..."; - - var designs = new EquippableItemData[] { longinus, djinni, mediumDrive, smallDrive, victoire, talaria, revvItUp }; - - Console.WriteLine("=== Cut 1 fix batch ==="); - Console.WriteLine("Djinni: Prefab, Schematic, OptimalTemperature 250->251"); - Console.WriteLine("Medium Drive, Small Drive, Victoire, Talaria, RevvITup 2.0: ParticlesPrefab, OptimalTemperature/PlateauWidth 300/100 -> 251/24"); - Console.WriteLine("ThermalResilience: Medium Drive/Small Drive -> 10, Victoire/Talaria/RevvITup 2.0 -> 15"); - Console.WriteLine("Victoire: Durability -> 30, description restored"); - Console.WriteLine("Medium Drive: description restored"); - Console.WriteLine("Re-keying to legacy Guids: " + string.Join(", ", designs.Select(d => $"{d.Name} -> {legacyKeys[d.Name]}"))); - - foreach (var design in designs) CultRecordRefs.Validate(design); - - if (!apply) - { - Console.WriteLine("\nDry run. Pass \"apply\" to land these fixes in Aetheria.cc."); - return 0; - } - - var oldKeys = designs.ToDictionary(d => d.Name, d => cache.RefOf(d).Key); - var products = cache.GetAll().Where(p => legacyKeys.ContainsKey(p.Name)).ToArray(); - var productKeys = products.ToDictionary(p => p.Name, p => cache.RefOf(p).Key); - - db.Cache.Commit(batch => - { - var newKeys = new Dictionary(); - foreach (var design in designs) - { - batch.Remove(oldKeys[design.Name]); - newKeys[design.Name] = batch.Upsert(design.GetType(), design, new CultRecordKey(legacyKeys[design.Name])); - } - foreach (var product in products) - { - product.Design = new CultRecordRef(newKeys[product.Name]); - batch.Upsert(typeof(FactionProductData), product, productKeys[product.Name]); - } - }); - - Console.WriteLine($"\nApplied Cut 1 fixes and re-keyed {designs.Length} designs to their legacy Guids in Aetheria.cc"); - return 0; - } } From 3a0b5892c93d9754ca743f37a7d6f6f2cf005ba4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 17:40:31 +0200 Subject: [PATCH 045/116] Tutorial station: ZoneGenerator.GenerateZone always seats the entrance zone (operator ruling) Operator, 2026-09-25 (docs/locomotion-cut.md): the tutorial's entrance zone always gets a generated faction station with a docking bay, deterministically. Structurally, three defects made that false on every seed: Galaxy.cs halves influence and picks the lowest-influence unowned zone for Entrance, so stationCount rolled 0; a Lagrange orbit is only available when a planet has an orbiting sibling, which a boring or empty system never has; and a null station loadout silently skipped the slot. GenerateZone now identifies the entrance zone by reference against the galaxy parameter it already takes (isTutorial && galaxyZone == galaxy.Entrance), never by name: - stationCount is floored to 1 for the entrance zone, after the roll that produced it. - When no Lagrange orbit exists, a fallback orbit fixed at half the zone radius from centre is used instead of leaving selectedStationOrbits empty. CreateLagrangeOrbit now carries FixedPosition through so a Parent-less base orbit (the fallback) still positions correctly. - A null station loadout throws for the entrance zone instead of continuing past it. - The enemy count is based on the pre-override station count, so forcing the entrance's station doesn't also hand it a bonus enemy. The story-station path (Locations/LocationStory) is not used: it isn't saved, and EntitySerializer.cs:77 would break Continue, per the map's own ruling. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 34 ++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c36d5e9f6..d09e52b6b 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -207,6 +207,15 @@ public static class ZoneGenerator var storyStations = galaxyZone.Locations.Where(story => story.Type == LocationType.Station).ToArray(); var stationCount = (int)(random.NextFloat() * (factionPresence + 1)) + storyStations.Length; + // Enemy count below is based on this pre-override value, so the tutorial entrance doesn't gain an enemy + // just because its station got forced. + var baseStationCount = stationCount; + // Operator ruling, 2026-09-25 (docs/locomotion-cut.md, "Tutorial station"): the entrance zone always + // gets a generated faction station with a docking bay, deterministically. Galaxy.cs halves influence and + // picks the lowest-influence unowned zone for Entrance, which otherwise rolls stationCount 0 on every + // seed. galaxy.Entrance is reference-identity, not a name comparison. + var isTutorialEntrance = isTutorial && galaxyZone == galaxy.Entrance; + if (isTutorialEntrance && stationCount < 1) stationCount = 1; var potentialLagrangePoints = planets .Where(p => p.Parent != null && p.Parent.Children .TrueForAll(c => !(c != p && abs(c.Distance - p.Distance) < .1f))) // Filter Rosettes @@ -218,6 +227,16 @@ public static class ZoneGenerator .Take(stationCount) .Select(p=>orbitMap[p]) .ToArray(); + // No planet has a sibling to share a Lagrange point with (a boring single-body system, or none at all), + // so there is nowhere the ordinary path would put a station. The entrance zone still needs one: give it + // a standalone orbit fixed at a safe distance from the zone centre rather than silently leaving it with + // no station. + if (isTutorialEntrance && selectedStationOrbits.Length == 0) + { + var fallbackOrbit = new OrbitData { FixedPosition = random.NextFloat2Direction() * pack.Radius * .5f }; + cache.Upsert(fallbackOrbit); + selectedStationOrbits = new[] { fallbackOrbit }; + } var loadoutGenerators = new Dictionary(); @@ -234,7 +253,10 @@ public static class ZoneGenerator { Parent = baseOrbit.Parent, Distance = baseOrbit.Distance, - Phase = baseOrbit.Phase + PI / 3 * sign(random.NextFloat() - .5f) + Phase = baseOrbit.Phase + PI / 3 * sign(random.NextFloat() - .5f), + // Carried through for the tutorial-entrance fallback orbit below, which has no Parent and is + // positioned by FixedPosition alone; every other base orbit here has a Parent and ignores it. + FixedPosition = baseOrbit.FixedPosition, }; pack.Orbits.Add(cache.Upsert(lagrangeOrbit)); return lagrangeOrbit; @@ -294,7 +316,13 @@ public static class ZoneGenerator var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout(); - if (station == null) continue; + if (station == null) + { + if (isTutorialEntrance) + throw new InvalidOperationException( + $"The tutorial entrance zone must always get a station, but {nearestFaction?.Name} has no station loadout to generate."); + continue; + } station.Orbit = cache.RefOf(lagrangeOrbit); station.SecurityLevel = security; station.SecurityRadius = pack.Radius; @@ -303,7 +331,7 @@ public static class ZoneGenerator PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2); } - var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + stationCount; + var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount; for (int i = 0; i < enemyCount; i++) { var ship = GetLoadoutGenerator(nearestFaction).GenerateShipLoadout(); From aafc90acedd552a7113b4d297c37bd099416f955 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 18:01:17 +0200 Subject: [PATCH 046/116] Cut 1 fix batch: commit Soul's behaviour tests (asset refs, entrance station, speed cap, loadout fill) Four tests against the real shipped catalog and the tutorial construction path, each pinning one of this campaign's rules: - EveryAssetReferenceResolvesToAMetaGuid: every hull's Prefab, every ThrusterData.ParticlesPrefab, and every non-empty Schematic names a GUID an Assets/**/*.meta actually carries. Adapted from Soul's own probe (AssetGuidsResolve); it is what caught Djinni's fabricated Prefab GUID and the five restored thrusters' null ParticlesPrefab. - EntranceZoneAlwaysGetsADockedStationAcrossSeeds: replaces TutorialZoneGenerationCanProduceADockedStation, which never used the tutorial construction path and only claimed a docking bay shows up somewhere in 25 zone-name variants. This builds the real tutorial Galaxy with Assets/Resources/Settings.asset's own values and generates the Entrance zone the way ActionGameManager.StartGame/PopulateLevel does, requiring a docked station in every run that gets that far. Galaxy zone-graph generation has its own pre-existing, seed-dependent flakiness (a disconnected zone graph throws inside the Galaxy constructor on roughly 1 seed in 15) confirmed unrelated to this cut by reproducing identically against the pre-fix-batch catalog; that failure belongs to Galaxy.cs and is skipped rather than asserted on, with a floor requiring at least 15 of 25 seeds to actually produce a Galaxy. - LonginusNeverExceedsItsRestoredTopSpeedUnderFullThrust: straight-line full-thrust run against the restored VelocityLimitData cap (100). - LoadoutGeneratorFillsEveryThrusterHardpointAcrossSeeds: LoadoutGenerator, filtered to each restored hull, fills every thruster hardpoint across ten seeds. Co-Authored-By: Claude Opus 5.5 --- .../RestoredHullsTests.cs | 247 +++++++++++++----- 1 file changed, 179 insertions(+), 68 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 18cf51de5..42be988fa 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -13,6 +13,7 @@ using GameCult.Caching.MessagePack; using Xunit; using static CultMath.math; using float3 = CultMath.float3; +using Random = CultMath.Random; // Cut 1 (docs/locomotion-cut.md, "Restore the legacy thruster ship hulls to the live catalog"). Verifies the // content restore-hulls landed, against the real shipped catalog, read-only: the hulls exist with the expected @@ -56,12 +57,9 @@ public sealed class RestoredHullsTests private static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc")); - // Same registry-scoping problem FireControlCut7Tests.OpenReadOnlyRealCatalog solves for a catalog-only open: - // AetheriaStores.Open's `new CultCache()` uses the shared, AppDomain-scanning registry, which also picks up - // this test assembly's own [CultDocument] fixture types and then demands a record for each -- so this test, - // which also needs a writable scratch run store (RunSave.Commit's SavedGame/SavedZone), builds the same - // catalog+run composition AetheriaStores.Open does, but against a registry scoped to the shipped assembly. - private static CultCache OpenRealCatalogWithScratchRun(string catalogPath, string runPath) + // ZoneGenerator.GenerateZone writes OrbitData/BodyData (run-store types, AetheriaStores.RunTypes), so a + // catalog-only cache has no home for them. A scratch run file, deleted after the seed that used it. + private static CultCache OpenReadOnlyRealCatalogWithScratchRun(string catalogPath, string runPath) { var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() .Where(t => t is { IsAbstract: false, IsInterface: false }) @@ -72,6 +70,36 @@ public sealed class RestoredHullsTests return cache; } + // Headless asset-reference rule: every hull's Prefab, every ThrusterData.ParticlesPrefab, and every + // non-empty Schematic names a GUID that actually exists under Assets/ (a .meta file carries it). Unity's + // EngineAssetCheck enforces this too, but only in-editor; this is the same check run headless against the + // shipped catalog, so a fabricated or dropped GUID fails a build that never opens Unity. Soul's own probe + // (commit message, this cut's fix batch) found Djinni.Prefab fabricated and all five restored thrusters' + // ParticlesPrefab null; this test is that probe, committed so it can be rerun and mutated. + [Fact] + public void EveryAssetReferenceResolvesToAMetaGuid() + { + var assetGuids = new HashSet(Directory.EnumerateFiles(Path.Combine(FindRepoRoot(), "Assets"), "*.meta", SearchOption.AllDirectories) + .Select(f => File.ReadLines(f).FirstOrDefault(l => l.StartsWith("guid: "))?.Substring("guid: ".Length).Trim()) + .Where(g => g != null)); + + using var cache = OpenCatalog(); + var missing = new List(); + foreach (var hull in cache.GetAll()) + if (!assetGuids.Contains(hull.Prefab ?? "")) + missing.Add($"{hull.Name}.Prefab = {hull.Prefab ?? "null"}"); + foreach (var design in cache.GetAll()) + { + foreach (var thruster in design.Behaviors.OfType()) + if (!assetGuids.Contains(thruster.ParticlesPrefab ?? "")) + missing.Add($"{design.Name}.ParticlesPrefab = {thruster.ParticlesPrefab ?? "null"}"); + if (!string.IsNullOrEmpty(design.Schematic) && !assetGuids.Contains(design.Schematic)) + missing.Add($"{design.Name}.Schematic = {design.Schematic}"); + } + + Assert.True(missing.Count == 0, "Dangling asset references:\n" + string.Join("\n", missing)); + } + [Theory] [InlineData("Longinus", 4)] [InlineData("Djinni", 8)] @@ -169,83 +197,82 @@ public sealed class RestoredHullsTests ship.MovementDirection = float2(0, 1); // full forward, same axis ActionGameManager.Input.Player.Move drives ship.LookDirection = float3(1, 0, 0); // off-axis target heading, so the mixer's yaw thrusters have work to do for (var i = 0; i < 10; i++) zone.Update(1f / 60f); - System.Console.WriteLine($"DEBUG end velocity {ship.Velocity} direction {ship.Direction}"); Assert.NotEqual(startVelocity, ship.Velocity); Assert.NotEqual(startDirection, ship.Direction); } - // Operator addition, 2026-09-25: "What we're testing is going to be the tutorial level... Let's drop a - // station in there." ZoneGenerator's STORY station path (galaxyZone.Locations, populated by StoryProcessor) - // is dead in the live code today -- Galaxy's tutorial constructor has that call commented out - // (Assets/Scripts/ServerShared/Galaxy.cs:242) -- so every zone's stations come from the BACKGROUND path - // (ZoneGenerator.cs:290-304): a random count near the owning faction, placed at whatever Lagrange points - // that zone's own planet generation produced. That placement is genuinely probabilistic (stationCount can - // roll 0, and a zone with no sub-zones has nowhere to put one), so this drives real generation across many - // zone names/seeds and asserts at least one produces a station with a docking bay -- proof this repo's own - // ZoneGenerator can still place a station on the restored catalog, not a claim that every zone gets one. - // - // Builds the same minimal SavedGame/Galaxy round trip the shipped AetherDb `loadout exclude-sellers` command - // and FireControlCut6cTests use, scoped to Aeronautics Unlimited (Zenith's own seller, docs probe) as the - // zone's sole owning/nearest faction so factionPresence is high and stations are not competing for the RNG - // draw with distant, uninvolved factions. + // Operator ruling, 2026-09-25 (docs/locomotion-cut.md, "Tutorial station"): "What we're testing is going to + // be the tutorial level, right? Let's drop a station in there." The tutorial's entrance zone always gets a + // generated faction station with a docking bay, deterministically -- not merely likely. This REPLACES + // TutorialZoneGenerationCanProduceADockedStation, which never used the tutorial construction path at all: it + // built an ad hoc single-zone SavedGame/Galaxy and asserted a docking bay showed up in at least one of 25 + // zone-name variants, which is a claim about the BACKGROUND station path in general, not about the entrance + // zone Galaxy.cs actually picks (lowest-influence, unowned -- the one case ZoneGenerator.cs's own stationCount + // roll produces 0 on essentially every seed, docs' own probe: 30 of 30). This test builds the real tutorial + // Galaxy exactly as Assets/Resources/Settings.asset configures it and ActionGameManager.StartGame -> + // PopulateLevel(CurrentGalaxy.Entrance) generates it, across seeds, and requires a docked station in every + // run. It must fail without ZoneGenerator's tutorial-entrance override. [Fact] - public void TutorialZoneGenerationCanProduceADockedStation() + public void EntranceZoneAlwaysGetsADockedStationAcrossSeeds() { - var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); var zoneSettings = new ZoneGenerationSettings { - SunMass = 5000, - PlanetMass = 100, - SatellitePasses = 2, - SatelliteCreationProbability = 0, - BinaryCreationProbability = 0, - RosetteProbability = 0, - ZoneRadius = new ExponentialLerp { Minimum = 1000, Maximum = 1000, Exponent = 1 }, - ZoneMass = new ExponentialLerp { Minimum = 1000, Maximum = 1000, Exponent = 1 }, - SubZoneCount = new ExponentialLerp { Minimum = 4, Maximum = 4, Exponent = 1 }, - ZoneBoundaryRadius = 4, - BeltProbability = 0, - PlanetSafetyRadius = new ExponentialCurve { Multiplier = 1, Exponent = 0 }, + PlanetSafetyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 2.5f, Constant = 0 }, + MassFloor = 1, SunMass = 5000, GasGiantMass = 1000, PlanetMass = 100, + SatellitePasses = 5, SatelliteCreationMassFloor = 100, SatelliteCreationProbability = .25f, + BinaryCreationProbability = .1f, RosetteProbability = .1f, + ZoneRadius = new ExponentialLerp { Exponent = 1.5f, Minimum = 1000, Maximum = 10000 }, + ZoneMass = new ExponentialLerp { Exponent = 1.5f, Minimum = 10000, Maximum = 500000 }, + SubZoneCount = new ExponentialLerp { Exponent = 1.5f, Minimum = 0, Maximum = 8 }, + ZoneBoundaryRadius = .9f, BeltProbability = .25f, BeltMassCeiling = 500, + AsteroidBeltWidth = new ExponentialCurve { Exponent = .666f, Multiplier = 5, Constant = 50 }, + AsteroidCount = new ExponentialCurve { Exponent = .5f, Multiplier = 1, Constant = 11 }, + AsteroidRotationSpeed = new ExponentialLerp { Exponent = 2, Minimum = .1f, Maximum = .5f }, + SunColorSaturation = .85f, SunSecondaryColorDistance = .33f, SunLightSaturation = .5f, SunFogTintSaturation = .55f, + GasGiantBandCount = new ExponentialLerp { Exponent = 2, Minimum = 5, Maximum = 8 }, + GasGiantBandColorSeparation = .25f, GasGiantBandAltColorChance = .25f, + GasGiantBandSaturation = new ExponentialLerp { Exponent = .5f, Minimum = 0, Maximum = .8f }, + GasGiantBandBrightness = new ExponentialLerp { Exponent = .25f, Minimum = .25f, Maximum = 1 }, + NameData = new string[0], }; - - var found = false; - for (var seed = 0; seed < 25 && !found; seed++) + var tutorialSettings = new TutorialGenerationSettings { - var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-restoredhulls-tutorial-{Guid.NewGuid():N}.cc"); + ProtagonistFaction = "Miss", AntagonistFaction = "Zhe", BufferFaction = "Luc", + NeutralFactions = new[] { "Aero", "Finch" }, QuestFaction = "Adras", LinkDensity = .5f, ZoneCount = 64, + }; + var background = new SectorBackgroundSettings + { + NoiseAmplitude = 1, NoiseOffset = .3f, NoiseGain = .7f, NoiseLacunarity = 2, NoiseFrequency = .1f, + NoisePosition = 536.5106f, CloudExponent = 10, CloudAmplitude = .01f, + }; + var names = new NameGeneratorSettings { NameGeneratorMinLength = 5, NameGeneratorMaxLength = 10, NameGeneratorOrder = 3 }; + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + + // Galaxy zone-graph generation has a pre-existing, seed-dependent flakiness of its own (a disconnected + // zone graph throws inside the Galaxy constructor on roughly 1 seed in 15, confirmed unrelated to this + // cut: it reproduces identically against the pre-fix-batch catalog). That failure belongs to Galaxy.cs, + // not to ZoneGenerator's entrance-station override this test is pinning, so it is skipped rather than + // asserted on; every seed whose Galaxy actually builds must still get a docked entrance station. + var succeeded = 0; + for (uint seed = 1; seed <= 25; seed++) + { + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-station-{Guid.NewGuid():N}.cc"); try { - using var cache = OpenRealCatalogWithScratchRun(gameData, scratchRun); - var au = cache.GetAll().Single(f => f.ShortName == "AU"); - var auRef = cache.RefOf(au); - var game = new SavedGame - { - Background = new SectorBackgroundSettings - { - NoiseAmplitude = 1, NoiseGain = .5f, NoiseLacunarity = 2, NoiseFrequency = .1f, CloudExponent = 10, CloudAmplitude = 1, - }, - Factions = new[] { auRef }, - Relationships = new[] { FactionRelationship.Neutral }, - HomeZones = new Dictionary { { 0, 0 } }, - BossZones = new Dictionary(), - DiscoveredZones = new[] { 0 }, - ActionBarBindings = new SavedActionBarBinding[0], - Entrance = 0, - Exit = -1, - }; - var zones = new[] - { - new SavedZone { Name = $"Test Zone {seed}", Position = float2(seed, 0), AdjacentZones = Array.Empty(), Factions = new[] { 0 }, Owner = 0, Contents = null }, - }; - RunSave.Commit(cache, game, zones, new ProvenanceLedger()); - var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + // Galaxy mutates Faction.InfluenceDistance in place; a fresh catalog open per seed keeps runs independent. + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + Galaxy galaxy; + try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } + catch (Exception) { continue; } + succeeded++; var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); - var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Zones[0], isTutorial: true); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); - if (pack.Entities.OfType().Any(e => - (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0)) - found = true; + var docked = pack.Entities.OfType() + .Any(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + Assert.True(docked, $"seed {seed}: the tutorial entrance zone generated no station with a docking bay."); } finally { @@ -253,6 +280,90 @@ public sealed class RestoredHullsTests } } - Assert.True(found, "No station with a docking bay generated in 25 zone attempts against the restored catalog."); + Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result."); + } + + // Speed cap (docs/locomotion-cut.md Cut 1 verification): Longinus's restored VelocityLimitData caps it at + // 100 (legacy TopSpeed 100..100). Runs it under straight-line full forward thrust -- LookDirection held on + // the ship's own heading, so the mixer never spends torque turning and thrust stays aligned with velocity -- + // long enough to approach its drag/thrust balance well past 100, and asserts the observed speed never + // exceeds the cap at any sampled tick. Must fail when VelocityLimitData is removed (docs' own probe: Longinus + // reaches 122 without it). + [Fact] + public void LonginusNeverExceedsItsRestoredTopSpeedUnderFullThrust() + { + using var cache = OpenCatalog(); + var hull = cache.GetByName("Longinus"); + var topSpeed = ((VelocityLimitData) hull.Behaviors.Single(b => b is VelocityLimitData)).TopSpeed.Max; + var thrusterDesigns = cache.GetAll().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray(); + + var settings = Settings(); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; + var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); + + var lot = 2; + foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) + { + var design = thrusterDesigns.First(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == hardpoint.Shape.Coordinates.Length); + var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++ }; + Assert.True(ship.TryEquip(gearItem, hardpoint.Position)); + } + var reactorHardpoint = hull.Hardpoints.First(h => h.Type == HardpointType.Reactor); + var reactorDesign = cache.GetAll().First(g => g.Hardpoint == HardpointType.Reactor && + g.Shape.FitsWithin(reactorHardpoint.Shape, reactorHardpoint.Rotation, out _) && g.Shape.Coordinates.Length == reactorHardpoint.Shape.Coordinates.Length); + Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf(reactorDesign), Durability = reactorDesign.Durability, Lot = lot++ }, reactorHardpoint.Position)); + + zone.Entities.Add(ship); + ship.Position = float3.zero; + ship.Activate(); + zone.Update(0f); + + ship.MovementDirection = float2(0, 1); // full forward + ship.LookDirection = float3(ship.Direction.x, 0, ship.Direction.y); // straight line: hold the ship's own heading + + for (var i = 0; i < 60 * 20; i++) + { + zone.Update(1f / 60f); + var speed = length(ship.Velocity); + // The mixer applies thrust then VelocityLimit clamps at the START of the next tick (Ship.cs's + // per-behavior execution order), so one tick's own acceleration can carry speed briefly past the + // cap before the following tick reins it in. The tolerance below is several times the base flight + // table's own measured Longinus forward acceleration (172.38 m/s^2) at dt=1/60 (~2.9 per tick). + Assert.True(speed <= topSpeed + 10f, $"tick {i}: speed {speed} exceeded the restored top speed {topSpeed} by more than one tick's acceleration."); + } + } + + // Loadout generation (docs/locomotion-cut.md Cut 1 verification): LoadoutGenerator, filtered to each + // restored hull, must fill every one of its thruster hardpoints -- the check that the restored designs and + // their FactionProductData/roles are authored correctly, not just present. + [Theory] + [InlineData("Longinus")] + [InlineData("Djinni")] + public void LoadoutGeneratorFillsEveryThrusterHardpointAcrossSeeds(string name) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + var thrusterHardpoints = hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster).ToArray(); + var settings = Settings(); + + for (uint seed = 1; seed <= 10; seed++) + { + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var random = new Random(seed); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate == hull); + Assert.True(pack != null, $"{name}: seed {seed} produced no loadout."); + + foreach (var hardpoint in thrusterHardpoints) + { + var filled = pack.Equipment.Any(e => e.position.Equals(hardpoint.Position) && + (cache.Get(e.item.Data) as GearData)?.Hardpoint == HardpointType.Thruster); + Assert.True(filled, $"{name}: seed {seed}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} got no thruster."); + } + } } } From 0dd16713b642d68fccd913adf15d6bdd02481795 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 18:01:38 +0200 Subject: [PATCH 047/116] Cut 12.3 map: record the cycle rule as Self's default Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 5aa4c435c..e17252ad3 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2568,6 +2568,8 @@ authority, and the second reachability derivation it needed is the same failure lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of the lanes that reach it, a pool of one lane included. The survey and the sequential path die. +**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. + ### Cut 12.4. The detonation primitive - **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: From 64d17a290da1c16fd8d4ae56491f379992c8f9f5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 18:33:09 +0200 Subject: [PATCH 048/116] Cut 12.3 fix batch (F4): one writer for lane completion, delete dead reachability offset ApplyPooled.Resolve no longer calls FinishLane directly -- AdvanceLane is now the single place that checks pointer >= walked and finishes a lane, so lane completion has one writer instead of two. StillReachableByAnotherLane's own `blocked ? pointer+1 : pointer` collapses to plain `pointer[li2]`: the alreadyContributed check just above it already excludes a lane that is blocked on the very item being asked about, so `pointer[li2]` is never that item's own cell by the time the +1 would have mattered. Soul's findings (docs/fire-control-cut.md, Cut 12.3): F4. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index b81a7a2d3..eebbda665 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -758,8 +758,10 @@ public static class FireControl if (contributors[k].lane == li2) { alreadyContributed = true; break; } if (alreadyContributed) continue; - var start = blocked[li2] ? pointer[li2] + 1 : pointer[li2]; - for (var i = start; i < walked[li2]; i++) + // No +1 needed when li2 is blocked at this same item's cell: `alreadyContributed` above already + // caught that case (a blocked lane is always a contributor to the item it is blocked on), so + // `pointer[li2]` itself is never this item's own cell by the time this line runs. + for (var i = pointer[li2]; i < walked[li2]; i++) if (target.GearOccupancy[buffers[li2][i].Cell.x, buffers[li2][i].Cell.y] == item) return true; } return false; @@ -780,7 +782,9 @@ public static class FireControl rem[lane] = amounts[k]; blocked[lane] = false; pointer[lane]++; - if (pointer[lane] >= walked[lane]) FinishLane(lane); + // One writer for lane completion: AdvanceLane, not here. Unblocking and advancing the + // pointer is enough -- the next call to AdvanceLane(lane), later this pass or the next while + // iteration, is the one place that checks `pointer >= walked` and calls FinishLane. } } finally From c07b4fee65702d5ff44fa736828d8725c386a063 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 18:33:23 +0200 Subject: [PATCH 049/116] Cut 12.3 fix batch (F4, F6): delete unreachable guards, fix stale ArmorAbsorb comment Entity.ItemAbsorb's `item == null` guard is unreachable: both callers (Absorb, and FireControl.ApplyPooled's Resolve) already guarantee a non-null item before calling in. Its `total > 0f ? ... : 0f` divide guard is unreachable too -- the function already returned above whenever total <= 0.1f, so the divide never sees zero. Both deleted; the surrounding comment now says why instead of describing a guard that no longer exists. ArmorAbsorb's own comment was stale from before the "one code path" ruling: it described the item phase as deferred only for a cell shared with another lane, when every occupied cell now defers to ItemAbsorb's pool, a lane that turns out to be the sole contributor included (a pool of one, not a separate solo path). Soul's findings (docs/fire-control-cut.md, Cut 12.3): F4, F6. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 9b49d9666..f7e5ac14c 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -332,8 +332,10 @@ public abstract class Entity // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of // what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so // Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per - // cell -- always local to one lane, since armour never spans cells -- while deferring the item phase for a - // cell whose item is shared with another lane in the same shot. ArmorDamage fires only for incoming > 0. + // cell -- always local to one lane, since armour never spans cells -- while deferring the item phase to + // ItemAbsorb's own pool for EVERY occupied cell, not only ones shared with another lane: a lane that turns + // out to be an item's only contributor still pools through a list of one (ItemAbsorb's own degenerate + // case), never a separate solo path. ArmorDamage fires only for incoming > 0. public float ArmorAbsorb(int2 cell, float damage) { var d = damage; @@ -357,15 +359,15 @@ public abstract class Entity // The existing .1f threshold is decided once, on the POOLED total, never per contribution (a pooled path // that gated each contribution separately would let two shares under .1f each slip past an item that a // single .08f solo hit would already have stopped at). Absorbed durability is split back across `incoming` - // in place, pro-rata to what each lane brought (a guarded divide: total is already >.1f whenever this - // divides, but 0 is still handled explicitly rather than assumed). ItemDamage fires once per contributing - // lane, reporting its own INCOMING share -- not the post-clamp amount, the same convention ArmorAbsorb - // already uses -- and only when that lane's own share is itself > 0, so a zero-deposit contributor (a - // lane whose armour ate its whole share before reaching this item) never fires a phantom event. + // in place, pro-rata to what each lane brought -- the divide below never sees total <= 0.1f, so it never + // sees zero (the guard above already returned). ItemDamage fires once per contributing lane, reporting its + // own INCOMING share -- not the post-clamp amount, the same convention ArmorAbsorb already uses -- and only + // when that lane's own share is itself > 0, so a zero-deposit contributor (a lane whose armour ate its + // whole share before reaching this item) never fires a phantom event. `item` is never null here: both + // callers (`Absorb`, below, and `FireControl.ApplyPooled`'s `Resolve`) already guarantee it -- a pool is + // only ever opened under a non-null `GearOccupancy` cell. public void ItemAbsorb(EquippedItem item, Span incoming) { - if (item == null) return; - var total = 0f; for (var i = 0; i < incoming.Length; i++) total += incoming[i]; if (total <= 0.1f) return; @@ -373,7 +375,7 @@ public abstract class Entity var before = item.EquippableItem.Durability; var absorbed = min(total, before); item.EquippableItem.Durability = max(before - absorbed, 0f); - var fraction = total > 0f ? absorbed / total : 0f; + var fraction = absorbed / total; for (var i = 0; i < incoming.Length; i++) { if (incoming[i] > 0f) ItemDamage.OnNext((item, incoming[i])); From cccf3a2ac43aaaa248484646f2441e6d767240f8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 18:33:50 +0200 Subject: [PATCH 050/116] Cut 12.3 fix batch (F5, F1, F2): model sweep never meets a cycle; commit P3-P5 FixedPointItemResolution now throws "the model never meets a cycle" if a pool is still open after its fixed point settles, instead of silently force-settling in the reverse of first-encountered order. The sweep already gates its own draws (LanesReaching) so a genuine cross-item cycle is never actually presented to the model; a thrown exception here means the gating missed a real case, not a numbers question the model should answer with its own competing tie-break. The stale "opposite tie-break" comment describing the deleted behaviour is gone with it. Three of Soul's five probes (soul125-FireControlCut123Tests.probe.cs) are committed here, ported from reflection onto the public Fire/Commit/Apply path this file's own tests already use -- FireCentre/FireUntilHit retries until the committed hit lands on the expected column, the same idiom ZeroDepositDoesNotStall and PooledPathHonoursTheItemThresholdLikeTheSoloPath already use, never a direct call into the private Apply: - ZeroDepositContributorFiresNoItemDamageEvent (P3): a zero-deposit contributor must not fire a phantom ItemDamage event. Kills Entity.cs's `incoming[i] > 0f` boundary flipped to `>=`, and a swap that reports the post-split share instead of the incoming one (F2). - ClippedLaneDoesNotHoldThePool (P4): an item beyond every lane's own penetration clip must never be waited on. - OwnRepeatVisitDoesNotHoldThePool (P5): a lane's own later visit to an item must not hold that item's pool open -- only other lanes are future reachers. Kills FireControl.cs's `alreadyContributed` guard being deleted (F1). The other two probes (Soul5_AngledMirrorAndConservationWithSharedItems, Soul5_AngledMirrorCycleShapes) are NOT committed. They drive Apply directly through reflection with a hand-built PendingShot.Outcome so they can sweep 1400 exact, mirrored (bearing, lateral) pairs per test and compare against an independent model -- Commit's own bearing is reachable through the public path (shooter/target positioning), but its lateral is a random draw from the per-shot RNG, not a value either test can set or predict without either control of that RNG's seed and Silhouette's exact sigma, or a decoy Apply entry point. Reaching them without reflection needs one of: (a) `[InternalsVisibleTo]` for Aetheria.Shared.Tests on FireControl.Apply, or (b) a public/internal test seam that takes a pre-built ShotOutcome (bearing, lateral) and runs Apply's own walk against it. Neither is authorized by this fix batch's map; this is reported rather than silently reflected. The Soul5_Alloc benchmark (also reflection-based, and not one of P1-P5) is likewise left uncommitted. Mutation verification (docs/fire-control-cut.md, Cut 12.3, one-apply-path): F1 and both F2 mutants confirmed killed by the new tests; the earlier fix batch's own forced-sequential, resolve-one-pool-only, and cycle-throws mutants (Soul's M3/M4/M6 family) still die against CycleTieBreakIsMirrorSymmetric and friends. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut123Tests.cs | 131 ++++++++++++++++-- 1 file changed, 120 insertions(+), 11 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 5fbd879ae..90f5891c1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -1417,11 +1417,10 @@ public sealed class FireControlCut123Tests : IDisposable // from a table built once up front): a precomputed-once reach table was tried first and produced a real // deadlock on a non-convex item crossed twice by the SAME lane, because a set keyed by item identity cannot // tell a two-lane first pool apart from the one-lane pool the item's second visit degenerates into. A - // genuine cross-item cycle (every remaining lane blocked, nothing left to converge) is broken by - // force-settling whatever is still open, in the REVERSE of first-encountered order -- the opposite - // tie-break direction from production's own cycle rule, so the two could only agree on a genuine cycle's - // numbers by actually computing the same conservation-respecting split, not by sharing a tie-break - // convention. + // genuine cross-item cycle (every remaining lane blocked, nothing left to converge) is not modelled at all -- + // this sweep gates its own draws (`LanesReaching`, below) so a cycle is never actually presented to this + // fixed point. Finding one here would mean the gating missed a real case, not a numbers question for this + // function to answer with its own tie-break; see the cycle-break check at the end of this function. // // Geometry itself is NOT reimplemented a third time: which cells belong to which lane is read directly from // `FireControl.Lanes`, the one public function this whole cut already treats as the single geometric owner @@ -1554,12 +1553,15 @@ public sealed class FireControlCut123Tests : IDisposable if (ReadyToSettle(item, openPool[item])) { Settle(item); progressed = true; } } - // Cycle break: a genuine cyclic dependency between two non-convex items leaves pools open with no lane - // able to progress. Force-settle whatever remains, in the REVERSE of first-encountered order -- - // deliberately the opposite tie-break to production's own, so agreement on a genuine cycle's numbers - // can only come from both sides actually conserving and splitting correctly. - foreach (var item in Enumerable.Reverse(new List(openPool.Keys))) - if (openPool.ContainsKey(item)) Settle(item); + // Cycle break: the model never meets a cycle. This sweep gates its own draws (`LanesReaching`, below) so + // a genuine cross-item cycle -- every remaining lane blocked, nothing left to converge -- is never + // actually presented to this fixed point. A pool still open here means the gating let a real cycle + // through, which is a defect in the sweep, not a numbers question this function should silently answer + // with its own tie-break. + if (openPool.Count > 0) + throw new InvalidOperationException( + "FixedPointItemResolution: the model never meets a cycle -- a pool is still open after the fixed " + + "point settled, which means this sweep's own gating let a genuine cross-item cycle through."); return (armor, items, hull); } @@ -1851,4 +1853,111 @@ public sealed class FireControlCut123Tests : IDisposable Assert.Equal(0f, 1000 - e.Markers[0].EquippableItem.Durability, 3); // column 1's own zero deposit: nothing reaches its marker Assert.Equal(5f, 1000 - e.Markers[1].EquippableItem.Durability, 3); // column 2's own leftover (30-15-10=5) reaches its marker } + + // Soul's own claim (2026-09-25, fix batch on 166b6202): a zero-deposit contributor -- column 1's lane here, + // whose armour eats its whole share before it ever reaches the bar -- must never fire a phantom ItemDamage + // event for that zero. Same fixture as ZeroDepositDoesNotStall above (that test pins durability and marker + // leftovers; this one pins the event itself, which Entity.ItemAbsorb's `incoming[i] > 0f` guard decides). + // Kills a boundary flip of that guard to `>=` (a zero deposit would then fire) and a swap that reports the + // post-split share instead of the incoming one. + [Fact] + public void ZeroDepositContributorFiresNoItemDamageEvent() + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 1), 10f) }, markers: new[] { (new int2(1, 2), 1000f), (new int2(2, 2), 1000f) }); + var bar = e.Target.GearOccupancy[1, 1]; + void Reset() + { + ZeroArmor(e, 5, 4); + e.Target.Armor[1, 0] = 1000f; e.Target.MaxArmor[1, 0] = 1000f; e.Target.Armor[2, 1] = 15f; + bar.EquippableItem.Durability = 10f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + + // A fresh subscription per attempt (the PooledPathHonoursTheItemThresholdLikeTheSoloPath convention, + // above): a rejected attempt still fires a real shot, and its events must not contaminate the accepted + // attempt's own count. + var barEvents = new List(); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 800 && (outcome == null || !outcome.Hit || outcome.Cell.x != 2); attempt++) + { + Reset(); + barEvents.Clear(); + using var sub = e.Target.ItemDamage.Subscribe(x => { if (x.item == bar) barEvents.Add(x.damage); }); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.True(outcome != null && outcome.Hit && outcome.Cell.x == 2, "fixture: needed a hit on the centre interior column within the attempt budget"); + + Assert.Single(barEvents); // column 2's own deposit only -- column 1's zero deposit fires nothing + Assert.Equal(15f, barEvents[0], 3); // column 2's post-armour remainder (30 - 15 armour) + } + + // An item crossed by a lane whose CLIPPED walk ends before the item's own cell on that lane must not wait + // for that lane -- reach is decided by the clipped walk (what Apply's own penetration clip left), never by + // the lane's raw, unclipped geometry. A 2x1 bar at row 2; penetration 1.5 clips every lane to rows 0-1, so no + // lane can ever reach the bar at all -- if StillReachableByAnotherLane read the raw walk instead, a lane + // that (on the raw geometry) also crosses the bar's row would look like a future reacher and the other + // lane's own pool would stall waiting for it forever. + [Fact] + public void ClippedLaneDoesNotHoldThePool() + { + // 2x1 bar at (1,2)-(2,2); straight shot, lanes cols 1,2,3; penetration 1.5 -> each lane reaches rows 0,1 only. + var e = Build(TestSettings(), SolidShape(5, 5), precision: .6f, penetration: 1.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 2), 10f) }); + var bar = e.Target.GearOccupancy[1, 2]; + void Reset() + { + ZeroArmor(e, 5, 5); + bar.EquippableItem.Durability = 10f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + + var hull = 0f; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 800 && (outcome == null || !outcome.Hit || outcome.Cell.x != 2); attempt++) + { + Reset(); + hull = 0f; + using var sub = e.Target.HullDamage.Subscribe(d => hull += d); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.True(outcome != null && outcome.Hit && outcome.Cell.x == 2, "fixture: needed a hit on the centre column within the attempt budget"); + + Assert.Equal(10f, bar.EquippableItem.Durability, 3); // never reached -- clipped out before row 2 + Assert.Equal(90f, hull, 2); // the whole shot (3 lanes x 30, no armour, nothing absorbed) reaches the hull + } + + // A lane's own LATER cell of the same item never holds that item's pool open -- only OTHER lanes are future + // reachers (StillReachableByAnotherLane's own `alreadyContributed` check, F1 fix batch). Lane column 1 + // crosses a vertical 1x2 item X twice (X spans rows 1-2 in that single column), then a 2x1 bar Z it shares + // with column 2's lane at row 3. Column 2 reaches Z first and must wait for column 1; column 1's two visits + // to X are each a solo pool (nothing else ever reaches X) and must resolve at once rather than stalling on + // column 1's own second visit, so column 1 goes on to reach Z and the pool there is the genuine + // {column 1: 25, column 2: 30} -- not a cycle-rule resolve of Z with column 2 alone (which deleting the + // `alreadyContributed` guard produces instead). + [Fact] + public void OwnRepeatVisitDoesNotHoldThePool() + { + var e = Build(TestSettings(), SolidShape(5, 6), precision: .6f, penetration: 10f, damageSpread: 1f, + bars: new[] { (new int2(1, 3), 20f) }, markers: new[] { (new int2(1, 4), 1000f), (new int2(2, 4), 1000f) }, + custom: new[] { ("SoulV", CellsShape(1, 2, (0, 0), (0, 1)), new int2(1, 1), 5f) }); + var z = e.Target.GearOccupancy[1, 3]; + var x = e.Custom["SoulV"]; + void Reset() + { + ZeroArmor(e, 5, 6); + z.EquippableItem.Durability = 20f; + x.EquippableItem.Durability = 5f; + e.Markers[0].EquippableItem.Durability = 1000f; + e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + + FireCentre(e, 2, Reset, 90f); + var l1 = 1000f - e.Markers[0].EquippableItem.Durability; + var l2 = 1000f - e.Markers[1].EquippableItem.Durability; + Assert.Equal(0f, z.EquippableItem.Durability, 3); + Assert.Equal(25f * 35f / 55f, l1, 2); // column 1's own leftover after X (30 - 5, X's own 5 durability): 15.909 + Assert.Equal(30f * 35f / 55f, l2, 2); // column 2's leftover pooled with column 1's at Z (durability 20): 19.091 + } } From d402283c06a11d3e0e3ae987eafbf71ac122b9dd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 18:38:21 +0200 Subject: [PATCH 051/116] Cut 12.3 status: closed Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index e17252ad3..9e9ba9bbd 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2517,11 +2517,38 @@ import. - **Operator:** a smoke with a GT 3K or pswarm launcher into a LonginusX bow, then from a flank. The schematic display should pulse the facing edge only. -**Status (2026-09-25): closed apart from one pending operator ruling** (multi-cell items shared -between lanes, below). Landed on `codex/fire-control-12`: `70c3e0cc`..`73b15d9f` (cut and first -Stryker batch), `b82b7eab`..`be538217` (Soul pass 1 fixes and the lane-spacing ruling), -`4e6495dd`, `742354c6`, `cb71a3cf` (Soul pass 2 fixes). 286 tests; Unity batchmode clean at each -stage. +**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: +- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. +- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. +- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. +- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. +- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. +- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. + +297 tests. Unity batchmode is clean at `cccf3a2a`. + +Proportional absorption, passes 4 and 5: +- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` + was already ruled out by the operator. Soul found three more defects: + - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. + - The pooled resolve skipped the item's `.1f` threshold. + - The model sweep still passed with proportionality deleted. +- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. + `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is + Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. +- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled + bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. + Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and + stale comments. All are fixed, and each mutant dies to a committed test. + +Recorded, not fixed: +- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, + which only a private seam offers. The operator rule forbids reflection, and no test seam is added + for them. + - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. + - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). + - If 12.4 grows a seam that takes a built shot, these probes become committable there. +- `Entity.Absorb` survives only for `Splash`. 12.4's `Detonate` must go through `ItemAbsorb`, not bring `Absorb` back as a second path. What the three Soul passes found: - **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of From 27318cb39f5a79227f89d18bc9f62330051e6cb5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 18:58:58 +0200 Subject: [PATCH 052/116] Cut 12.4 map: re-anchor at d402283c, areas on the one-path absorption, Q12-9 Imagination pass: every anchor is re-probed; a disc runs armour per cell, then pools each item through ItemAbsorb; Entity.Absorb dies with Splash; the penetrator's fuse point reads the same Reach owner as the direct-hit walk; the ledger now carries actuals for 12.1-12.3; Q12-9 (the host's shield for a blast) is opened. Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 749 ++++++++++++++++++++++++++++++--------- 1 file changed, 581 insertions(+), 168 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 9e9ba9bbd..fb32dd915 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1938,7 +1938,8 @@ Second round: takes the share of the disc that lies inside it. The share of the disc that falls outside the hull's occupied cells is lost. Each covered cell absorbs independently, in the existing order (armour, then item, then hull). Nothing travels, nothing is occluded, and - no remainder is routed anywhere. + no remainder is routed anywhere. (Superseded for items by the 2026-09-25 one-path + ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) **A shot has a direction; a blast does not.** That sentence is the rule behind the split: direct hits keep the lane model of 12.2 and 12.3, and blasts are areas. Where P sits: @@ -2003,8 +2004,8 @@ Second round: embeds each type's schema descriptor, including `{"slot":29,"name":"AirburstRange", "type":"System.Nullable"}`, and the wire format is positional (`Probe5_Wire`: `[…,12.5,50,20]`, with no field names on the wire). On a content-hash - mismatch, `CultCache.ResolveSchema` (`CultCache.cs:590-670`) runs `CompareSchemaShapes` - (`:672-744`), which compares slot type, reference, cardinality, target schema, name + mismatch, `CultCache.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` + (`:672-752`), which compares slot type, reference, cardinality, target schema, name semantics and index alias, **not the member name**. So renaming slot 29 opens as `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` warning. The warnings persist until the catalog is next written with the new descriptor. @@ -2452,7 +2453,7 @@ import. exhausted, a gap, or the far side (Q12-3 = A). The remainders are summed and passed to `DamageHull` once. 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead - (12.4), never both. + (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. **Where 12.3 and 12.4 meet.** Which model a shot uses is decided by the blast radius, not by its penetration: @@ -2547,8 +2548,8 @@ Recorded, not fixed: for them. - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). - - If 12.4 grows a seam that takes a built shot, these probes become committable there. -- `Entity.Absorb` survives only for `Splash`. 12.4's `Detonate` must go through `ItemAbsorb`, not bring `Absorb` back as a second path. + - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). +- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. What the three Soul passes found: - **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of @@ -2599,160 +2600,519 @@ the lanes that reach it, a pool of one lane included. The survey and the sequent ### Cut 12.4. The detonation primitive +Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path +rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. +Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see +Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. + - **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: - - (a) the data fields and the label demotion. This is behaviour-neutral on shipped - content, because no weapon carries the label or a radius **(probe)**. - - (b) `Detonate`, with Splash deleted. -- **Deletes first:** - - The label read `isAirburst = … WeaponModifiers.HasFlag(WeaponModifiers.Airburst)` - (`FireControl.cs:281-284`), in (a). Afterwards `WeaponModifiers` has no simulation - reader. `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing (today - it returns exactly `FireControl.cs:282`). - - `FireControl.Splash` (`:488-529`) and its half-hull footprint, in (b). This absorbs - F12-1. - - `PendingShot.BurstRadius` (`:648-652`) becomes `BlastRadius`, and `BurstPosition` stays. -- **Data (smallest field set, both on `WeaponItemData`, `ItemData.cs:476-499`):** - - Slot 29, `float? AirburstRange`, is renamed `float? BlastRadius` (JSON `"blastRadius"`), - in world units, meaning the same thing it meant for airburst. Same slot, same type: - CultCache resolves it as compatible drift (see the probes). - - New slot 30: `WeaponFuse? Fuse` (JSON `"fuse"`), with `enum WeaponFuse { Contact, - Proximity, Delayed }` in `Enums.cs`. It is nullable per the 7.4 rule. Null means no - blast, which is what every shipped record reads as. - - A shot detonates iff `Fuse != null && BlastRadius > 0`. Fire freezes both - (`PendingShot.Fuse`, `BlastRadius`). A fuse without a radius (or a radius without a - fuse) is inert, and nothing reports it: Q12-6 ruled that the catalog is not policed. - - The catalog needs one write through `tools/AetherDb` or Studio to clear the drift - warnings. That write is content, with no data change, and is recorded as part of the - retune follow-up. - - Readers to update: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, - 279`, which author `WeaponModifiers.Airburst`. Those fixtures move to - `Fuse = Proximity`. Whether those fixtures keep the label is free: Q12-6 ruled that - nothing checks labels against behaviour. -- **Adds:** `FireControl.Detonate(Zone zone, float2 worldPoint, float radius, float damage, - DamageType type)`, the one owner of blast damage, plus `Entity.ToSchematicPoint` and - `ToWorldPoint` (see the frame rules). - - **Where P is:** - - Proximity: Step calls it at arrival with the frozen `BurstPosition` - (`PredictedIntercept`, `:283`). There is no roll, as today (`AirburstSplashesEvenOnA - GuaranteedMiss` stands). - - Contact: on a committed, unshielded hit, P is the entry point of the impact cell on - the committed lane. - - Delayed: on a committed, unshielded hit, P is the end of the penetration walk on that - lane (penetration exhausted or first gap). The walk only locates P; nothing is - absorbed along it (Q12-8), and that is true only because this weapon carries a blast. - A weapon with no blast radius never reaches this path and spends its damage down the - lane instead (12.3). + - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no + weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has + not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). + - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. - Both contact and delayed are converted with `target.ToWorldPoint`, so every entity in - the radius (the host included) is treated alike. A shielded contact or delayed hit is - absorbed whole, as a direct hit is, and does not detonate. `Mine.cs:99` calls - `Detonate`. - - **Area, per entity:** - 1. Candidates are the entities with `|Position.xz - P| ≤ radius + boundingRadius`, - where `boundingRadius = ½·√(W²+H²)·cellSize`. - 2. For each candidate, `Ps = ToSchematicPoint(P)` and `rCells = radius / - SchematicCellSize`. - 3. For every cell of the candidate's schematic inside the bounding box - `[Ps ± (rCells + .5)]`, compute `overlap(c)`, the exact area of the disc of radius - `rCells` centred on `Ps` intersected with that cell's unit square. - 4. An occupied cell takes `damage × overlap(c) / (π · rCells²)` through - `Absorb(c, …)`, and the remainder of that share goes to `DamageHull`. An unoccupied - cell's share is lost, and so is every part of the disc outside the schematic. - 5. Entities are independent. Two ships caught by one blast each cover their own cells, - and neither shadows the other. - - **Why exact overlap and not sampling.** The overlap is the standard circle-rectangle - intersection, evaluated per cell from the antiderivative `½(x√(r²-x²) + r²·asin(x/r))` - over the cell's clipped x-span, combined across the rectangle's edges. It is closed - form, allocation-free, and exactly conservative: the overlaps over the bounding box sum - to `π·rCells²`, so shares sum to 1 and the only loss is geometric. Supersampling would - be cheaper to write and would break that: a blast smaller than one cell, or one - straddling a cell boundary, would deliver the wrong total, and the conservation test - below could only be written with a tolerance wide enough to hide real errors. - - **Shields:** an entity with an active shield is charged the summed share of the disc - that its occupied cells cover, under the same `CanTakeHit`/`Break`/`TakeHit` rules as - today. - - If the shield absorbs, no cell of that entity takes anything. - - If not, it breaks and the cells take their shares. +#### Deletes first - Today every shield in radius pays the full damage (`:505-513`). Charging the covered - share makes the shield pay only for what reaches the ship, which is consistent with - "omnidirectional": the reserve has no facings, but it is not charged for energy that - went past the hull. - - **Cost:** `candidates × (2·rCells + 2)²` overlap evaluations, about 3 × 27² ≈ 2k for a - 25-unit blast at the default cell size, once per detonation and never per tick. No - allocation: the loop writes nothing but the per-cell absorption. - - **Accepted loss.** Interior armour no longer shadows the cells behind it, because - nothing is occluded within an area. A bulkhead protects its own cell and no other. The - ray model bought that shadowing, and the operator cut the ray model. Falloff toward the - rim is F12-7, not this cut. - - **HitProbability and Inspect** are unchanged: - - A contact or delayed blast still needs the shot to hit, so the same forecast - applies. - - A proximity blast has no roll and delivers whatever geometry gives. The forecast - still reports the direct-hit number for it. That is pre-existing, and AI gating on it - is recorded as F12-5. -- **Authority map:** - - Owner: `FireControl.Detonate` owns blast damage and the disc's coverage. - `Entity.Absorb` owns per-cell absorption, shared with 12.3. `Lane` owns traversal and - is a direct-hit function only; a blast calls it just once, for the delayed fuse's - point, on the walk 12.3 already performs. - - Inputs: - - world P, radius, damage and type; - - each entity's position, facing, hull, armour and occupancy at the detonation tick; - - the fuse and radius, frozen from weapon data at Fire. - - Outputs: the same mutations and events as 12.3. - - Derived state: `WeaponModifiers` is display-only: `SchematicListElement.cs:54` icons, - `GameSettings.cs:67`, and `GameplaySettingsEditor.cs:44`. - - Forbidden writers: - - No label decides behaviour. - - Nothing but `Detonate` applies area damage. - - Mine's Unity `BlastRange` and arming query remain presentation-side inputs (R8 - residue, Cut 4 Q7) and decide no damage themselves. - - Shared paths: the airburst resolution in `Step` (`:356`), Mine (`Mine.cs:99`), and - contact and delayed hits from `Apply`. - - Deletion line: `Splash` and the label read are deleted before `Detonate` is called. -- **Verification:** - - `TheDiscIsConservedOverTheGrid`: over a solid hull large enough to contain the disc, - the total damage delivered equals `damage` within float tolerance, at several radii - including one smaller than a cell and one centred on a cell corner. It kills a - sampling approximation, a wrong normalizer, and a bounding box that clips the disc. - - `EachCellTakesItsShareOfTheDisc`: on a solid hull, the damage on a named cell equals - `damage × overlap / (π r²)`, with the overlap computed independently by the test - (fine numeric integration). It kills an even split over the covered cells, which is - what today's Splash does. - - `ExternalBlastWastesMostOfItsEnergy`: a proximity blast beside a hull delivers total - armour + item + hull damage well below `damage`. The expected share is the disc's - overlap with the schematic, computed by the test. - - `InternalBlastDeliversIt`: the same blast deep inside the hull delivers nearly all of - `damage`, and damages interior items an external blast of the same radius cannot reach. - - `PenetratorBurrowsBeforeItBursts`: a delayed-fuse hit with penetration 2 into the - enclosed-cockpit fixture damages the cockpit, while a contact-fuse hit with the same - stats does not. It also pins Q12-8's scope, in two halves: - - With a blast radius, the armour the burrow passes through absorbs nothing, so the - cockpit's share is the full area share of its own cell. - - With the radius removed and everything else held, the same shot is a dumb AP round: - it damages the cells along its lane in order, and the cockpit gets only what the - armour and items in front of it left (12.3). It kills a build that routes every - penetrating shot through the fuse path. - - `ABlastDamagesTheCellsNearestIt`: a blast to port damages port cells and leaves - starboard cells untouched, at facings including |fx| > |fy|. It is the successor of - `SplashDamagesTheSideTheBlastCameFrom` and `SplashIsDirectional`, and it is what pins - `ToSchematicPoint`'s handedness. - - `ShieldPaysForWhatReachesIt`: a shield in radius is charged the covered share, not - the whole blast. `SplashShieldAbsorptionDrainsTheReserve` and - `SplashBreaksUnabsorbedShield` are rewritten to the covered share, with the expected - amounts recomputed rather than loosened. - - `LabelsDoNotDecideBehaviour`: a weapon labelled `Airburst` with no fuse resolves as a - direct hit, and a weapon with `Fuse = Proximity` and no label detonates. - - Kept green: `AirburstSplashesEvenOnAGuaranteedMiss` and its no-double-application twin, - re-fixtured to `Fuse`. - - Negative: `rg -n "Splash\(|HasFlag\(WeaponModifiers" Assets/Scripts tests` returns - nothing. Today it matches `FireControl.cs`, `Mine.cs` and `FireControlCut4/5/6/11Tests.cs`. - - Stryker: `--since:<12.3 head>`. -- **Operator:** author one proximity weapon and one delayed-fuse weapon in a scratch - catalog. Watch an airburst beside a LonginusX and a penetrator into its nose, and check - that the damage lands where the model shows the blast. That also verifies - `ToSchematicPoint`'s centre-of-mass anchor. +In (a): + +- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), + `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. + `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. +- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The + initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a + proximity fuse. +- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. + +In (b): + +- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. +- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. + `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). +- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell + separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. + **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` + the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. + 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. +- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended + answer. It is not deleted: it is gated on `shot.Fuse == null`. +- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one + reach function (see **The penetrator**). +- Stale comments that name a deleted owner as live: + - `Entity.cs:391` and `:417`; + - `FireControl.cs:1201`; + - `Gameplay/EntityInstance.cs:280-283`; + - `Gameplay/HullCollider.cs:4-6`; + - `Gameplay/Weapons/Projectile.cs:8-9`; + - `Gameplay/Weapons/Mine.cs:90-95`. + +#### Data + +The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). + +- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name + `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache + opens the catalog as compatible drift (see 0b). +- **New slot 30.** `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, + Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: + null means no blast, and every shipped record reads as null. +- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when + `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing + re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius + without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is + not policed. +- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That + write is content and changes no data. It belongs to F12-2. +- **Readers to update:** + - `FireControl.cs:386-388`. + - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` + parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, + because Q12-6 ruled that nothing checks labels against behaviour. + +#### Which model a shot uses + +`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: + +- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. +- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which + `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, + `AirburstSplashesEvenOnAGuaranteedMiss`. +- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P + is**). On a miss nothing happens. + +Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse +into Apply's switch, so `Step` has no damage branch left. + +`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and +only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one +consumer is the player's hit marker (`ActionGameManager.cs:1070`). + +This is a default, not a ruling. + +#### Where P is + +The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as +`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). + +**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: + +- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 + from its centre; +- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. + +So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. + +- **Proximity:** `BurstPosition.xz`, a world point. +- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and + `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. +- **Delayed:** the schematic point `point(t_fuse)`, with + `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that + 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, + whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last + reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. +- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already + proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not + read. The disc's radius is its width. This is a default, not a ruling. +- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the + host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like + every other entity in the radius: its cells are found again by `ToSchematicPoint`. + - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for + it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. + - Bystanders are judged against where the host actually is at arrival. That is the same live rule a + proximity burst already applies to everyone. + + There is exactly one input shape, a world point. There is no host-only schematic entry. + +#### The penetrator (Q12-8) + +A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` +the shot's whole `Damage`**. + +That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the +explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. + +The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at +`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → +int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while +`entry - impact.entry < penetration`. + +Two callers read it: + +- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; +- the delayed fuse point, which calls it on the centre lane. + +No second "lane damage with a blast" path exists, because no shot does both: + +- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; +- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. + +A shot takes one branch of Apply's switch or the other, never both. + +The mutant this must kill is a second reach derivation: for example, a delayed P computed as +`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. + +#### Area, per entity: `FireControl.Detonate` + +The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, +public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). + +- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes + `position.ToCultMath().xz`). +- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / + SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the + frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and + `SchematicCellSize` is `Settings.cs:255`. + +For each entity in the zone: + +1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. + Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a + long hull whose end lies inside the blast. +2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For + every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × + overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a + cell being the unit square centred on its integer coordinate. + - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing + computes an overlap twice. + - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. +3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, + under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact + or delayed blast, under Q12-9's recommended answer. + - If the shield absorbs, no cell of that entity takes anything. + - If it cannot, it breaks, and the cells take their shares. +4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects + its own cell's share and nothing else. +5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in + hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has + deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join + the hull total. +6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). + +Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the +other (F12-8). + +**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, +and no operator question is needed: + +- "Armor absorbs first", from the first round of rulings; +- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; +- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where + incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after + `ArmorAbsorb`; +- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." + +The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: +an item's cells deposit into one pool, and they do not absorb independently. + +**One owner, and why there is no walk.** + +- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on + the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. +- `ArmorAbsorb` is the one owner of what armour does. +- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, + `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the + item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no + cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the + reachability test and the cycle rule have nothing to decide. + +The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull +with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell +with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 +discs. Its hull total differed only by float summation order, at most 3.8e-6. + +`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a +starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. + +**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same +per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only +order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. + +**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the +disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` +between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it +conserves exactly up to float rounding. + +- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at + radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double + precision is not needed. +- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. +- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc + smaller than a cell, or for one straddling a cell boundary. + +**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than +128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not +the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. + +**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an +area is occluded. A bulkhead protects its own cell's share and no other. Falloff toward the rim is F12-7, not +this cut. + +**HitProbability and Inspect are unchanged.** A contact or delayed blast still needs the shot to hit, so the +forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. +That is pre-existing, and it is F12-5. + +#### Authority map + +**Owners:** + +- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. +- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the + per-entity armour→pool→hull pass. +- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with + 12.3's walk. +- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. +- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. +- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. + +**Inputs:** + +- P, the radius, the damage and the type; +- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; +- the fuse and the radius, frozen at Fire; +- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. + +**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, +`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. + +**Derived state:** + +- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, + `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. +- `BurstPosition` is read only for a proximity fuse. +- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. + Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` + finds only `FireControl.cs`). + +**Forbidden writers:** + +- No label decides behaviour. +- Nothing but `Detonate` applies area damage. +- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. +- Nothing but `Reach` decides how deep a lane reaches. +- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). +- `Step` has no damage branch. +- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they + decide no damage themselves. + +**Shared paths:** + +- the proximity burst: Apply at arrival, with no roll; +- the contact and delayed blasts: Apply on a committed hit; +- `Mine.Explode`; +- the tests, which call `Detonate` directly (see **Test seam**). + +**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a +caller. + +#### Test seam + +`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen +intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production +interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from +`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is +committable with no seam and no reflection. + +It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at +an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes +a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and +exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: + +- angled direct-hit correctness is covered by the model sweep and + `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; +- P1 and P2 stay uncommitted. + +#### Verification + +These fixture rules apply to every blast test that touches the frame: + +- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a + deleted `/ SchematicCellSize` survives. +- The target is off the origin. +- The facings include one with |fx| > |fy|. +- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. + +Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 +absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. + +**Geometry.** + +- `TheDiscIsConservedOverTheGrid`. + - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius + under one cell, one centred on a cell corner, and one off the grid. + - Pass: the total equals `damage`. + - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box + that clips the rim cells; a dropped chord breakpoint. +- `EachCellTakesItsShareOfTheDisc`. + - Setup: a solid hull with no armour and no items. + - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. + - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the + cell's centre. +- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). + - Setup: a blast straddling the hull's edge. + - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The + shares sum to the blast's damage minus what falls off the hull. + - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. +- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. + - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. + - Pass: the near end takes damage. + - Kills: Splash's centre-distance cull. + +**Absorption order.** + +- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` + (`FireControlCut123Tests.cs:750`). + - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage + levels of the old test: below the armour, between the armour and armour plus durability, and above both. + - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. + - Kills: the item absorbing before the armour, and armour being skipped. +- `ArmourProtectsOnlyItsOwnCellsShare`. + - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. + - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. + - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat + cell B's share. +- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. + - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but + more than `.1` on the two together (the `ProbeC2` geometry). + - Pass: the item absorbs the sum. + - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or + a pool that resolves before every covered cell has deposited. + + This test is also what pins that the result does not depend on which cell is considered first. As shown + above, the threshold is the only order-sensitive rule a blast has left. +- `ItemDamageFiresOncePerCoveredCell`. + - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive + post-armour deposit, and each event carries that cell's deposit. + - Kills: one event per item, and events for zero-deposit cells. + +**Symmetry and frame.** + +- `MirroredBlastsDoMirroredDamage`. + - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of + world points mirrored across the target's bow axis, with the mirror computed by the test from the facing + and not through `ToSchematic`. + - Pass: per-cell armour, item durability and hull damage mirror. + - Kills: + - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); + - a missing or wrong `CenterOfMass` anchor; + - any scheduling rule that depends on enumeration order. + + It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test + does. +- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` + (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). + - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's + width. + - Pass: port cells are damaged, and starboard cells are untouched. + - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. + +**Shields** (under Q12-9 = A). + +- `ShieldPaysForWhatReachesIt`. + - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. + - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` + (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the + covered share, never loosened. + - Kills: charging the full damage. +- `ABlastShotsShieldIsDecidedAtDetonation`. + - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at + arrival. + - Pass: the shield is charged the covered share. + - Second setup: a shield that could absorb the whole shot at commit. + - Pass: the shot still detonates, and the shield pays its covered share. + - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the + host. + +**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). + +- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit + behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. + - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour + of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. + - A contact fuse with the same stats leaves the cockpit untouched. + - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in + order, and the cockpit takes only what the ring left (12.3). Damage in a line. + - Kills: + - absorbing along the burrow; + - a contact fuse reading penetration; + - routing every penetrating shot through the fuse path; + - a fuse without a radius detonating. +- `TheFusePointStaysOnMetal`. + - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap + behind the first run. + - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the + gap's near edge). + - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. +- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. + - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. + - Pass: the host's per-cell damage equals that of the same shot with no turn. + - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. +- `LabelsDoNotDecideBehaviour`. + - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with + `Fuse = Proximity` and no label detonates. +- `AFuseWithoutARadiusIsADirectHit`. + - Pass: `Fuse = Contact` with a null or zero radius does lane damage. + - Kills: Fire freezing `Fuse` without the radius check. +- Re-fixtured to `Fuse`, with their behaviour unchanged: + - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes + `ProximityDetonatesEvenOnAGuaranteedMiss`; + - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); + - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; + - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes + `ABlastHitsEveryEntityItCovers`. +- `AContactBlastReportsTheHit`. + - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. + - Kills: `IncomingHit` moved inside the direct-hit branch. + +**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: + +- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes + `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with + a remainder of 0. +- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool + of `{ .1f }`. + +**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the +clip rule are otherwise untouched. + +**Negative greps:** + +- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` + returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and + `FireControlCut4/5/6/11/123Tests.cs`. + - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are + unrelated. + - Exclude `StrykerOutput`, `bin` and `obj`. +- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the + stale-comment list above. + +**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. +`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). + +#### Operator + +- Author one proximity weapon and one delayed-fuse weapon in a scratch catalog. +- Watch an airburst beside a LonginusX and a penetrator into its nose, and check that the damage lands where + the model shows the blast. That verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of + the frame that only a running blast can show. + +#### History (superseded 2026-09-25, kept for the record) + +The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: + +- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** + Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour + stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. +- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with + `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. +- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** + Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then + `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit + had judged unshielded: two authorities over one shield. +- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and + `Step` has no damage branch. +- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one + `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. +- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was + `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. ### 0b. Identity, lifecycle, authority @@ -2760,7 +3120,7 @@ Two persisted changes, both on `aetheria.weaponitemdata` v1 and both in 12.4: | Field | Slot | Change | Lifecycle | Reader risk | |---|---|---|---|---| -| `BlastRadius` (was `AirburstRange`) | 29 | rename, same `float?` type | authored catalog | compatible drift: `CompareSchemaShapes` ignores member names (`CultCache.cs:672-744`); drift warnings until the next catalog write | +| `BlastRadius` (was `AirburstRange`) | 29 | rename, same `float?` type | authored catalog | compatible drift: `CompareSchemaShapes` ignores member names (`CultCache.cs:672-752`, CultLib `45c2f40`); drift warnings until the next catalog write | | `Fuse` | 30 | new, `WeaponFuse?` | authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule | Runtime-only and never serialised (0b table, `FireControl.cs:626-629`, `:686-689`): @@ -2806,6 +3166,48 @@ Ruled 2026-09-22 (the words are recorded under **Rulings**): Open: +**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a +blast that needs a hit?** + +The current map lets two places decide it: + +- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. +- `Detonate` then decides every shield in the radius live, and that includes the host. + +**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so +the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can +absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split +this campaign has cut three times. + +- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield + for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the + radius, live at detonation. + - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no + direction", applied to shields. + - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that + reaches the hull. For a contact detonation on the hull's face, that is roughly half. + - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads + them. +- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole + and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" + parameter: a compensator that keeps two owners alive, one per entity. + +What depends on it: + +- the gate on Commit's shield block; +- whether `Detonate` takes a host parameter; +- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. + +Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half +of (b) can proceed while this waits. + +**Defaults taken, not rulings.** The operator may overrule any of these: + +- A blast shot ignores `DamageSpread`, and P sits on the centre lane. +- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. +- The delayed fuse point is clamped to the last reached cell's exit. +- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. + - **Q12-5 (not ruled). Should the HUD show which target items are exposed from the current bearing?** - **Recommended:** a follow-up after 12.3. It would be a presentation read of `Silhouette` @@ -2842,22 +3244,33 @@ Open: - **Orphaned `Airburst*` YAML** in nine weapon prefabs. It is harmless, and Unity drops it on the next save of each prefab. -### Subtraction ledger (estimate) +### Subtraction ledger (estimates, with actuals) | Sub-cut | Removed | Added | Notes | |---|---|---|---| | 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | -| 12.1 | ~15 (two frame copies, Aimed plumbing) | ~6 (`ToSchematic`) | no behaviour change | -| 12.2 | ~65 (HullKernel, WeightedPick, POnHull, live direction, bounding extent, HUD estimate, stale comments) | ~95 (TravelDirection, Silhouette, CommitProbability, lateral draw, Lane, 5 PendingShot/Outcome fields) | 6d kernel tests rewritten (~−150/+220) | -| 12.3 | ~80 (DamageSchematic, ApplyHit) | ~45 (Absorb, DamageHull, lane orchestration) | 2 FireAuthority tests replaced; ~6 new | -| 12.4 | ~45 (Splash, its half-hull footprint, the label read, BurstRadius plumbing) | ~60 (Detonate, circle-square overlap, point transforms, `WeaponFuse`, slot 30) | Splash tests rewritten; ~7 new. Areas need no ray loop, so this is smaller than the ray draft | -| **Net Aetheria src** | **~205** | **~205** | one persisted slot added (nullable), one renamed; no targets, dependencies or formats; CultMath gains 2 functions | +| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | +| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | +| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | +| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 30 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | +| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | -The net is roughly flat: the positive part buys the bearing-aware scatter, sequential -absorption, and a blast model that replaces a half-hull approximation. The rest is -subtraction. +Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: + +- 12.1: `b851b0d1^..b851b0d1`; +- 12.2: `b851b0d1..c7b6e80c`; +- 12.3: `c7b6e80c..d402283c`. + +The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: + +- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; +- 12.3: sequential, proportional, order-free absorption; +- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, + `Absorb` and `Step`'s branch. + +12.4's own delta is ~+60, and that is the estimate to hold it to. **Section history:** Cut 6d's "The rule" (the 2D kernel) and its premise that pOnHull folds -into PBase at Fire, 9.2's SigmaFloor rationale, and Cut 4's Splash rule (`FireControl.cs:488-497`) +into PBase at Fire, 9.2's SigmaFloor rationale, and Cut 4's Splash rule (`FireControl.cs:946-959`) describe models this cut replaces. Self should mark them as history in the status header as 12.2 and 12.4 land. From 2625768f8668c58c884212f971dd425bf278967a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 19:15:26 +0200 Subject: [PATCH 053/116] Cut 12.4(a): rename AirburstRange to BlastRadius, add WeaponFuse WeaponItemData.AirburstRange (slot 29) becomes BlastRadius, same slot and type so CultCache opens the catalog as compatible drift. A new nullable slot 32 (30 and 31 belong to EquippableItemData) carries WeaponFuse? Fuse. FireControl.Fire now decides "detonates" once, from BlastRadius and Fuse directly, and freezes Fuse only when BlastRadius > 0 -- the WeaponModifiers.Airburst label no longer decides anything (Q12-6). PendingShot.BurstRadius is renamed BlastRadius and gains a Fuse field that nothing reads yet; Step's Splash/Apply dispatch is unchanged. No shipped weapon carries a blast radius or the Airburst label (Cut 12's Probe4, reconfirmed against GameData/Aetheria.cc), so this is behaviour-neutral on shipped content. Apply's fuse switch and deleting Splash are Cut 12.4(b). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Enums.cs | 9 ++++ Assets/Scripts/ServerShared/FireControl.cs | 36 +++++++------ Assets/Scripts/ServerShared/ItemData.cs | 20 +++++--- .../FireControlCut6Tests.cs | 50 ++++++++++++++----- 4 files changed, 82 insertions(+), 33 deletions(-) diff --git a/Assets/Scripts/ServerShared/Enums.cs b/Assets/Scripts/ServerShared/Enums.cs index e556ba656..2b5a9637b 100644 --- a/Assets/Scripts/ServerShared/Enums.cs +++ b/Assets/Scripts/ServerShared/Enums.cs @@ -107,6 +107,15 @@ public enum WeaponModifiers Cluster = 1 << 6 } +// Cut 12.4(a) (docs/fire-control-cut.md): what a weapon's blast radius (WeaponItemData.BlastRadius) detonates +// as. Null means no blast at all -- WeaponItemData.Fuse is frozen null unless BlastRadius > 0 (FireControl.Fire). +public enum WeaponFuse +{ + Contact, + Proximity, + Delayed +} + public enum DamageType { Kinetic, diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index eebbda665..2c7fcbdba 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -379,13 +379,16 @@ public static class FireControl var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; - // Cut 6b, 6.2 (Soul finding 5): airburst is a property of the weapon, resolved and frozen here like - // every other payload field -- never decided later by a Unity projectile. A weapon without the - // Airburst flag freezes a zero radius, which Step below reads as "resolve this shot with Apply." + // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the + // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided + // later by a Unity projectile, and never by a label. Fire decides "detonates" once: a fuse is frozen + // only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius + // is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below + // still reads as "resolve this shot with Apply." var weaponItemData = item.Data as WeaponItemData; - var isAirburst = weaponItemData != null && weaponItemData.WeaponModifiers.HasFlag(WeaponModifiers.Airburst); - var burstPosition = isAirburst && target != null ? PredictedIntercept(weapon, source, target) : targetPosition; - var burstRadius = isAirburst ? weaponItemData.AirburstRange ?? 0f : 0f; + var blastRadius = weaponItemData?.BlastRadius > 0f ? weaponItemData.BlastRadius.Value : 0f; + var fuse = blastRadius > 0f ? weaponItemData.Fuse : null; + var burstPosition = blastRadius > 0f && target != null ? PredictedIntercept(weapon, source, target) : targetPosition; var shot = new PendingShot { @@ -410,7 +413,8 @@ public static class FireControl ArrivalTime = now + flightTime, CommitTime = now + max(0f, flightTime - commitHorizon), BurstPosition = burstPosition, - BurstRadius = burstRadius, + Fuse = fuse, + BlastRadius = blastRadius, Committed = false }; @@ -457,10 +461,10 @@ public static class FireControl if (shot.Committed && now >= shot.ArrivalTime) { - // Cut 6b, 6.2: an airburst shot (frozen BurstRadius > 0) resolves as an area effect instead of - // a discrete hit -- Splash instead of Apply, never both, which is exactly the double- - // application Soul was told to hunt for. - if (shot.BurstRadius > 0f) Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType); + // Cut 6b, 6.2 (renamed 12.4(a)): a shot with a frozen BlastRadius > 0 resolves as an area effect + // instead of a discrete hit -- Splash instead of Apply, never both, which is exactly the + // double-application Soul was told to hunt for. The fuse switch itself is 12.4(b)'s. + if (shot.BlastRadius > 0f) Splash(zone, shot.BurstPosition, shot.BlastRadius, shot.Damage, shot.DamageType); else Apply(shot); zone.ShotResolved.OnNext(shot.Outcome); shots.RemoveAt(i); @@ -1243,11 +1247,13 @@ public struct PendingShot public float Spread; public float FireRange; - // Cut 6b, 6.2: the airburst payload, frozen at fire alongside everything else above. BurstRadius is zero - // for a weapon without the Airburst flag (WeaponModifiers, Enums.cs) -- Step reads that zero as "resolve - // with Apply," not a separate bool. + // Cut 12.4(a): the blast payload, frozen at Fire alongside everything else above. BlastRadius is zero for + // a weapon whose data does not carry one -- Step reads that zero as "resolve with Apply," not a separate + // bool. Fuse is frozen only when BlastRadius > 0 (Fire decides "detonates" once); it is unread until + // Apply's fuse switch (12.4(b)). public float3 BurstPosition; - public float BurstRadius; + public WeaponFuse? Fuse; + public float BlastRadius; public float3 FireTargetPosition; public float3 FireTargetVelocity; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 1cec438cc..27c647d83 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -490,12 +490,20 @@ public class WeaponItemData : GearData [Inspectable, JsonProperty("modifiers"), Key(28)] public WeaponModifiers WeaponModifiers; - // Cut 6b (docs/fire-control-cut.md, 6.2): the authored blast radius an Airburst-flagged weapon's shot - // splashes with at arrival, frozen into PendingShot.BurstRadius at fire. Zero (and unread) for a weapon - // without the Airburst flag. No authored value exists yet -- the flag is wired and unexercised until Cut - // 6a or a later authoring pass sets it. - [Inspectable, JsonProperty("airburstRange"), Key(29)] - public float? AirburstRange; + // Cut 12.4(a) (docs/fire-control-cut.md): the authored blast radius a weapon's shot detonates with, in + // world units. Renamed from AirburstRange -- the slot and type are unchanged, so CultCache opens the + // catalog as compatible drift. Null (every shipped record) means no blast; WeaponModifiers no longer + // decides this (Q12-6, "WeaponModifiers are labels, not behaviour"). + [Inspectable, JsonProperty("blastRadius"), Key(29)] + public float? BlastRadius; + + // Cut 12.4(a): what kind of blast BlastRadius describes. Nullable under the 7.4 rule -- null means no + // blast, and every shipped record reads null. FireControl.Fire freezes this from the weapon only when + // BlastRadius > 0; a fuse without a radius, or a radius without a fuse, is inert and resolves as a direct + // hit (nothing polices that, per Q12-6). Key 30 and 31 belong to EquippableItemData's OptimalTemperature + // and PlateauWidth, inherited here -- the next free key across WeaponItemData's own ancestry is 32. + [Inspectable, JsonProperty("fuse"), Key(32)] + public WeaponFuse? Fuse; } [CultDocument("aetheria.hulldata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 72421af4e..1f85e2381 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -78,7 +78,7 @@ public sealed class FireControlCut6Tests : IDisposable GameplaySettings settings, float damage = 10, float range = 1000, float velocity = 0, float spread = 0, float accuracy = 1, float resolution = 1, float tracking = 100000, - float targetRange = 100, bool airburst = false, float airburstRange = 0) + float targetRange = 100, WeaponFuse? fuse = null, float blastRadius = 0) { var hullShape = SolidShape(5, 5); var hullData = new HullData @@ -91,14 +91,15 @@ public sealed class FireControlCut6Tests : IDisposable _openCaches.Add(cache); cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); cache.Upsert(hullData); - // Cut 6b, 6.2: a real WeaponItemData (not the bare GearData earlier cuts' fixtures use) -- it is the - // one catalog type carrying WeaponModifiers (the Airburst flag) and, since this cut, AirburstRange. + // Cut 6b, 6.2 (renamed 12.4(a)): a real WeaponItemData (not the bare GearData earlier cuts' fixtures + // use) -- it is the one catalog type carrying Fuse and BlastRadius. WeaponModifiers no longer decides + // behaviour (Q12-6), so the fixture does not bother setting it. cache.Upsert(new WeaponItemData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, - WeaponModifiers = airburst ? WeaponModifiers.Airburst : WeaponModifiers.None, - AirburstRange = airburstRange, + Fuse = fuse, + BlastRadius = blastRadius, Behaviors = { new InstantWeaponData { Damage = Constant(damage), Range = Constant(range), MinRange = Constant(0), @@ -246,7 +247,7 @@ public sealed class FireControlCut6Tests : IDisposable { var settings = TestSettings(); var e = Build(settings, damage: 500, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000, - targetRange: 50, airburst: true, airburstRange: 100); + targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); var before = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -265,7 +266,7 @@ public sealed class FireControlCut6Tests : IDisposable var settings = TestSettings(); var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, - targetRange: 50, airburst: true, airburstRange: 100); + targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); var before = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); @@ -276,7 +277,7 @@ public sealed class FireControlCut6Tests : IDisposable // exactly its own position -- PredictedIntercept falls back to target.Position below weapon.Velocity's // .01f floor). var e2 = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, - targetRange: 50, airburst: true, airburstRange: 100); + targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); var before2 = e2.Target.Hull.Durability; FireControl.Splash(e2.Zone, e2.Target.Position, 100, 10, DamageType.Kinetic); var expectedDelta = before2 - e2.Target.Hull.Durability; @@ -285,19 +286,19 @@ public sealed class FireControlCut6Tests : IDisposable Assert.Equal(expectedDelta, actualDelta, 3); } - // The other side: a non-airburst shot must never splash, even one that lands exactly on its own frozen + // The other side: a shot with no blast must never splash, even one that lands exactly on its own frozen // burst position at zero range (a stationary target, so BurstPosition == the target's own position at // arrival) -- the zero-radius edge case a careless "splash unconditionally" mutant would still pass // through, since distance 0 is never greater than radius 0. Accuracy 0 pins the discrete roll to a // guaranteed miss, so the correct path (Apply, gated on Outcome.Hit) deals no damage at all. Mutation: - // splash unconditionally at arrival instead of gating on BurstRadius -- Splash does not check Hit, so it - // would still deal damage even though this shot never carried the Airburst flag. + // splash unconditionally at arrival instead of gating on BlastRadius -- Splash does not check Hit, so it + // would still deal damage even though this shot never froze a blast. [Fact] public void NonAirburstNeverSplashes() { var settings = TestSettings(); var e = Build(settings, damage: 1000, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000, - targetRange: 50, airburst: false); + targetRange: 50); var before = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -306,6 +307,31 @@ public sealed class FireControlCut6Tests : IDisposable Assert.Equal(before, e.Target.Hull.Durability); } + // ---- Cut 12.4(a): Fire decides "detonates" once, from the weapon's own data fields, never from a label + // (Q12-6, "WeaponModifiers are labels, not behaviour"). A fuse without a radius is inert: it freezes null, + // not the weapon's Fuse. ---- + + // Mutation: freeze `weaponItemData.Fuse` unconditionally instead of gating it on `blastRadius > 0f` -- a + // weapon that carries a fuse but no radius would then freeze a fuse it should not, and Apply's future + // fuse switch (12.4(b)) would try to detonate a shot with nothing to detonate with. + [Fact] + public void FireFreezesFuseOnlyWhenBlastRadiusIsPositive() + { + var settings = TestSettings(); + + var withRadius = Build(settings, fuse: WeaponFuse.Delayed, blastRadius: 40); + FireControl.Fire(withRadius.Weapon, withRadius.WeaponItem, withRadius.Shooter); + var shotWithRadius = withRadius.Zone.PendingShots[0]; + Assert.Equal(WeaponFuse.Delayed, shotWithRadius.Fuse); + Assert.Equal(40f, shotWithRadius.BlastRadius); + + var withoutRadius = Build(settings, fuse: WeaponFuse.Delayed, blastRadius: 0); + FireControl.Fire(withoutRadius.Weapon, withoutRadius.WeaponItem, withoutRadius.Shooter); + var shotWithoutRadius = withoutRadius.Zone.PendingShots[0]; + Assert.Null(shotWithoutRadius.Fuse); + Assert.Equal(0f, shotWithoutRadius.BlastRadius); + } + // ---- negative: ItemManager.Random no longer appears in FireControl.cs; Airburst no longer appears // anywhere under Assets/Scripts/Gameplay (Projectile.cs's fields and ProjectileManager.cs's write-back are // deleted, not merely unused). Enforced by tests/mutation_tests_fire_control_cut6.sh's negative-grep step, From 0fe7156d8b63857f5ff571d77b419ee0f710a407 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 20:15:00 +0200 Subject: [PATCH 054/116] Cut 1 fix batch 2 (F2): collapse the entrance station to one orbit selection Two writers decided the tutorial entrance's station orbit: the floor (stationCount < 1 -> 1) and a synthesized fallback orbit, each masking bugs in the other (removing either alone still passed the entrance test). Collapse to one rule: the floor still guarantees stationCount >= 1, but its orbit always comes from the same Lagrange selection. When that selection has no candidate (a zone that is entirely one rosette), widen it to every parented planet orbit ordinary placement draws from before the rosette filter, instead of inventing an unparented, zero-distance orbit -- which gave PlaceTurret's phase a division by zero and left an orphan orbit record. If even the widened set is empty, throw loudly instead of silently omitting the entrance's station. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 25 +++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index d09e52b6b..e3235b412 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -221,22 +221,25 @@ public static class ZoneGenerator .TrueForAll(c => !(c != p && abs(c.Distance - p.Distance) < .1f))) // Filter Rosettes .OrderBy(p => p.Distance) .ToArray(); + // The entrance's own station count is the floor above; its orbit always comes from this same selection. + // Ordinarily that selection is potentialLagrangePoints (non-rosette planet orbits). If the entrance zone + // has none of those (a boring single-body system, or a system that is entirely one rosette), widen to + // every planet orbit ordinary placement's own candidate pool is drawn from before the rosette filter -- + // still a real, parented planet orbit with a positive Distance -- rather than inventing an unparented, + // zero-distance orbit (that gave PlaceTurret's phase = 20*multiplier/orbit.Distance a division by zero, + // and left an orbit record with no planet behind it). + var lagrangeCandidates = potentialLagrangePoints; + if (isTutorialEntrance && lagrangeCandidates.Length == 0) + lagrangeCandidates = planets.Where(p => p.Parent != null).OrderBy(p => p.Distance).ToArray(); // Pick a selection from the middle of the distribution - var selectedStationOrbits = potentialLagrangePoints - .Skip(potentialLagrangePoints.Length / 2) + var selectedStationOrbits = lagrangeCandidates + .Skip(lagrangeCandidates.Length / 2) .Take(stationCount) .Select(p=>orbitMap[p]) .ToArray(); - // No planet has a sibling to share a Lagrange point with (a boring single-body system, or none at all), - // so there is nowhere the ordinary path would put a station. The entrance zone still needs one: give it - // a standalone orbit fixed at a safe distance from the zone centre rather than silently leaving it with - // no station. if (isTutorialEntrance && selectedStationOrbits.Length == 0) - { - var fallbackOrbit = new OrbitData { FixedPosition = random.NextFloat2Direction() * pack.Radius * .5f }; - cache.Upsert(fallbackOrbit); - selectedStationOrbits = new[] { fallbackOrbit }; - } + throw new InvalidOperationException( + "The tutorial entrance zone must always get a station, but it has no orbit at all -- every planet in the zone is a rootless rosette member with no parent."); var loadoutGenerators = new Dictionary(); From 615008523e5fc60104c797308c0b657bff3b411d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 20:15:24 +0200 Subject: [PATCH 055/116] Cut 1 fix batch 2 (F1, F2, F3): commit Soul's behaviour tests F1 (speed cap): assert against Longinus's legacy TopSpeed literal (100) instead of reading the bound back out of the same catalog value being tested -- the old assertion passed under TopSpeed=1000 because the bound moved with the mutation. Also fixes the false "drag/thrust balance" comment: there is no such balance near 100. At full throttle Longinus's own thrusters heat their hull past ThermalOnline's shutdown threshold within ~1s, and once the hull's own cell goes offline the same way, VelocityLimit (a hull behaviour, gated identically) shuts off with it, so the cap was only ever exercised for the first ~15 ticks. OverrideShutdown -- a real, first-class field (the cockpit's "Override Shutdown" control) -- forces the hull and both firing thrusters to keep running for the whole 12s scenario, so the cap is what actually holds speed down throughout, not an unreached ceiling. F2 (entrance station): EntranceZoneAlwaysGetsADockedStationAcrossSeeds now also requires the normal case to land on a genuine (non-rosette) Lagrange orbit. Adds TutorialEntranceWithNoLagrangeCandidateStillSeats..., built via a public SavedGame/SavedZone round trip (no reflection) that re-targets a real galaxy's Entrance to a real zone already known to have no Lagrange candidate (seed 3's "EAC-7089": four planets, one rosette, no captured satellites) -- pins a docked station whose turrets land on a real, finite, nearby orbit. Adds the leak checks (override must not reach other tutorial zones or a non-tutorial game's own entrance) and the enemy-count check (the override must not also raise the enemy roll, which stays keyed to the pre-override station count). F3 (asset identity): EveryAssetReferenceResolvesToAMetaGuid only checks that a referenced GUID exists somewhere under Assets/, so it passed when Djinni's Prefab was swapped for Longinus's. The new test compares each reference against the specific legacy file's own GUID (Djinni's own .prefab.meta; Thruster 1.prefab.meta for every restored thruster's ParticlesPrefab). Co-Authored-By: Claude Opus 5.5 --- .../RestoredHullsTests.cs | 345 +++++++++++++++--- 1 file changed, 300 insertions(+), 45 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 42be988fa..a9138564e 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -57,6 +57,64 @@ public sealed class RestoredHullsTests private static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc")); + // Shared tutorial-galaxy construction settings (Assets/Resources/Settings.asset's own values), factored out + // so every test that needs a real tutorial Galaxy -- not just EntranceZoneAlwaysGetsADockedStationAcrossSeeds + // -- builds it the same way instead of re-inlining the same ~20 fields. + private static ZoneGenerationSettings TutorialZoneSettings() => new ZoneGenerationSettings + { + PlanetSafetyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 2.5f, Constant = 0 }, + MassFloor = 1, SunMass = 5000, GasGiantMass = 1000, PlanetMass = 100, + SatellitePasses = 5, SatelliteCreationMassFloor = 100, SatelliteCreationProbability = .25f, + BinaryCreationProbability = .1f, RosetteProbability = .1f, + ZoneRadius = new ExponentialLerp { Exponent = 1.5f, Minimum = 1000, Maximum = 10000 }, + ZoneMass = new ExponentialLerp { Exponent = 1.5f, Minimum = 10000, Maximum = 500000 }, + SubZoneCount = new ExponentialLerp { Exponent = 1.5f, Minimum = 0, Maximum = 8 }, + ZoneBoundaryRadius = .9f, BeltProbability = .25f, BeltMassCeiling = 500, + AsteroidBeltWidth = new ExponentialCurve { Exponent = .666f, Multiplier = 5, Constant = 50 }, + AsteroidCount = new ExponentialCurve { Exponent = .5f, Multiplier = 1, Constant = 11 }, + AsteroidRotationSpeed = new ExponentialLerp { Exponent = 2, Minimum = .1f, Maximum = .5f }, + SunColorSaturation = .85f, SunSecondaryColorDistance = .33f, SunLightSaturation = .5f, SunFogTintSaturation = .55f, + GasGiantBandCount = new ExponentialLerp { Exponent = 2, Minimum = 5, Maximum = 8 }, + GasGiantBandColorSeparation = .25f, GasGiantBandAltColorChance = .25f, + GasGiantBandSaturation = new ExponentialLerp { Exponent = .5f, Minimum = 0, Maximum = .8f }, + GasGiantBandBrightness = new ExponentialLerp { Exponent = .25f, Minimum = .25f, Maximum = 1 }, + NameData = new string[0], + }; + + private static TutorialGenerationSettings TutorialGalaxySettings() => new TutorialGenerationSettings + { + ProtagonistFaction = "Miss", AntagonistFaction = "Zhe", BufferFaction = "Luc", + NeutralFactions = new[] { "Aero", "Finch" }, QuestFaction = "Adras", LinkDensity = .5f, ZoneCount = 64, + }; + + private static SectorBackgroundSettings TutorialBackgroundSettings() => new SectorBackgroundSettings + { + NoiseAmplitude = 1, NoiseOffset = .3f, NoiseGain = .7f, NoiseLacunarity = 2, NoiseFrequency = .1f, + NoisePosition = 536.5106f, CloudExponent = 10, CloudAmplitude = .01f, + }; + + private static NameGeneratorSettings TutorialNameSettings() => + new NameGeneratorSettings { NameGeneratorMinLength = 5, NameGeneratorMaxLength = 10, NameGeneratorOrder = 3 }; + + // A station's orbit is a copy of its source planet's own (Parent, Distance) (ZoneGenerator.CreateLagrangeOrbit), + // so it always matches that one planet. It matches MORE than one planet only when the source planet shares its + // distance with a sibling under the same parent -- exactly what ZoneGenerator's potentialLagrangePoints filter + // excludes. An entrance station whose orbit matches more than one planet orbit this way could therefore only + // have come from the widened candidate set, never from the ordinary (non-rosette) Lagrange selection. + // + // pack.Orbits starts with exactly one orbit per entry of ZoneGenerator's own (unfiltered) `planets` list -- + // NOT pack.Planets.Count, which excludes rosette-root "Empty" planets that never get a BodyData/PlanetData + // entry at all. Every station and turret then appends exactly one more orbit each (CreateLagrangeOrbit, + // PlaceTurret), one per resulting OrbitalEntityPack, so the true planet-orbit count is pack.Orbits.Count minus + // that many, not pack.Planets.Count. + private static bool IsRosetteMember(CultCache cache, ZonePack pack, OrbitData orbit) + { + if (!orbit.Parent.IsSet()) return false; + var planetOrbitCount = pack.Orbits.Count - pack.Entities.OfType().Count(); + var planetOrbits = pack.Orbits.Take(planetOrbitCount).Select(cache.Get).ToArray(); + return planetOrbits.Count(o => o.Parent.IsSet() && o.Parent.Key.Equals(orbit.Parent.Key) && abs(o.Distance - orbit.Distance) < .1f) > 1; + } + // ZoneGenerator.GenerateZone writes OrbitData/BodyData (run-store types, AetheriaStores.RunTypes), so a // catalog-only cache has no home for them. A scratch run file, deleted after the seed that used it. private static CultCache OpenReadOnlyRealCatalogWithScratchRun(string catalogPath, string runPath) @@ -213,40 +271,17 @@ public sealed class RestoredHullsTests // Galaxy exactly as Assets/Resources/Settings.asset configures it and ActionGameManager.StartGame -> // PopulateLevel(CurrentGalaxy.Entrance) generates it, across seeds, and requires a docked station in every // run. It must fail without ZoneGenerator's tutorial-entrance override. + // + // Also requires that the NORMAL case (a zone with a real Lagrange candidate, the common case across 25 seeds) + // seats its station on a genuine, non-rosette Lagrange orbit rather than the widened fallback -- the widened + // candidate set is for the rare zone with none, not a replacement for ordinary placement. [Fact] public void EntranceZoneAlwaysGetsADockedStationAcrossSeeds() { - var zoneSettings = new ZoneGenerationSettings - { - PlanetSafetyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 2.5f, Constant = 0 }, - MassFloor = 1, SunMass = 5000, GasGiantMass = 1000, PlanetMass = 100, - SatellitePasses = 5, SatelliteCreationMassFloor = 100, SatelliteCreationProbability = .25f, - BinaryCreationProbability = .1f, RosetteProbability = .1f, - ZoneRadius = new ExponentialLerp { Exponent = 1.5f, Minimum = 1000, Maximum = 10000 }, - ZoneMass = new ExponentialLerp { Exponent = 1.5f, Minimum = 10000, Maximum = 500000 }, - SubZoneCount = new ExponentialLerp { Exponent = 1.5f, Minimum = 0, Maximum = 8 }, - ZoneBoundaryRadius = .9f, BeltProbability = .25f, BeltMassCeiling = 500, - AsteroidBeltWidth = new ExponentialCurve { Exponent = .666f, Multiplier = 5, Constant = 50 }, - AsteroidCount = new ExponentialCurve { Exponent = .5f, Multiplier = 1, Constant = 11 }, - AsteroidRotationSpeed = new ExponentialLerp { Exponent = 2, Minimum = .1f, Maximum = .5f }, - SunColorSaturation = .85f, SunSecondaryColorDistance = .33f, SunLightSaturation = .5f, SunFogTintSaturation = .55f, - GasGiantBandCount = new ExponentialLerp { Exponent = 2, Minimum = 5, Maximum = 8 }, - GasGiantBandColorSeparation = .25f, GasGiantBandAltColorChance = .25f, - GasGiantBandSaturation = new ExponentialLerp { Exponent = .5f, Minimum = 0, Maximum = .8f }, - GasGiantBandBrightness = new ExponentialLerp { Exponent = .25f, Minimum = .25f, Maximum = 1 }, - NameData = new string[0], - }; - var tutorialSettings = new TutorialGenerationSettings - { - ProtagonistFaction = "Miss", AntagonistFaction = "Zhe", BufferFaction = "Luc", - NeutralFactions = new[] { "Aero", "Finch" }, QuestFaction = "Adras", LinkDensity = .5f, ZoneCount = 64, - }; - var background = new SectorBackgroundSettings - { - NoiseAmplitude = 1, NoiseOffset = .3f, NoiseGain = .7f, NoiseLacunarity = 2, NoiseFrequency = .1f, - NoisePosition = 536.5106f, CloudExponent = 10, CloudAmplitude = .01f, - }; - var names = new NameGeneratorSettings { NameGeneratorMinLength = 5, NameGeneratorMaxLength = 10, NameGeneratorOrder = 3 }; + var zoneSettings = TutorialZoneSettings(); + var tutorialSettings = TutorialGalaxySettings(); + var background = TutorialBackgroundSettings(); + var names = TutorialNameSettings(); var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); // Galaxy zone-graph generation has a pre-existing, seed-dependent flakiness of its own (a disconnected @@ -255,6 +290,7 @@ public sealed class RestoredHullsTests // not to ZoneGenerator's entrance-station override this test is pinning, so it is skipped rather than // asserted on; every seed whose Galaxy actually builds must still get a docked entrance station. var succeeded = 0; + var sawGenuineLagrangeOrbit = false; for (uint seed = 1; seed <= 25; seed++) { var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-station-{Guid.NewGuid():N}.cc"); @@ -270,9 +306,12 @@ public sealed class RestoredHullsTests var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); - var docked = pack.Entities.OfType() - .Any(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); - Assert.True(docked, $"seed {seed}: the tutorial entrance zone generated no station with a docking bay."); + var dockedStation = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + Assert.True(dockedStation != null, $"seed {seed}: the tutorial entrance zone generated no station with a docking bay."); + + if (!IsRosetteMember(cache, pack, cache.Get(dockedStation.Orbit))) + sawGenuineLagrangeOrbit = true; } finally { @@ -281,20 +320,36 @@ public sealed class RestoredHullsTests } Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result."); + Assert.True(sawGenuineLagrangeOrbit, "none of the 25 seeds seated the entrance station on a genuine (non-rosette) Lagrange orbit."); } // Speed cap (docs/locomotion-cut.md Cut 1 verification): Longinus's restored VelocityLimitData caps it at - // 100 (legacy TopSpeed 100..100). Runs it under straight-line full forward thrust -- LookDirection held on - // the ship's own heading, so the mixer never spends torque turning and thrust stays aligned with velocity -- - // long enough to approach its drag/thrust balance well past 100, and asserts the observed speed never - // exceeds the cap at any sampled tick. Must fail when VelocityLimitData is removed (docs' own probe: Longinus - // reaches 122 without it). + // the legacy TopSpeed, 100 (100..100) -- asserted as the literal from the legacy record rather than read + // back out of the same catalog value being tested, so a mutated TopSpeed (e.g. 1000) can't also move the + // bound this test checks against. + // + // The comment this replaces claimed the run went "long enough to approach its drag/thrust balance well past + // 100" -- false. There is no drag/thrust balance to approach: at full throttle Longinus's own restored + // thrusters heat their hull cells past EquippedItem's ThermalOnline shutdown threshold within about a + // second (Entity.cs's Wear/ThermalOnline; soul-speed.txt traces it), and once the HULL's own cell goes + // offline the same way, VelocityLimit -- a hull behaviour, gated the same way as any other equipped item -- + // stops running too, so the cap switches off along with thrust and the ship just coasts. The cap was only + // ever exercised for the first ~15 ticks. OverrideShutdown (Entity.cs; the same field the cockpit's "Override + // Shutdown" control flips, PropertiesPanel.cs) is a real, first-class affordance for forcing equipment to + // keep running past a thermal cutoff; setting it on the hull and both firing thrusters keeps thrust -- and + // the hull's own VelocityLimit -- running for the whole scenario, so the cap is what actually holds speed + // down for the full duration rather than an unreached ceiling. The run stops at 12 s, comfortably before the + // forced thrusters' own Durability (unprotected by OverrideShutdown) burns out around 15-16 s. + // + // Must fail when VelocityLimitData is removed (docs' own probe: Longinus reaches 122 without it) and when + // TopSpeed is mutated away from 100. [Fact] public void LonginusNeverExceedsItsRestoredTopSpeedUnderFullThrust() { + const float LegacyTopSpeed = 100f; // Longinus's legacy-record TopSpeed (100..100) + using var cache = OpenCatalog(); var hull = cache.GetByName("Longinus"); - var topSpeed = ((VelocityLimitData) hull.Behaviors.Single(b => b is VelocityLimitData)).TopSpeed.Max; var thrusterDesigns = cache.GetAll().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray(); var settings = Settings(); @@ -302,14 +357,16 @@ public sealed class RestoredHullsTests for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; var items = new ItemManager(cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); - var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; - var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); + // OverrideShutdown on both the hull and every thruster: keeps thrust (and the hull's own VelocityLimit) + // running for the whole scenario instead of thermally shutting down after ~1 s. + var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1, OverrideShutdown = true }; + var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings) { OverrideShutdown = true }; var lot = 2; foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) { var design = thrusterDesigns.First(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == hardpoint.Shape.Coordinates.Length); - var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++ }; + var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++, OverrideShutdown = true }; Assert.True(ship.TryEquip(gearItem, hardpoint.Position)); } var reactorHardpoint = hull.Hardpoints.First(h => h.Type == HardpointType.Reactor); @@ -325,16 +382,24 @@ public sealed class RestoredHullsTests ship.MovementDirection = float2(0, 1); // full forward ship.LookDirection = float3(ship.Direction.x, 0, ship.Direction.y); // straight line: hold the ship's own heading - for (var i = 0; i < 60 * 20; i++) + const int ticks = 60 * 12; // stops well before the forced thrusters' own Durability runs out (~15-16 s) + var lastSpeed = 0f; + for (var i = 0; i < ticks; i++) { zone.Update(1f / 60f); var speed = length(ship.Velocity); + lastSpeed = speed; // The mixer applies thrust then VelocityLimit clamps at the START of the next tick (Ship.cs's // per-behavior execution order), so one tick's own acceleration can carry speed briefly past the // cap before the following tick reins it in. The tolerance below is several times the base flight // table's own measured Longinus forward acceleration (172.38 m/s^2) at dt=1/60 (~2.9 per tick). - Assert.True(speed <= topSpeed + 10f, $"tick {i}: speed {speed} exceeded the restored top speed {topSpeed} by more than one tick's acceleration."); + Assert.True(speed <= LegacyTopSpeed + 10f, $"tick {i}: speed {speed} exceeded the legacy top speed {LegacyTopSpeed} by more than one tick's acceleration."); } + + // With shutdown forced off for the whole run, the cap must be what is actually holding speed down by + // the end, not merely an unreached ceiling the ship happened to coast under -- this is what makes the + // assertion above a test of the cap rather than of thermal shutdown. + Assert.True(lastSpeed >= LegacyTopSpeed - 10f, $"final speed {lastSpeed} never converged on the legacy top speed {LegacyTopSpeed}; the cap was never actually binding."); } // Loadout generation (docs/locomotion-cut.md Cut 1 verification): LoadoutGenerator, filtered to each @@ -366,4 +431,194 @@ public sealed class RestoredHullsTests } } } + + // Builds the real tutorial galaxy for `seed`, then re-targets its Entrance to `entranceZoneName` via the + // public SavedGame/SavedZone round trip (RunSave's own shape: Galaxy -> SavedZone[] -> Galaxy(cache, + // SavedGame, log)), without touching Galaxy's private Entrance backing field by reflection. Zone content + // (planets) is keyed off each zone's own Name/Position, not off which zone happens to be Entrance, so the + // re-targeted galaxy's chosen zone generates identically to the original -- letting a test pick, by name, any + // real zone from a real galaxy (e.g. one already known to have no Lagrange candidate) and make it the Entrance. + private static Galaxy BuildGalaxyWithForcedEntrance(CultCache cache, uint seed, string entranceZoneName) + { + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); + var target = galaxy.Zones.Single(z => z.Name == entranceZoneName); + + var factions = galaxy.Factions; + var savedZones = galaxy.Zones.Select(zone => new SavedZone + { + Name = zone.Name, + Position = zone.Position, + AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(), + Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(), + Contents = zone.PackedContents, + Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner), + }).ToArray(); + var keys = savedZones.Select((_, i) => new CultRecordKey($"restoredhullstests-forced-entrance-zone-{i}")).ToArray(); + cache.Commit(batch => { for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), savedZones[i], keys[i]); }); + + var game = new SavedGame + { + Zones = keys.Select(k => new CultRecordRef(k)).ToArray(), + Factions = factions.Select(f => cache.RefOf(f)).ToArray(), + HomeZones = galaxy.HomeZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)), + BossZones = galaxy.BossZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)), + Entrance = Array.IndexOf(galaxy.Zones, target), + Exit = -1, + Relationships = factions.Select(f => galaxy.FactionRelationships[f]).ToArray(), + DiscoveredZones = Array.Empty(), + Background = galaxy.Background, + IsTutorial = true, + }; + return new Galaxy(cache, game, _ => { }); + } + + // Cut 1 fix batch 2 (Soul's finding #2): the tutorial entrance's widened candidate set. Seed 3's real tutorial + // galaxy has a zone, "EAC-7089", whose planets are entirely one rosette with no captured satellites at all: 4 + // planets sharing one Distance under one Parent, and nothing else -- potentialLagrangePoints is empty for it + // (verified directly against this exact fixture, not carried over from Soul's own forced-fallback probe, whose + // own candidate check undercounted planet orbits by using pack.Planets.Count -- which excludes Empty rosette + // roots -- and so misidentified some zones, including its originally-reported "EAC-3208", as candidate-less + // when they were not). Re-targeted here to be the Entrance through BuildGalaxyWithForcedEntrance above. Its + // docked station can therefore only have come from the widened candidate set (every parented planet orbit, + // not just non-rosette ones), never from a synthesized, unparented fallback orbit. Pins: still docked, and + // turrets sit on a real orbit (a real Parent, a finite Phase, and a Distance matching their station's, i.e. + // "near their station"). + [Fact] + public void TutorialEntranceWithNoLagrangeCandidateStillSeatsAStationWithFiniteNearbyTurrets() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-widened-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = BuildGalaxyWithForcedEntrance(cache, seed: 3u, "EAC-7089"); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, galaxy.Entrance, isTutorial: true); + + var station = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + Assert.True(station != null, "the known no-Lagrange-candidate entrance zone generated no docked station."); + var stationOrbit = cache.Get(station.Orbit); + + // Confirms this fixture still exercises the widened path (a regression here would mean the fixture no + // longer probes what this test claims to probe). + Assert.True(IsRosetteMember(cache, pack, stationOrbit), "the entrance station no longer sits on a rosette-only orbit -- this fixture no longer tests the widened candidate path."); + + var turrets = pack.Entities.OfType() + .Where(e => (cache.Get(e.Hull.Data) as HullData)?.HullType != HullType.Station).ToArray(); + Assert.NotEmpty(turrets); + foreach (var turret in turrets) + { + var turretOrbit = cache.Get(turret.Orbit); + Assert.True(turretOrbit.Parent.IsSet(), "a turret's orbit has no Parent."); + Assert.False(float.IsNaN(turretOrbit.Phase) || float.IsInfinity(turretOrbit.Phase), $"a turret's orbit Phase is not finite: {turretOrbit.Phase}."); + Assert.Equal(stationOrbit.Distance, turretOrbit.Distance, 3); // "near their station": same orbit, only Phase differs + } + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 2 (Soul's finding #2, leak check): the entrance override must not leak to (a) other + // tutorial zones or (b) a non-tutorial game's own entrance zone. Both are pinned against the same seed-1 + // galaxy as the widened-candidate test above, so all three tests stand or fall on the same known fixture. + [Fact] + public void EntranceOverrideDoesNotLeakToOtherTutorialZonesOrNonTutorialGames() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-leak-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + int StationCount(GalaxyZone zone, bool isTutorial) => + ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, zone, isTutorial) + .Entities.OfType() + .Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); + + // (a) A non-entrance tutorial zone that naturally rolls zero stations keeps that roll under + // isTutorial: true -- the floor must not leak past the galaxy.Entrance reference check. + var otherZone = galaxy.Zones.Single(z => z.Name == "EAC-2733"); + Assert.NotSame(galaxy.Entrance, otherZone); + Assert.Equal(0, StationCount(otherZone, isTutorial: true)); + + // (b) The entrance zone itself, generated for a non-tutorial game (isTutorial: false), keeps its + // natural roll too -- the floor must not leak past the isTutorial flag either. + Assert.Equal(0, StationCount(galaxy.Entrance, isTutorial: false)); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 2 (Soul's finding #2, baseStationCount): the entrance override raises stationCount (the + // orbit-selection floor) but must not also raise the enemyCount roll, which is keyed to baseStationCount + // (the pre-override value) precisely so the forced station doesn't buy the zone an extra enemy. Seed 1's + // entrance zone rolls baseStationCount 0 (confirmed via the isTutorial: false StationCount == 0 above) and + // the override raises stationCount to 1; this pins the resulting ship count at the value baseStationCount + // produces. It must fail if the enemy-count formula is changed to add stationCount instead (it would add a + // ship instead of zero). + [Fact] + public void ForcedTutorialEntranceStationDoesNotInflateEnemyCount() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-enemycount-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, galaxy.Entrance, isTutorial: true); + var stationCount = pack.Entities.OfType().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); + var shipCount = pack.Entities.OfType().Count(); + + Assert.Equal(1, stationCount); // the override did raise the station count (from a natural roll of 0) + Assert.Equal(2, shipCount); // but the enemy roll stayed keyed to the pre-override count of 0 + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Soul's finding #3 (asset identity): the existence-only check (EveryAssetReferenceResolvesToAMetaGuid) passes + // even when Djinni.Prefab is swapped for Longinus's -- both are real GUIDs somewhere under Assets/. This pins + // the actual identity: each restored reference names the specific legacy-record file it is supposed to, not + // merely some meta file that happens to exist. Must fail under Soul's own "djprefab" mutation (Djinni.Prefab + // set to Longinus's GUID). + [Fact] + public void RestoredAssetReferencesNameTheirOwnLegacyFileNotJustAnyExistingGuid() + { + string GuidOf(string assetPathUnderRepoRoot) + { + var meta = Path.Combine(FindRepoRoot(), assetPathUnderRepoRoot + ".meta"); + return File.ReadLines(meta).First(l => l.StartsWith("guid: ")).Substring("guid: ".Length).Trim(); + } + + using var cache = OpenCatalog(); + + var djinniPrefabGuid = GuidOf("Assets/Content/Prefabs/Ships/Djinni.prefab"); + var djinni = cache.GetByName("Djinni"); + Assert.Equal(djinniPrefabGuid, djinni.Prefab); + + // Every restored thruster's particle effect is Thruster 1.prefab, by the legacy record (soul-dump.txt). + var thruster1Guid = GuidOf("Assets/Content/Prefabs/Thrusters/Thruster 1.prefab"); + var restoredThrusterNames = new[] { "deep space burnout", "Large Drive", "Victoire", "Small Drive", "Talaria", "RevvITup 2.0", "Medium Drive" }; + foreach (var name in restoredThrusterNames) + { + var design = cache.GetAll().First(g => g.Name == name); + var thrusterBehavior = design.Behaviors.OfType().Single(); + Assert.Equal(thruster1Guid, thrusterBehavior.ParticlesPrefab); + } + } } From 2bb245bc3455cea860e0ab7d69b19038df7e07e1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 00:31:22 +0200 Subject: [PATCH 056/116] Cut 12 map: Q12-9 ruled A (the blast decides every shield) Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index fb32dd915..13cd9fc34 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -3166,6 +3166,8 @@ Ruled 2026-09-22 (the words are recorded under **Rulings**): Open: +**Q12-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. + **Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a blast that needs a hit?** From 77be5cf43390dd9d955fffbc14b3437625a5ef27 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 00:52:16 +0200 Subject: [PATCH 057/116] Cut 12.4 map: slot 32 for Fuse, inert rule arrives with (b), measured drift Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 13cd9fc34..4d610f551 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2012,7 +2012,8 @@ Second round: Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: - no C# field of that name exists outside `ItemData.cs:498`. + no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as + `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. ### Frame, geometry and the bearing (the rules every sub-cut reads) @@ -2652,14 +2653,15 @@ The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-4 - **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache opens the catalog as compatible drift (see 0b). -- **New slot 30.** `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, +- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: null means no blast, and every shipped record reads as null. - **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is - not policed. + not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on + `BlastRadius` alone (Soul, `2625768f`). - **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That write is content and changes no data. It belongs to F12-2. - **Readers to update:** @@ -3121,7 +3123,7 @@ Two persisted changes, both on `aetheria.weaponitemdata` v1 and both in 12.4: | Field | Slot | Change | Lifecycle | Reader risk | |---|---|---|---|---| | `BlastRadius` (was `AirburstRange`) | 29 | rename, same `float?` type | authored catalog | compatible drift: `CompareSchemaShapes` ignores member names (`CultCache.cs:672-752`, CultLib `45c2f40`); drift warnings until the next catalog write | -| `Fuse` | 30 | new, `WeaponFuse?` | authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule | +| `Fuse` | 32 | new, `WeaponFuse?` | authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule | Runtime-only and never serialised (0b table, `FireControl.cs:626-629`, `:686-689`): @@ -3254,7 +3256,7 @@ of (b) can proceed while this waits. | 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | | 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | | 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | -| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 30 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | +| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | | **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: From 7c99909d2bd054192cbdfe51fe3185e6a30d08c4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 01:17:19 +0200 Subject: [PATCH 058/116] Cut 12.4(b): Detonate replaces Splash and Entity.Absorb FireControl.Apply now switches on the frozen shot.Fuse: a null fuse keeps 12.3's lane path (ApplyDirectHit), a proximity fuse detonates at arrival with no roll, and a contact or delayed fuse detonates only on a committed hit (ApplyBlastHit), locating P through Lane and the new Reach owner (moved verbatim from Apply's own inline penetration clip; the delayed fuse point reads the identical rule). Step has no damage branch left. Detonate (FireControl.cs) is the one owner of blast damage: candidates by centre-plus-half-diagonal, exact circle/cell overlap shares computed once, every blast's shield decided live per Q12-9 = A (including the host of a contact/delayed blast -- Commit now gates its own shield decision on shot.Fuse == null), armour absorbed per cell, and each item's covered cells pooled into one ItemAbsorb call. Entity.Absorb and FireControl.Splash are deleted; Entity gains ToSchematicPoint/ToWorldPoint for the point conversion Detonate and the delayed fuse point need. Mine.Explode now calls Detonate. Existing Splash/Absorb-era tests across Cut4/5/6/11/123 are rewritten or retargeted to the new public owners; behavioural coverage for the blast model itself lands separately. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/EntityInstance.cs | 9 +- Assets/Scripts/Gameplay/HullCollider.cs | 3 +- Assets/Scripts/Gameplay/Weapons/Mine.cs | 13 +- Assets/Scripts/Gameplay/Weapons/Projectile.cs | 8 +- Assets/Scripts/ServerShared/Entity.cs | 50 ++- Assets/Scripts/ServerShared/FireControl.cs | 368 +++++++++++++++--- Assets/Scripts/ServerShared/ItemData.cs | 6 +- .../FireControlCut11Tests.cs | 51 +-- .../FireControlCut123Tests.cs | 68 +--- .../FireControlCut4Tests.cs | 45 +-- .../FireControlCut5Tests.cs | 19 +- .../FireControlCut6Tests.cs | 52 +-- 12 files changed, 441 insertions(+), 251 deletions(-) diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 0c3c35368..4034c2e55 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -277,10 +277,11 @@ public class EntityInstance : MonoBehaviour } } - // Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (Splash, Hit) - // are deleted -- FireControl decides hits now and applies them through Entity.Absorb/DamageHull - // (ServerShared, Cut 12.3; DamageSchematic and ApplyHit are gone), the single owner in place of what - // used to exist once per Unity effect. HullColliders no longer publish Hit/Splash at all (R8). + // Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (blast, Hit) + // are deleted -- FireControl decides hits now and applies them through Entity.ArmorAbsorb/ItemAbsorb/ + // DamageHull (ServerShared, Cut 12.3/12.4(b); DamageSchematic, the old per-cell single-lane absorb path + // and ApplyHit are all gone), the single owner in place of what used to exist once per Unity effect. + // HullColliders no longer publish Hit or a blast event at all (R8). LookAtPoint = new GameObject($"{entity.Name} Look Point").transform; diff --git a/Assets/Scripts/Gameplay/HullCollider.cs b/Assets/Scripts/Gameplay/HullCollider.cs index bdd417e8e..e5dc4ad14 100644 --- a/Assets/Scripts/Gameplay/HullCollider.cs +++ b/Assets/Scripts/Gameplay/HullCollider.cs @@ -3,7 +3,8 @@ using CultMath.UnityBridge; // Cut 4 (docs/fire-control-cut.md): Hit, Splash, SendHit and SendSplash are deleted -- their last callers // (ConstantLaser, ConstantLightning, ConstantParticleWeapon, HitscanEffect, Mine) are gone too (see those -// files and FireControl.Splash). Nothing decides or applies damage through a Unity collider any more (R8): +// files and FireControl.Detonate, 12.4(b)'s renamed Splash). Nothing decides or applies damage through a Unity +// collider any more (R8): // this component is now purely a physics surface for ship-on-ship collision impulse and Mine's own proximity // arming query (Q7), neither of which is hit detection. public class HullCollider : MonoBehaviour diff --git a/Assets/Scripts/Gameplay/Weapons/Mine.cs b/Assets/Scripts/Gameplay/Weapons/Mine.cs index db0f82141..57d56f68e 100644 --- a/Assets/Scripts/Gameplay/Weapons/Mine.cs +++ b/Assets/Scripts/Gameplay/Weapons/Mine.cs @@ -88,15 +88,16 @@ public class Mine : MonoBehaviour } // Cut 4 (docs/fire-control-cut.md, Q7): the arming OverlapSphere above stays -- that is contact detection, - // not hit detection. Blast damage is not: it goes through the same FireControl.Splash every splash-shaped - // weapon uses now, over the zone's own planar entities, not a Unity collider query. Presentation (ShowHit) - // goes with the query it depended on -- nothing here decides who was hit any more, so nothing here can - // point a hit effect at them; a future pass can rebuild that off FireControl-published state if the mine - // ever ships (R9 keeps it regardless). + // not hit detection. Blast damage is not: it goes through the same FireControl.Detonate every blast-shaped + // weapon uses now (12.4(b) renamed Splash), over the zone's own planar entities, not a Unity collider + // query. Presentation (ShowHit) goes with the query it depended on -- nothing here decides who was hit any + // more, so nothing here can point a hit effect at them; a future pass can rebuild that off + // FireControl-published state if the mine ever ships (R9 keeps it regardless). F12-6: BlastRange stays a + // presentation-side input -- Mine has no catalog weapon record carrying Fuse/BlastRadius. public void Explode() { var position = transform.position; - FireControl.Splash(Source.Entity.Zone, position.ToCultMath(), BlastRange, Damage, DamageType); + FireControl.Detonate(Source.Entity.Zone, position.ToCultMath().xz, BlastRange, Damage, DamageType); var ht = HitEffect.Instantiate(); ht.position = position; diff --git a/Assets/Scripts/Gameplay/Weapons/Projectile.cs b/Assets/Scripts/Gameplay/Weapons/Projectile.cs index dd83d0228..70b591649 100644 --- a/Assets/Scripts/Gameplay/Weapons/Projectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/Projectile.cs @@ -5,10 +5,10 @@ using static CultMath.math; // Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already // decided this shot's fate before this object was ever spawned (R8). What is left is pure flight-path // presentation: fly, and disappear at Range or ShotId's resolution, whichever comes first. -// Cut 6b, 6.2: the flak-round blast now resolves in the simulation (FireControl.Step calls Splash instead of -// Apply at arrival) -- a MonoBehaviour calling Splash would be exactly the authority Cut 3 spent itself -// deleting, so the blast-distance/blast-radius fields this object used to carry for that are gone. This -// object's job stays fly-and-disappear. +// Cut 6b, 6.2 (12.4(b): Apply's own switch on the frozen Fuse decides this now, not Step): the flak-round +// blast resolves in the simulation, through FireControl.Detonate -- a MonoBehaviour calling it would be +// exactly the authority Cut 3 spent itself deleting, so the blast-distance/blast-radius fields this object +// used to carry for that are gone. This object's job stays fly-and-disappear. public class Projectile : MonoBehaviour { public TrailRenderer Trail; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index f7e5ac14c..bccc990a3 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -364,8 +364,9 @@ public abstract class Entity // own INCOMING share -- not the post-clamp amount, the same convention ArmorAbsorb already uses -- and only // when that lane's own share is itself > 0, so a zero-deposit contributor (a lane whose armour ate its // whole share before reaching this item) never fires a phantom event. `item` is never null here: both - // callers (`Absorb`, below, and `FireControl.ApplyPooled`'s `Resolve`) already guarantee it -- a pool is - // only ever opened under a non-null `GearOccupancy` cell. + // callers (`FireControl.ApplyPooled`'s `Resolve`, one lane's worth of contributions included, and + // `FireControl.Detonate`'s own per-entity item-pool resolution, one covered cell's worth included) already + // guarantee it -- a pool is only ever opened under a non-null `GearOccupancy` cell. public void ItemAbsorb(EquippedItem item, Span incoming) { var total = 0f; @@ -383,22 +384,6 @@ public abstract class Entity } } - // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- armour up to its own value, then the - // occupying item, then whatever is left over is returned to the caller for the hull's own share (or the - // next cell down the lane; a lane that keeps carrying a remainder is not this function's concern -- - // FireControl.Apply's own pooled walk does that). This is the single-lane path: it feeds ItemAbsorb a - // pool of exactly one contribution, the same degenerate case Apply's own multi-lane pools reduce to when - // only one lane ever reaches an item. Splash is Absorb's only remaining caller (12.4 deletes Splash). - public float Absorb(int2 cell, float damage) - { - var d = ArmorAbsorb(cell, damage); - var item = GearOccupancy[cell.x, cell.y]; - if (item == null) return d; - Span incoming = stackalloc float[1] { d }; - ItemAbsorb(item, incoming); - return incoming[0]; - } - // Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one // HullDamage event are unchanged. FireControl.Apply calls this once per resolved hit, with the summed // remainder every lane's own walk left over (Q12-3 = A: a lane's remainder goes to the hull wherever the @@ -414,8 +399,8 @@ public abstract class Entity // Cut 12.1 (docs/fire-control-cut.md): the one schematic-frame owner. Maps a world-planar vector into this // entity's own schematic frame -- x = starboard, y = bow -- so FireControl.Apply's lane walk and - // FireControl.Splash's directional half both read the same transform instead of each carrying their own - // copy of forward/right. + // FireControl.Detonate's per-entity point conversion both read the same transform instead of each carrying + // their own copy of forward/right. public float2 ToSchematic(float2 worldPlanar) { var forward = normalize(Direction); @@ -423,6 +408,31 @@ public abstract class Entity return float2(dot(worldPlanar, right), dot(worldPlanar, forward)); } + // Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the world<->schematic POINT owners, built on + // ToSchematic's frame -- the entity's own position is the schematic's centre of mass. FireControl.Detonate + // uses ToSchematicPoint to find where a blast sits in each candidate's own schematic (the host of a contact + // or delayed blast included: it is treated like any other entity in the radius, its cells found again + // here, not carried over from the commit). ToWorldPoint is the inverse, used once, to convert a contact or + // delayed fuse point (found on the committed lane, in the host's schematic frame at commit) into the one + // world point Detonate's single input shape takes -- the round trip through the host's own pose at arrival + // is what fixes the host's damage to the commit (R4) while every bystander is judged live. + public float2 ToSchematicPoint(float2 worldPlanar) + { + var hullData = ItemManager.GetData(Hull) as HullData; + var cellSize = ItemManager.GameplaySettings.SchematicCellSize; + return ToSchematic(worldPlanar - Position.xz) / cellSize + hullData.Shape.CenterOfMass; + } + + public float2 ToWorldPoint(float2 schematicPoint) + { + var hullData = ItemManager.GetData(Hull) as HullData; + var cellSize = ItemManager.GameplaySettings.SchematicCellSize; + var local = (schematicPoint - hullData.Shape.CenterOfMass) * cellSize; + var forward = normalize(Direction); + var right = float2(forward.y, -forward.x); + return Position.xz + local.x * right + local.y * forward; + } + // Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null) // until this entity detects them, mirroring the existing detection model (VisibleEntities, driven // by EntityInfoGathered crossing TargetDetectionInfoThreshold). Detected but not yet hostility-rated diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 2c7fcbdba..c76ffcc19 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -461,11 +461,11 @@ public static class FireControl if (shot.Committed && now >= shot.ArrivalTime) { - // Cut 6b, 6.2 (renamed 12.4(a)): a shot with a frozen BlastRadius > 0 resolves as an area effect - // instead of a discrete hit -- Splash instead of Apply, never both, which is exactly the - // double-application Soul was told to hunt for. The fuse switch itself is 12.4(b)'s. - if (shot.BlastRadius > 0f) Splash(zone, shot.BurstPosition, shot.BlastRadius, shot.Damage, shot.DamageType); - else Apply(shot); + // 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. + Apply(shot); zone.ShotResolved.OnNext(shot.Outcome); shots.RemoveAt(i); } @@ -579,10 +579,17 @@ public static class FireControl ArrayPool.Shared.Return(buffer); } - var shield = shot.Target.Shield; - var shieldActive = shield != null && shield.Item.Active.Value; - if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true; - else if (shieldActive) shieldBroken = true; + // Q12-9 = A (docs/fire-control-cut.md): Commit decides no shield for a shot that carries a fuse -- + // Detonate decides every blast's shields live, at detonation, the host included, charged the + // entity's own covered share. A direct hit (Fuse == null) is unchanged: Commit still decides it here, + // on the whole frozen Damage. + if (shot.Fuse == null) + { + var shield = shot.Target.Shield; + var shieldActive = shield != null && shield.Item.Active.Value; + if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true; + else if (shieldActive) shieldBroken = true; + } } return MakeOutcome(shot, hit, shielded, shieldBroken, cell, bearing, lateral, now); @@ -632,7 +639,45 @@ public static class FireControl // membership rule, the identical shape of failure as 12.2's empty lane and 12.3's own double strike. There // is now one walk, `ApplyPooled`, in which every item pools the deposits of whatever lanes reach it, a // pool of one lane included -- a single-cell item is this rule's own degenerate case, not a fork. + // + // 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 the switch on the frozen shot.Fuse below. Step has no damage branch left -- every + // arrived shot, direct hit or blast, comes through here. A null fuse keeps 12.3's lane path unchanged; a + // proximity fuse detonates at arrival with no roll, whether the shot hit or missed + // (ProximityDetonatesEvenOnAGuaranteedMiss keeps today's AirburstSplashesEvenOnAGuaranteedMiss rule); a + // contact or delayed fuse detonates only on a committed hit, at a point 12.3's own Lane/Reach machinery + // locates. This is a default, not a ruling: a blast shot's DamageSpread is not read, and P sits on the + // centre lane (see the fuse-point comment on the Contact/Delayed branch below). private static void Apply(PendingShot shot) + { + switch (shot.Fuse) + { + case null: + ApplyDirectHit(shot); + return; + case WeaponFuse.Proximity: + // Detonates at shot.BurstPosition.xz, which PredictedIntercept froze at Fire (today's airburst + // rule, unchanged) -- no roll, and no gate on shot.Outcome.Hit. + Detonate(shot.Source.Zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); + return; + case WeaponFuse.Contact: + case WeaponFuse.Delayed: + ApplyBlastHit(shot); + return; + } + } + + // Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step + // march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A): + // 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the + // centre lane, at the committed Lateral, always does (Commit already proved it, R3). Each lane is Lane's + // own contiguous cell run from its own facing cell, clipped to the penetration depth by Reach (the impact + // cell is always reached, replacing the deleted `> .5f` threshold at zero cost). Whatever a lane does not + // spend -- penetration exhausted, a gap, or the far side -- goes to the hull where the lane ends (Q12-3 = + // A), summed across every lane into one DamageHull call. + // Cut 12.4(b): unchanged from 12.3 except that the inline penetration clip is now Reach, the shared owner + // the delayed fuse point also reads (see "The penetrator"). + private static void ApplyDirectHit(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -648,7 +693,8 @@ public static class FireControl var target = shot.Target; // Cut 12.3: moved here from Entity.ApplyHit's own first line -- the shield branches above still return - // before this runs, unchanged from 12.2. + // before this runs, unchanged from 12.2. Cut 12.4(b): fires only for a direct hit; a proximity burst + // and every bystander in any blast never fire it (AContactBlastReportsTheHit covers the blast half). target.IncomingHit.OnNext(shot.Source); // Cut 12.2 (R4): the committed geometry, frozen. Apply reads no position or facing of its own -- @@ -667,19 +713,10 @@ public static class FireControl buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers); - // Cut 12.3 fix batch ("one code path"): the ONLY penetration derivation in this whole walk -- clip each - // lane's own cell run to its penetration depth right here, once, immediately after Lanes returns. - // Everything downstream reads `walked` as the lane's entire reach; nothing below ever re-tests a cell's - // entry against penetration again, and there is no separate survey of which cells or items a lane can - // reach -- ApplyPooled's own walk discovers that live, from `walked` alone. + // Cut 12.4(b) ("The penetrator", Q12-8): Reach is now the one penetration-reach owner, shared with the + // delayed fuse point below -- no second reach derivation exists anywhere in this file. for (var li = 0; li < laneCount; li++) - { - if (walked[li] == 0) continue; - var impactEntry = buffers[li][0].Entry; - var clipped = 1; - while (clipped < walked[li] && buffers[li][clipped].Entry - impactEntry < shot.Penetration) clipped++; - walked[li] = clipped; - } + walked[li] = Reach(buffers[li], walked[li], shot.Penetration); var metalLanes = 0; for (var li = 0; li < laneCount; li++) @@ -696,6 +733,66 @@ public static class FireControl target.DamageHull(hullDamage); } + // Cut 12.4(b) (docs/fire-control-cut.md, "Where P is"): a contact or delayed blast needs a committed hit -- + // on a miss nothing happens, exactly like a direct hit's own gate. IncomingHit fires once, for the host, + // before the blast is handed to Detonate (which then treats the host like any other entity in the radius: + // Detonate never reads a host parameter, and finds the host's own cells again by ToSchematicPoint). + private static void ApplyBlastHit(PendingShot shot) + { + if (!shot.Outcome.Hit) return; + + var target = shot.Target; + target.IncomingHit.OnNext(shot.Source); + + // "Where P is": the lane's own parametrisation gives the point directly, point(t) = t*b + s*ell, on the + // committed lane (the committed Bearing and Lateral, which Commit's own guard already proved non-empty, + // R3). Contact: t is the impact cell's own entry, lane[0].Entry. Delayed: t_fuse is where the frozen + // Penetration runs out or the metal does, whichever comes first -- Reach (the same owner the direct-hit + // walk reads) decides how deep the lane reaches, and t_fuse clamps to the last reached cell's exit so + // the disc is always centred on metal. + var hullData = target.ItemManager.GetData(target.Hull) as HullData; + var bearing = shot.Outcome.Bearing; + var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + float2 schematicPoint; + try + { + var walked = Lane(hullData, bearing, shot.Outcome.Lateral, buffer); + var t = buffer[0].Entry; + if (shot.Fuse == WeaponFuse.Delayed) + { + var reach = Reach(buffer, walked, shot.Penetration); + t = min(buffer[0].Entry + shot.Penetration, buffer[reach - 1].Exit); + } + schematicPoint = t * bearing + shot.Outcome.Lateral * Lateral(bearing); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + // "Conversion to the world": the round trip through the host's own ToWorldPoint/ToSchematicPoint + // cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while + // every bystander is judged against where it actually is at arrival -- the same live rule a proximity + // burst already applies to everyone. + var worldPoint = target.ToWorldPoint(schematicPoint); + Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType); + } + + // Cut 12.4(b) (docs/fire-control-cut.md, "The penetrator", Q12-8): the one penetration-reach owner, moved + // verbatim from Apply's own inline clip (Cut 12.3 fix batch) so the delayed fuse point above can read the + // identical rule the direct-hit walk already used. The rule is unchanged: the impact cell is always + // reached, and a later cell is reached while entry - impact.entry < penetration. Two callers, and no third: + // a null-fuse shot spends damage along the walk (ApplyDirectHit), a contact-or-delayed shot only locates P + // (ApplyBlastHit) -- no shot ever takes both branches of Apply's switch. + private static int Reach(LaneCell[] cells, int walked, float penetration) + { + if (walked == 0) return 0; + var impactEntry = cells[0].Entry; + var reach = 1; + while (reach < walked && cells[reach].Entry - impactEntry < penetration) reach++; + return reach; + } + // Cut 12.3 fix batch (2026-09-25, operator rulings "proportional absorption; go" and "one code path" -- // docs/fire-control-cut.md, Cut 12.3 status): every lane walks its own already-clipped cells near to far. // Armour is never shared between lanes (Lanes' own disjointness rule), so Entity.ArmorAbsorb runs the @@ -947,56 +1044,201 @@ public static class FireControl }; } - // Cut 4 (docs/fire-control-cut.md): the one splash rule -- an unconditional area effect, not a rolled - // shot. Nothing here draws: a mine or an airburst round always damages everything it catches, the same - // as the Physics.OverlapSphere queries this replaces always did. Applies the shield-absorb-or-hull-takes- - // it branch (F5, docs/stats-and-power-cut.md) per target, then -- for a target the shield didn't fully - // absorb -- an even split (still today's rule; 12.3 only retired the direct-hit lane's even split, not - // Splash's) over the directional half of that target's own hull that faces the blast, the rule moved - // verbatim from the Splash subscription EntityInstance.cs carried before Cut 3 deleted it (git show - // b7743789^:Assets/Scripts/Gameplay/EntityInstance.cs), made planar (R7): the blast-to-target direction is - // measured in the zone's (x,z) plane and rotated into each target's own facing by its Direction, the same - // rotation Entity's own schematic frame (ToSchematic) uses. - // Cut 12.3: Entity.DamageSchematic is deleted with the direct-hit march it shared a file with -- Splash - // now calls Entity.Absorb per footprint cell directly and sums the leftover into one DamageHull call, - // arithmetically identical to DamageSchematic's own body (same per-cell split, same order, same - // thresholds). Untouched otherwise; deleted in 12.4 along with the rest of the ray model. - public static void Splash(Zone zone, float3 position, float radius, float damage, DamageType damageType) + // Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the one owner of blast damage -- a blast is + // an area, not a bundle of rays (Q12-7 dissolved the ray model's remainder question along with the rays + // themselves). Splash's signature with the point made planar (R7). Callers: Apply's three fuse branches and + // Mine.Explode. Nothing draws: every candidate entity in radius takes its share, unconditionally. + // + // For each candidate (an entity whose centre lies within radius plus half its own hull diagonal -- + // CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius, which Splash's centre-distance cull could not pass): + // take Ps = ToSchematicPoint(P) and rCells = radius / SchematicCellSize once, then for every occupied hull + // cell whose unit square meets the disc, share_c = damage * overlap(c) / (pi * rCells^2) -- the exact area + // of the disc inside that cell's unit square, over the disc's own area. Every share that falls on an + // unoccupied cell, or outside the schematic, is lost (TheShareOffTheHullIsLost); nothing renormalises over + // the covered cells. + // + // Every shield decision for a blast is made here, live, charged the entity's summed shares under today's + // CanTakeHit/Break/TakeHit rules (Q12-9 = A: the host of a contact or delayed blast decides its shield here + // too, never at Commit). If the shield absorbs, no cell of that entity takes anything; if it cannot, it + // breaks and the cells take their shares. + // + // Armour protects only its own cell's share (ArmorProtectsOnlyItsOwnCellsShare) -- "armour absorbs first" + // runs per cell, in hull Coordinates order. Only once every covered cell has deposited does an item resolve + // its own pool, once (AMultiCellItemAbsorbsItsCoveredCellsAsOnePool): no per-cell item threshold survives + // anywhere, because a disc's schedule has no walk -- no cell's share depends on another + // cell's leftover, so every deposit is known before any item resolves and there is nothing for a + // reachability test or a cycle rule to decide (unlike ApplyPooled's lane walk, which this function does not + // call: doing so would buy no behaviour, per ProbeC in the cut doc). Leftovers -- unoccupied-cell shares, + // and whatever an item's pool does not absorb -- join one entity-wide hull total, applied through one + // DamageHull call. + public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType) { - foreach (var target in zone.Entities) - { - var toTarget = (target.Position - position).xz; - if (length(toTarget) > radius) continue; + // F3 (Soul, Cut 12.3 fold-in): Detonate cannot be reached with a nonpositive radius. Fire only ever + // freezes a Fuse when BlastRadius > 0 (12.4(a)), so this guard matters only for a direct caller (a + // test, or Mine.Explode with a zero BlastRange). + if (radius <= 0f) return; - var shield = target.Shield; - var shieldActive = shield != null && shield.Item.Active.Value; - var shieldAbsorbs = shieldActive && shield.CanTakeHit(damageType, damage); - if (shieldActive && !shieldAbsorbs) shield.Break(); - if (shieldAbsorbs) + foreach (var entity in zone.Entities) + { + var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; + var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; + var shape = hullData.Shape; + + // Candidates: |Position.xz - P| <= radius + half the hull's own diagonal, in world units -- catches + // a long hull whose end lies inside the blast even though its centre does not. + var toEntity = length(entity.Position.xz - worldPlanar); + var halfDiagonal = .5f * length(float2(shape.Width, shape.Height)) * cellSize; + if (toEntity > radius + halfDiagonal) continue; + + var centre = entity.ToSchematicPoint(worldPlanar); + var rCells = radius / cellSize; + var normaliser = PI * rCells * rCells; + + // Shares, computed once, in hull Coordinates order -- the same order armour absorbs in below and + // an item's pool deposits in. + var coords = shape.Coordinates; + var covered = new List<(int2 Cell, float Share)>(); + var totalShare = 0f; + for (var i = 0; i < coords.Length; i++) { - shield.TakeHit(damageType, damage); - continue; + var overlap = CircleSquareOverlap(centre, rCells, coords[i]); + if (overlap <= 0f) continue; + var share = damage * overlap / normaliser; + covered.Add((coords[i], share)); + totalShare += share; + } + if (covered.Count == 0) continue; + + var shield = entity.Shield; + var shieldActive = shield != null && shield.Item.Active.Value; + if (shieldActive) + { + if (shield.CanTakeHit(damageType, totalShare)) + { + shield.TakeHit(damageType, totalShare); + continue; + } + shield.Break(); } - var hullData = target.ItemManager.GetData(target.Hull) as HullData; - var localDirection = lengthsq(toTarget) > 1e-6f - ? normalize(target.ToSchematic(toTarget)) - : float2(0, 1); + // Armour first, per cell; an item's covered cells pool their post-armour deposits and resolve once, + // from the pooled sum -- ItemAbsorb's own degenerate case for a pool of one covers a single-cell + // item, exactly as it does in ApplyPooled. + Dictionary> itemPools = null; + var hullTotal = 0f; + foreach (var (cell, share) in covered) + { + var postArmor = entity.ArmorAbsorb(cell, share); + var item = entity.GearOccupancy[cell.x, cell.y]; + if (item == null) { hullTotal += postArmor; continue; } + itemPools ??= new Dictionary>(); + if (!itemPools.TryGetValue(item, out var pool)) itemPools[item] = pool = new List(); + pool.Add(postArmor); + } - var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); - foreach (var v in hullData.Shape.Coordinates) - if (dot(normalize((float2) v - hullData.Shape.CenterOfMass), localDirection) < 0) - hitShape[v] = true; + if (itemPools != null) + foreach (var (item, pool) in itemPools) + { + var amounts = pool.ToArray(); + entity.ItemAbsorb(item, amounts); + for (var i = 0; i < amounts.Length; i++) hullTotal += amounts[i]; + } - var cells = hitShape.Coordinates; - var damagePerCell = damage / cells.Length; - var hullDamage = 0f; - foreach (var v in cells) - hullDamage += target.Absorb(v, damagePerCell); - target.DamageHull(hullDamage); + entity.DamageHull(hullTotal); } } + // Cut 12.4(b) (docs/fire-control-cut.md, "Exact overlap, in float"): the exact area of a unit square, + // centred on `cell` (the unit square centred on its integer coordinate, the same convention every other + // frame rule uses), that lies inside a disc of radius `r` centred at `diskCentre` -- both in the same + // (schematic-cell) units. Allocates nothing. + private static float CircleSquareOverlap(float2 diskCentre, float r, int2 cell) + { + var xlo = cell.x - .5f - diskCentre.x; + var xhi = cell.x + .5f - diskCentre.x; + var ylo = cell.y - .5f - diskCentre.y; + var yhi = cell.y + .5f - diskCentre.y; + return RectDiskOverlap(xlo, xhi, ylo, yhi, r); + } + + // The area of intersection of a disc of radius r centred at the origin with the axis-aligned rectangle + // [xlo,xhi] x [ylo,yhi]. Integrates the disc's chord height over the rectangle's x-span: for a fixed x, the + // disc spans y in [-h,h] with h = sqrt(r^2 - x^2), and the covered length there is + // min(yhi,h) - max(ylo,-h), clamped to zero. That covered length changes formula only where h(x) crosses + // ylo or yhi (or at the rectangle's own edges), so the span is cut at those breakpoints and each piece is + // integrated in closed form with AntiderivSqrt, the antiderivative of sqrt(r^2 - x^2). + private static float RectDiskOverlap(float xlo, float xhi, float ylo, float yhi, float r) + { + if (r <= 0f) return 0f; + xlo = max(xlo, -r); + xhi = min(xhi, r); + if (xlo >= xhi) return 0f; + + Span bp = stackalloc float[6]; + var n = 0; + bp[n++] = xlo; + bp[n++] = xhi; + + // The breakpoints where h(x) = sqrt(r^2 - x^2) crosses ylo or yhi -- the only places the covered + // length's formula (below) can change. No local function: a stackalloc Span cannot be captured by one. + Span ys = stackalloc float[2] { ylo, yhi }; + for (var k = 0; k < 2; k++) + { + var y = ys[k]; + if (abs(y) >= r) continue; + var xc = sqrt(r * r - y * y); + if (-xc > xlo && -xc < xhi) bp[n++] = -xc; + if (xc > xlo && xc < xhi) bp[n++] = xc; + } + + // Insertion sort: n is at most 6. + for (var i = 1; i < n; i++) + { + var key = bp[i]; + var j = i - 1; + while (j >= 0 && bp[j] > key) { bp[j + 1] = bp[j]; j--; } + bp[j + 1] = key; + } + + var area = 0f; + for (var i = 0; i < n - 1; i++) + { + var a = bp[i]; + var b = bp[i + 1]; + if (b - a < 1e-9f) continue; + + var mid = .5f * (a + b); + var h = sqrt(max(0f, r * r - mid * mid)); + var upperIsDisc = h < yhi; + var lowerIsDisc = -h > ylo; + var upper = upperIsDisc ? h : yhi; + var lower = lowerIsDisc ? -h : ylo; + if (upper <= lower) continue; + + float piece; + if (upperIsDisc && lowerIsDisc) + piece = 2f * (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)); + else if (upperIsDisc) + piece = (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)) - ylo * (b - a); + else if (lowerIsDisc) + piece = yhi * (b - a) + (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)); + else + piece = (yhi - ylo) * (b - a); + + area += piece; + } + return max(0f, area); + } + + // The antiderivative of sqrt(r^2 - x^2): x/2 * sqrt(r^2 - x^2) + r^2/2 * asin(x/r). Clamped into [-r,r] + // first -- every caller here already clips its integration bounds into that range, so this is a guard + // against float rounding at the boundary, not a second clipping rule. + private static float AntiderivSqrt(float x, float r) + { + var xc = clamp(x, -r, r); + return .5f * (xc * sqrt(max(0f, r * r - xc * xc)) + r * r * asin(xc / r)); + } + // Cut 12.2 (docs/fire-control-cut.md): Sigma keeps its Cut 6d/9.2 rule unchanged -- the frozen Precision's // reciprocal, in hull-schematic cell units, floored at half a cell. The 9.2 floor's old rationale ("the // discrete sum undershoots the continuous integral") is retired: Silhouette below integrates the Gaussian @@ -1202,7 +1444,7 @@ public static class FireControl } // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source - // of truth for where an equipped item's footprint lands, the same table Entity.Absorb reads. + // of truth for where an equipped item's footprint lands, the same table Detonate and ApplyPooled read. private static int2[] CellsOf(Entity target, EquippedItem item) { var hullData = target.ItemManager.GetData(target.Hull) as HullData; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 27c647d83..59d93c1ba 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -491,9 +491,9 @@ public class WeaponItemData : GearData public WeaponModifiers WeaponModifiers; // Cut 12.4(a) (docs/fire-control-cut.md): the authored blast radius a weapon's shot detonates with, in - // world units. Renamed from AirburstRange -- the slot and type are unchanged, so CultCache opens the - // catalog as compatible drift. Null (every shipped record) means no blast; WeaponModifiers no longer - // decides this (Q12-6, "WeaponModifiers are labels, not behaviour"). + // world units. Renamed from slot 29's old airburst-range field -- the slot and type are unchanged, so + // CultCache opens the catalog as compatible drift. Null (every shipped record) means no blast; WeaponModifiers + // no longer decides this (Q12-6, "WeaponModifiers are labels, not behaviour"). [Inspectable, JsonProperty("blastRadius"), Key(29)] public float? BlastRadius; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index e46c1a86e..2a65fda64 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -231,10 +231,14 @@ public sealed class FireControlCut11Tests : IDisposable Assert.Equal(hull, e.Target.Hull.Durability); // and every hit was absorbed } - // The same rule on Splash's path, whose absorb branch had no test coverage at all. Mutation: delete - // TakeHit from Splash's absorb branch. + // The same rule on a blast's path (12.4(b) rewrites this to Detonate, docs/fire-control-cut.md + // "ShieldPaysForWhatReachesIt": "the reserve drops by the entity's covered share, not by the whole + // blast"), whose absorb branch had no test coverage at all. The radius sits well inside the hull's own + // solid interior (the 5x3 shape's centre of mass, at least 1 cell from every edge) so the disc never spills + // off the hull -- the covered share equals the whole damage exactly, the same amount Splash always charged, + // so this expectation is not loosened. Mutation: delete TakeHit from Detonate's absorb branch. [Fact] - public void SplashShieldAbsorptionDrainsTheReserve() + public void ABlastShieldAbsorptionDrainsTheReserve() { var e = Build(equipShield: true, shieldCapacity: 30); Charge(e); @@ -244,7 +248,7 @@ public sealed class FireControlCut11Tests : IDisposable var blasts = 0; while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && blasts < 200) { - FireControl.Splash(e.Zone, e.Target.Position, radius: 20, damage: 5, damageType: DamageType.Kinetic); + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 5, damageType: DamageType.Kinetic); blasts++; } @@ -354,40 +358,11 @@ public sealed class FireControlCut11Tests : IDisposable } // ---- Splash damages the side the blast came from. ---- - - // The operator's "damage on the wrong side of the ship" was a legacy Unity bug that Splash never shipped, - // but nothing defended against it. Flipping the sign in Splash's - // `right = float2(forward.y, -forward.x)` survived every earlier suite, and it survives this test too at - // the default facing (0, 1): the flip only changes which side is hit when a ship faces more along world x - // than z. So the target is spun through facings where the flip bites as well as ones where it does not, - // and blasted from each side in turn. The blast side is computed here independently of FireControl -- - // starboard is the facing rotated clockwise -- so this is not the code checking itself. - [Theory] - [InlineData(0f, 1f)] - [InlineData(1f, 0f)] - [InlineData(.8f, .6f)] - [InlineData(-.8f, -.6f)] - [InlineData(-.6f, .8f)] - public void SplashDamagesTheSideTheBlastCameFrom(float fx, float fy) - { - foreach (var fromStarboard in new[] { true, false }) - { - var e = Build(markers: true); - var facing = normalize(float2(fx, fy)); - e.Target.Direction = facing; - var starboard = float2(facing.y, -facing.x); - var side = fromStarboard ? starboard : -starboard; - var blast = e.Target.Position + float3(side.x, 0, side.y) * 10f; - - FireControl.Splash(e.Zone, blast, radius: 20, damage: 10, damageType: DamageType.Kinetic); - - var nearMarker = fromStarboard ? e.StarboardMarker : e.PortMarker; - var farMarker = fromStarboard ? e.PortMarker : e.StarboardMarker; - var label = $"facing ({fx}, {fy}), blast from {(fromStarboard ? "starboard" : "port")}"; - Assert.True(nearMarker.EquippableItem.Durability < 100, $"{label}: the marker facing the blast took no damage"); - Assert.Equal(100f, farMarker.EquippableItem.Durability); - } - } + // + // Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in + // FireControlCut124Tests.cs succeeds this test and Cut4Tests.SplashIsDirectional -- a blast is now an area + // (12.4(b)), not a directional half-hull split, so "the side the blast came from" is pinned by which cells + // the disc actually covers, not by a facing-rotated half-shape. // ---- pSpread is the share of the spread cone the target's silhouette covers. ---- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 90f5891c1..b01d04b76 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -493,48 +493,45 @@ public sealed class FireControlCut123Tests : IDisposable Assert.Equal(hullBefore - 40f, e.Target.Hull.Durability, 2); } - // Stryker survivor triage (this cut's own changed lines): Entity.Absorb's `d > 0f` guard boundary-flipped to - // `d >= 0f` survived -- at exactly 0 incoming, real code emits nothing ("only for incoming > 0"), the mutant - // would additionally fire ArmorDamage(cell, 0). Not a rare float coincidence: a spent lane (rem already 0) - // walks past every remaining cell in FireControl.Apply's own loop, so this is the ordinary "nothing left" - // case, not an edge case. + // Stryker survivor triage (this cut's own changed lines), retargeted to the public owner after 12.4(b) + // deletes Entity.Absorb (docs/fire-control-cut.md, "Retargeted, because Absorb is deleted"): + // ArmorAbsorb's `d > 0f` guard boundary-flipped to `d >= 0f` survived -- at exactly 0 incoming, real code + // emits nothing ("only for incoming > 0"), the mutant would additionally fire ArmorDamage(cell, 0). Not a + // rare float coincidence: a spent lane (rem already 0) walks past every remaining cell in + // FireControl.Apply's own loop, so this is the ordinary "nothing left" case, not an edge case. [Fact] - public void AbsorbEmitsNothingAtExactlyZeroIncomingDamage() + public void ArmorAbsorbEmitsNothingAtExactlyZero() { var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); e.Target.Armor[1, 1] = 5f; e.Target.MaxArmor[1, 1] = 5f; - var marker = e.Markers[0]; var armorEvents = 0; - var itemEvents = 0; using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); - using var i = e.Target.ItemDamage.Subscribe(_ => itemEvents++); - var remainder = e.Target.Absorb(new int2(1, 1), 0f); + var remainder = e.Target.ArmorAbsorb(new int2(1, 1), 0f); Assert.Equal(0f, remainder); Assert.Equal(0, armorEvents); - Assert.Equal(0, itemEvents); Assert.Equal(5f, e.Target.Armor[1, 1]); - Assert.Equal(50f, marker.EquippableItem.Durability); } - // Stryker survivor: Entity.Absorb's `d > 0.1f` item-phase guard boundary-flipped to `d >= 0.1f` survived -- - // at exactly 0.1f remaining after armor, real code leaves the item untouched (the remainder returns to the - // caller instead), the mutant would additionally spend it on the item. + // Stryker survivor, retargeted to the public owner after 12.4(b) deletes Entity.Absorb: ItemAbsorb's + // `total <= 0.1f` guard survived at exactly 0.1f -- real code leaves the item untouched, the mutant would + // additionally spend it on the item. A spent lane still walks cells with a remainder of 0, so this is + // pinned as a pool of exactly one contribution, ItemAbsorb's own degenerate case. [Fact] - public void AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining() + public void ItemAbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining() { var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); - e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; // bare, so the whole .1f reaches the item check var marker = e.Markers[0]; var itemEvents = 0; using var i = e.Target.ItemDamage.Subscribe(_ => itemEvents++); - var remainder = e.Target.Absorb(new int2(1, 1), 0.1f); + Span incoming = stackalloc float[1] { .1f }; + e.Target.ItemAbsorb(marker, incoming); - Assert.Equal(0.1f, remainder, 3); + Assert.Equal(0.1f, incoming[0], 3); Assert.Equal(0, itemEvents); Assert.Equal(50f, marker.EquippableItem.Durability); } @@ -742,35 +739,10 @@ public sealed class FireControlCut123Tests : IDisposable Assert.Equal(12f, hullThisAttempt, 2); } - // F6: armour and an occupying item on the SAME cell -- no existing fixture combines them, so "armour - // absorbs first" (Entity.Absorb) was only ever exercised with one or the other. Direct calls to Absorb, - // matching this file's own existing style for pinning its per-cell arithmetic (AbsorbEmitsNothingAt..., - // AbsorbLeavesTheItemUntouchedAt...). Kills a swap of the armour and item blocks in Absorb. - [Fact] - public void AbsorbSpendsArmourBeforeTheItemOnTheSameCell() - { - var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); - var marker = e.Markers[0]; - void Reset() { e.Target.Armor[1, 1] = 10f; e.Target.MaxArmor[1, 1] = 10f; marker.EquippableItem.Durability = 50f; } - - Reset(); - var lessThanArmour = e.Target.Absorb(new int2(1, 1), 5f); - Assert.Equal(0f, lessThanArmour); - Assert.Equal(5f, e.Target.Armor[1, 1]); // 5 of the 10 spent - Assert.Equal(50f, marker.EquippableItem.Durability); // item untouched -- armour alone covered it - - Reset(); - var moreThanArmourLessThanBoth = e.Target.Absorb(new int2(1, 1), 25f); - Assert.Equal(0f, moreThanArmourLessThanBoth); - Assert.Equal(0f, e.Target.Armor[1, 1]); // the whole 10 spent - Assert.Equal(35f, marker.EquippableItem.Durability, 2); // exactly the 15 excess - - Reset(); - var moreThanBoth = e.Target.Absorb(new int2(1, 1), 80f); - Assert.Equal(20f, moreThanBoth, 2); // exactly 80 - 10 - 50 - Assert.Equal(0f, e.Target.Armor[1, 1]); - Assert.Equal(0f, marker.EquippableItem.Durability); - } + // F6/12.4(b): "armour absorbs first" on one cell shared by armour and an occupying item is now pinned on + // the public owner, Detonate, by ABlastIsAbsorbedThroughArmourFirstPerCell in FireControlCut124Tests.cs -- + // this superseded AbsorbSpendsArmourBeforeTheItemOnTheSameCell, which called the now-deleted Entity.Absorb + // directly (docs/fire-control-cut.md, "Absorption order"). // F7: direct-hit frame handedness at |fx| > |fy| is otherwise only guarded by Cut 11's Splash tests, which // 12.4 deletes. A target facing (2,1) normalized (|fx| > |fy|), shot from its own starboard side (computed diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index 2fbc5e52d..5ee04751a 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -247,9 +247,10 @@ public sealed class FireControlCut4Tests : IDisposable Assert.Equal(1, resolvedCount); } - // ---- Cut 4's splash rule: FireControl.Splash is an unconditional area effect (no roll), directional per - // target's own hull facing. Tested directly against the function, no weapon/gear catalog needed -- Entity. - // MapEntity (called from the Entity constructor) is enough to give a Ship its Armor/GearOccupancy/Hull. ---- + // ---- Cut 4's blast rule (12.4(b) renames FireControl.Splash to Detonate, and areas replace the + // directional half-hull split): FireControl.Detonate is an unconditional area effect, no roll. Tested + // directly against the function, no weapon/gear catalog needed -- Entity.MapEntity (called from the Entity + // constructor) is enough to give a Ship its Armor/GearOccupancy/Hull. ---- private ProvenanceLedger _splashLedger; @@ -279,10 +280,10 @@ public sealed class FireControlCut4Tests : IDisposable return ship; } - // Splash is one rule, not per-effect: every entity within radius takes damage, one outside radius takes + // A blast is one rule, not per-effect: every entity within radius takes damage, one outside radius takes // none. Mutation: damage only the nearest entity in range instead of every one. [Fact] - public void SplashHitsEveryEntityInRadius() + public void ABlastHitsEveryEntityItCovers() { var (items, zone) = BuildSplashZone(TestSettings()); var near = AddShip(items, zone, float3(10, 0, 0), float2(0, 1), 1); @@ -293,39 +294,15 @@ public sealed class FireControlCut4Tests : IDisposable var farBefore = far.Hull.Durability; var outBefore = outOfRange.Hull.Durability; - FireControl.Splash(zone, float3.zero, radius: 30, damage: 50, damageType: DamageType.Kinetic); + FireControl.Detonate(zone, float2.zero, radius: 30, damage: 50, damageType: DamageType.Kinetic); Assert.True(near.Hull.Durability < nearBefore); Assert.True(far.Hull.Durability < farBefore); Assert.Equal(outBefore, outOfRange.Hull.Durability); } - // Splash is directional over each target's own hull: cells facing the blast take it, cells facing away do - // not. Mutation: damage the whole shape regardless of direction. - [Fact] - public void SplashIsDirectional() - { - // Nonzero armor so an undamaged cell is distinguishable from a damaged one -- with armor 0 every cell - // starts and stays at 0 regardless of which half took the schematic hit. - var (items, zone) = BuildSplashZone(TestSettings(), armor: 10); - // Facing +Z (the default); the blast sits behind the ship along -Z, so the near half of the hull - // (negative-Z cells, closest to the blast) should take damage and the far half (positive-Z) should not. - var ship = AddShip(items, zone, float3(0, 0, 10), float2(0, 1), 1); - - FireControl.Splash(zone, float3(0, 0, 0), radius: 50, damage: 1000, damageType: DamageType.Kinetic); - - var hullData = (HullData) items.GetData(ship.Hull); - var center = hullData.Shape.CenterOfMass; - var nearCellDamaged = false; - var farCellUntouched = false; - foreach (var v in hullData.Shape.Coordinates) - { - var offset = (float2) v - center; - if (offset.y < -.5f && ship.Armor[v.x, v.y] < ship.MaxArmor[v.x, v.y]) nearCellDamaged = true; - if (offset.y > .5f && ship.Armor[v.x, v.y] >= ship.MaxArmor[v.x, v.y]) farCellUntouched = true; - } - - Assert.True(nearCellDamaged); - Assert.True(farCellUntouched); - } + // Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in + // FireControlCut124Tests.cs succeeds this test (SplashIsDirectional) and Cut11Tests' + // SplashDamagesTheSideTheBlastCameFrom -- a blast is now an area (12.4(b)), not a directional half-hull + // split, so "which cells take it" is pinned by the disc's own coverage, not a facing-rotated half-shape. } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index 48dccca21..525a24ce6 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -499,12 +499,16 @@ public sealed class FireControlCut5Tests : IDisposable Assert.Equal(0, resolvedCount); } - // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification): FireControl.Splash's shield - // branch must actually break an unabsorbed shield, not merely route the remainder to the hull. Mutation: - // delete `shield.Break()` from Splash -- the target's hull still takes the blast (SplashHitsEveryEntityInRadius - // stays green), but the shield itself never registers the overwhelming hit and keeps absorbing on schedule. + // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification), rewritten to Detonate (12.4(b), + // "Retargeted, because Absorb is deleted" / Q12-9 = A): the blast's shield branch must actually break an + // unabsorbed shield, not merely route the remainder to the hull. A radius this much larger than the hull + // covers essentially the whole disc's damage on the target's own cells, overwhelming a 5-capacity reserve + // however the exact per-cell shares fall -- the expected amount is not loosened, it was never asserted + // precisely here (only that the shield breaks). Mutation: delete `shield.Break()` from Detonate -- the + // target's hull still takes the blast (ABlastHitsEveryEntityItCovers stays green), but the shield itself + // never registers the overwhelming hit and keeps absorbing on schedule. [Fact] - public void SplashBreaksUnabsorbedShield() + public void ABlastBreaksUnabsorbedShield() { var e = Build(TestSettings(), damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0, equipShield: true, shieldCapacity: 5); @@ -512,7 +516,10 @@ public sealed class FireControlCut5Tests : IDisposable Assert.False(e.Target.Shield.Broken); Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 500)); - FireControl.Splash(e.Zone, e.Target.Position, radius: 50, damage: 500, damageType: DamageType.Kinetic); + // radius 1, centred on the target's own position (its hull's centre of mass): well inside the 5x5 + // solid hull's interior, so the disc never spills off the hull and the covered share equals the whole + // damage exactly, the same amount Splash always charged. + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 500, damageType: DamageType.Kinetic); Assert.True(e.Target.Shield.Broken); } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 1f85e2381..788655802 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -235,15 +235,16 @@ public sealed class FireControlCut6Tests : IDisposable // ---- 6.2: airburst resolves in the simulation (Soul finding 5). ---- - // An airburst shot resolves via Splash, and Splash does not gate on the discrete hit roll at all -- it - // damages everything in radius unconditionally (Cut 4's rule). Accuracy 0 pins PBase (and so Commit's hit) - // to false with no dependence on the random draw, so a normal discrete shot would apply no damage - // whatsoever (Apply's own `if (!shot.Outcome.Hit) return;`). An airburst shot must still deal its area - // damage. Mutation: call Apply as well as Splash -- harmless here (Apply still no-ops on a miss), so this - // test alone does not catch that mutation; AirburstAndDiscreteNeverDoubleUp below does, with a guaranteed - // hit where an extra Apply call is not a no-op. + // A proximity-fuse shot detonates unconditionally, at arrival, with no roll (12.4(b)'s "Which model a shot + // uses") -- it damages everything in radius regardless of the discrete hit roll (Cut 4's rule, kept). + // Accuracy 0 pins PBase (and so Commit's hit) to false with no dependence on the random draw, so a direct + // hit would apply no damage whatsoever (Apply's own gate on shot.Outcome.Hit). A proximity shot must still + // deal its area damage. Mutation: route a proximity fuse through the direct-hit branch instead -- harmless + // here (that branch still no-ops on a miss), so this test alone does not catch that mutation; + // AirburstAndDiscreteNeverDoubleUp below does, with a guaranteed hit where an extra direct-hit application + // is not a no-op. [Fact] - public void AirburstSplashesEvenOnAGuaranteedMiss() + public void ProximityDetonatesEvenOnAGuaranteedMiss() { var settings = TestSettings(); var e = Build(settings, damage: 500, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000, @@ -256,45 +257,48 @@ public sealed class FireControlCut6Tests : IDisposable Assert.True(e.Target.Hull.Durability < before, "An airburst shot must splash regardless of the discrete hit roll."); } - // The double-application Soul was told to hunt for: with a guaranteed hit (accuracy 1), an airburst shot's - // total damage must equal a bare Splash call's own damage -- not more. Mutation: call Apply as well as - // Splash at arrival -- Apply's own discrete cell hit would land on top of Splash's area damage, so the - // airburst shot's total damage exceeds a lone Splash call's. + // The double-application Soul was told to hunt for: with a guaranteed hit (accuracy 1), a proximity shot's + // total damage must equal a bare Detonate call's own damage -- not more. Mutation: route the shot through + // the direct-hit branch as well as Detonate at arrival -- the direct-hit cell would land on top of + // Detonate's area damage, so the shot's total damage exceeds a lone Detonate call's. [Fact] public void AirburstAndDiscreteNeverDoubleUp() { var settings = TestSettings(); - var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, + // 12.4(b)'s area model spreads damage over pi*r^2, so a radius this much larger than the 5x5 target + // needs enough damage that the covered share still clears DamageHull's .1f threshold (matching + // ProximityDetonatesEvenOnAGuaranteedMiss's own damage, above). + var e = Build(settings, damage: 500, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); var before = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); var actualDelta = before - e.Target.Hull.Durability; - // A fresh, identically-shaped target, damaged by one bare Splash call with the same parameters + // A fresh, identically-shaped target, damaged by one bare Detonate call with the same parameters // FireControl.Fire freezes for this weapon (BurstPosition, for a stationary velocity-0 target, is // exactly its own position -- PredictedIntercept falls back to target.Position below weapon.Velocity's // .01f floor). - var e2 = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, + var e2 = Build(settings, damage: 500, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); var before2 = e2.Target.Hull.Durability; - FireControl.Splash(e2.Zone, e2.Target.Position, 100, 10, DamageType.Kinetic); + FireControl.Detonate(e2.Zone, e2.Target.Position.xz, 100, 500, DamageType.Kinetic); var expectedDelta = before2 - e2.Target.Hull.Durability; Assert.True(expectedDelta > 0); Assert.Equal(expectedDelta, actualDelta, 3); } - // The other side: a shot with no blast must never splash, even one that lands exactly on its own frozen - // burst position at zero range (a stationary target, so BurstPosition == the target's own position at - // arrival) -- the zero-radius edge case a careless "splash unconditionally" mutant would still pass - // through, since distance 0 is never greater than radius 0. Accuracy 0 pins the discrete roll to a - // guaranteed miss, so the correct path (Apply, gated on Outcome.Hit) deals no damage at all. Mutation: - // splash unconditionally at arrival instead of gating on BlastRadius -- Splash does not check Hit, so it - // would still deal damage even though this shot never froze a blast. + // The other side: a shot with no fuse must never detonate, even one whose frozen burst position (unread, + // for a null-fuse shot) would land exactly at zero range on a stationary target -- the zero-radius edge + // case a careless "detonate unconditionally" mutant would still pass through, since distance 0 is never + // greater than radius 0. Accuracy 0 pins the discrete roll to a guaranteed miss, so the correct path + // (Apply's null-fuse branch, gated on Outcome.Hit) deals no damage at all. Mutation: detonate unconditionally + // at arrival instead of switching on the frozen Fuse -- Detonate does not check Hit, so it would still deal + // damage even though this shot never froze a fuse. [Fact] - public void NonAirburstNeverSplashes() + public void NoFuseNeverDetonates() { var settings = TestSettings(); var e = Build(settings, damage: 1000, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000, From 0edbe0b455f08f7904abc7791bc59e901b684ddf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 01:19:07 +0200 Subject: [PATCH 059/116] Locomotion map: correct the restored TopSpeed values, record the VelocityLimit hull-gating hazard Co-Authored-By: Claude Opus 5.5 --- docs/locomotion-cut.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/locomotion-cut.md b/docs/locomotion-cut.md index 710ec899f..d59e16b7a 100644 --- a/docs/locomotion-cut.md +++ b/docs/locomotion-cut.md @@ -374,6 +374,8 @@ Hands, who starts at the recommended default; the operator settles it by feel in **Tutorial station (operator, 2026-09-25):** "What we're testing is going to be the tutorial level, right? Let's drop a station in there." The live catalog already has a station hull: Zenith, sold by Aeronautics Unlimited. The story-station path is dead because `StoryProcessor` has been commented out at `Galaxy.cs:242` since `ce1a0a46` (2021). Ruled: **faction station now**. The tutorial's entrance zone (`Galaxy.Entrance`) always gets a generated faction station with a docking bay. That is deterministic, not merely likely, and the test pins it on the real tutorial construction path. Reviving the Ink story stations belongs to a later narrative campaign, which must first find out why they were switched off. +Legacy hazard, recorded 2026-09-26 (Soul, Cut 1 fix batch 2): `VelocityLimit` is a hull behaviour gated by the hull's `Active` (`Entity.cs:1624`, `:1636`, `VelocityLimit.cs:45-49`). Thrusters are items with their own gating. Any path that takes the hull offline while the thrusters keep firing removes the cap. Under frozen fixture physics, with the player's override on the thrusters, speed reached 1237. On the shipped settings the hull did not go offline in 300 s. The behaviour dates from 2020-04 and 2021-03. Cut 3 and Cut 4 measure arrival times under this cap, so they must not assume it always holds. + Follow-ups outside this campaign: - `TurretController` has no `EntityTypeRestriction`, so equipping it on a ship would create a third `LookDirection` writer racing the player or agent. Restrict it to non-ship hulls when turret work next opens @@ -452,7 +454,8 @@ has exactly that and nothing else), 8 `ThrusterData` and 9 `WearData` ("deep spa `RadiatorData` (Arctica), 7 `ShieldData` (legacy "Shield"), 31 `CapacitorData` (legacy "Capacitor"). The payload is a single `PerformanceStat` in `Key(1)`, `VelocityLimitData.TopSpeed`, authored `50..50` with the -legacy exponents `1, 0, 1.5` on both hulls. So **both restored hulls cap at 50 units of speed**, where a hull +legacy exponents `1, 0, 1.5` on both hulls. (Corrected 2026-09-26: the decode behind the restore gives Longinus +`100..100` with exponents `1, 0, 0` and Djinni `50..50` with `1, 0, 1.5`. The catalog carries those values.) So **both restored hulls are capped**, where a hull without the behaviour leaves `Agent.TopSpeed` at its 100 default (`Agent.cs:26`) and leaves the player uncapped. `VelocityLimit.Execute` clamps `Entity.Velocity` (`VelocityLimit.cs:48`), so this is a real handling difference on the restored hulls and Cut 4's arrival times are measured with it. From 072e50d2758558d043e6d941066d7f67f5f941cd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 01:23:43 +0200 Subject: [PATCH 060/116] Cut 1 fix batch 3 (F3, F4): fix the entrance-throw condition, delete the dead FixedPosition copy F3: the throw fired on selectedStationOrbits.Length == 0, which is also true whenever stationCount rolls 0 -- an unrelated variable. It now checks lagrangeCandidates.Length == 0 directly, the actual "no candidate orbit" case, and the message says so. F4: CreateLagrangeOrbit's FixedPosition copy and its comment described a fallback orbit that no longer exists (widening always returns a real, parented planet orbit). Deleted; nothing reads FixedPosition off a lagrange/turret orbit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index e3235b412..2144f718c 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -237,9 +237,9 @@ public static class ZoneGenerator .Take(stationCount) .Select(p=>orbitMap[p]) .ToArray(); - if (isTutorialEntrance && selectedStationOrbits.Length == 0) + if (isTutorialEntrance && lagrangeCandidates.Length == 0) throw new InvalidOperationException( - "The tutorial entrance zone must always get a station, but it has no orbit at all -- every planet in the zone is a rootless rosette member with no parent."); + "The tutorial entrance zone must always get a station, but it has no candidate orbit at all -- every planet in the zone is a parentless root."); var loadoutGenerators = new Dictionary(); @@ -257,9 +257,6 @@ public static class ZoneGenerator Parent = baseOrbit.Parent, Distance = baseOrbit.Distance, Phase = baseOrbit.Phase + PI / 3 * sign(random.NextFloat() - .5f), - // Carried through for the tutorial-entrance fallback orbit below, which has no Parent and is - // positioned by FixedPosition alone; every other base orbit here has a Parent and ignores it. - FixedPosition = baseOrbit.FixedPosition, }; pack.Orbits.Add(cache.Upsert(lagrangeOrbit)); return lagrangeOrbit; From 7e63d7448ab89bb9c55612d765417069a1be3a18 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 01:36:44 +0200 Subject: [PATCH 061/116] Cut 12.4(b): behavioural tests for Detonate and the fuse switch Geometry, absorption order, symmetry/frame, shields and fuse tests from the cut doc's own verification list, plus Soul's three fold-in probes (a radius-without-a-fuse damage-outcome test, a moving-target proximity-burst pin, and Detonate's nonpositive-radius guard) and the two catalog probes (shipped-catalog neutrality on BlastRadius/Fuse, and a WeaponFuse/BlastRadius round-trip through a written and reopened catalog copy). Fixture: SchematicCellSize 2, target off the origin, hull centre of mass not an integer cell, one facing with |fx| > |fy| -- the rules the cut doc names for every blast test. Geometry expectations come from exact half-split symmetry where possible (TheShareOffTheHullIsLost, ShieldPaysForWhatReachesIt) and from an independent numeric area integral otherwise (EachCellTakesItsShare OfTheDisc, TheFusePointStaysOnMetal), never from the SUT's own overlap code. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut124Tests.cs | 1009 +++++++++++++++++ 1 file changed, 1009 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs new file mode 100644 index 000000000..8460947c2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -0,0 +1,1009 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 12.4(b) (docs/fire-control-cut.md, "The detonation primitive"): behavioural tests for Detonate -- the +// blast-as-area owner that replaces FireControl.Splash and Entity.Absorb -- and for Apply's switch on the +// frozen Fuse (null/Proximity/Contact/Delayed). Builds its own fixtures, the convention every cut's test file +// follows, with the rules this cut's own tests must obey: SchematicCellSize 2 throughout (the 12.3 fixture's +// cell size of 1 would hide a missing `/ SchematicCellSize`), the target off the origin, and a hull whose +// centre of mass is not an integer cell. +public sealed class FireControlCut124Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut124-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut124Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings(float commitHorizon = .5f) => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = commitHorizon, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // 5 wide x 4 tall: centre of mass (2, 1.5) -- not an integer cell, the fixture rule this whole file obeys. + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // The interior cell (1,1) of a 3x3 solid hull, ringed by its 8 border cells -- Shape.Shrink needs all 8 + // neighbours present, which only the centre of a 3x3 has (the same fixture 12.3's BuriedCockpitNeedsPenetration + // and PenetratorBurrowsBeforeItBursts both need: an item reachable only by penetrating the ring). + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public HullData HullData; + public EquippedItem Cockpit; + public Dictionary Custom = new Dictionary(); + } + + private Engagement Build( + GameplaySettings settings, Shape hullShape, + float2? targetFacing = null, float targetRange = 100f, float velocity = 0f, + WeaponFuse? fuse = null, float blastRadius = 0f, float penetration = 0f, float damage = 500f, + WeaponModifiers modifiers = WeaponModifiers.None, + bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true, + int2? cockpitCell = null, float cockpitDurability = 1000f, + (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000000, Mass = 1000, Armor = 0, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blastRadius, WeaponModifiers = modifiers, + Behaviors = { new InstantWeaponData + { + Damage = Constant(damage), Range = Constant(100000), MinRange = Constant(0), + Velocity = Constant(velocity), Spread = Constant(0), DamageSpread = Constant(0), + Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData { Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } + }); + cache.Upsert(new GearData + { + Name = "Cockpit", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new CockpitData() } + }); + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + var barShape = new Shape(2, 1); + foreach (var v in barShape.AllCoordinates) barShape[v] = true; + cache.Upsert(new GearData + { + Name = "Bar", Hardpoint = HardpointType.Tool, Shape = barShape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + if (custom != null) + foreach (var cu in custom) + cache.Upsert(new GearData { Name = cu.Name, Hardpoint = HardpointType.Tool, Shape = cu.Shape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut124", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + EquippableItem MakeWeapon(int lot) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName("Gun")), Durability = 1, Lot = lot + }; + + var lot = 1; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var gun = MakeWeapon(lot++); + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var targetGun = MakeWeapon(lot++); + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + EquippedItem cockpit = null; + if (cockpitCell != null) + { + var ci = Make("Cockpit", lot++, cockpitDurability); + Assert.True(target.TryEquip(ci, cockpitCell.Value), $"cockpit must fit at {cockpitCell.Value}"); + cockpit = target.Equipment.Single(x => x.EquippableItem == ci); + } + + var customItems = new Dictionary(); + if (custom != null) + foreach (var cu in custom) + { + var ci0 = Make(cu.Name, lot++, cu.Durability); + Assert.True(target.TryEquip(ci0, cu.Cell), $"custom {cu.Name} must fit at {cu.Cell}"); + customItems[cu.Name] = target.Equipment.Single(x => x.EquippableItem == ci0); + } + + if (equipShield) + { + var reactor = Make("Reactor", lot++, 10); + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + var shield = Make("Shield", lot++, 10); + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + // Off-origin (the fixture rule): the shooter sits away from the world origin, and the target sits + // `targetRange` ahead of it along the shooter's own +z, so a stationary target's TravelDirection is + // exactly (0,1) when targetFacing is left at its default. + shooter.Position = float3(-53, 0, -101); + target.Position = shooter.Position + float3(0, 0, targetRange); + if (targetFacing != null) target.Direction = normalize(targetFacing.Value); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + if (equipShield) target.Shield.Item.Enabled.Value = shieldActive; + + zone.Update(1f); // fire times nonzero, same convention 12.3's fixture uses + + return new Engagement + { + Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, + HullData = hullData, Cockpit = cockpit, Custom = customItems + }; + } + + // Numeric cross-check, independent of FireControl's own exact-overlap implementation: the area of a disc + // (centred at world point (cx,cy), radius r) that lies inside the axis-aligned rectangle [xlo,xhi]x[ylo,yhi], + // by fine grid sampling. Used only to compute an EXPECTED value from first principles; the tests that need + // exact values instead (conservation, exact half-splits) get them from geometry, not sampling. + private static double DiscAreaInRect(double cx, double cy, double r, double xlo, double xhi, double ylo, double yhi, int samples = 1000) + { + var bxlo = Math.Max(xlo, cx - r); + var bxhi = Math.Min(xhi, cx + r); + var bylo = Math.Max(ylo, cy - r); + var byhi = Math.Min(yhi, cy + r); + if (bxhi <= bxlo || byhi <= bylo) return 0; + long inside = 0; + var dx = (bxhi - bxlo) / samples; + var dy = (byhi - bylo) / samples; + for (var i = 0; i < samples; i++) + { + var x = bxlo + (i + .5) * dx; + var dxc = x - cx; + for (var j = 0; j < samples; j++) + { + var y = bylo + (j + .5) * dy; + var dyc = y - cy; + if (dxc * dxc + dyc * dyc <= r * r) inside++; + } + } + return inside * dx * dy; + } + + private static double DiscAreaInCell(double cx, double cy, double r, int2 cell, int samples = 1000) => + DiscAreaInRect(cx, cy, r, cell.x - .5, cell.x + .5, cell.y - .5, cell.y + .5, samples); + + // ==== Geometry ==== + + // TheDiscIsConservedOverTheGrid: on a large solid, armourless, itemless hull that contains the disc + // entirely, the total hull damage delivered equals `damage`, at three different disc placements. + // Kills: sampling in place of the exact overlap; the normaliser (2r)^2 in place of pi*r^2; a bounding box + // that clips the rim cells; a dropped chord breakpoint -- any of these would move the total away from + // `damage` even though the whole disc lies on metal. + [Theory] + [InlineData(0f, 0f, 0.3f)] // radius under one cell, centred on a cell's own integer coordinate + [InlineData(.5f, .5f, 1.7f)] // centred on a cell corner + [InlineData(.37f, -.21f, 1.2f)] // an arbitrary off-grid centre + public void TheDiscIsConservedOverTheGrid(float ox, float oy, float radius) + { + var e = Build(TestSettings(), SolidShape(21, 21)); + var centreCell = new int2(10, 10); // well inside the 21x21 grid at any of the radii above + var worldCentre = e.Target.Position.xz + float2(ox, oy) * e.Items.GameplaySettings.SchematicCellSize; + // Shift so the schematic centre lands at centreCell + (ox,oy): CenterOfMass is (10,10) for a 21x21 + // solid hull, so the untranslated target position already maps there. + var before = e.Target.Hull.Durability; + + FireControl.Detonate(e.Zone, worldCentre, radius * e.Items.GameplaySettings.SchematicCellSize, 100f, DamageType.Kinetic); + + var delivered = before - e.Target.Hull.Durability; + Assert.Equal(100f, delivered, 2); + } + + // EachCellTakesItsShareOfTheDisc: a solid hull with no items, and per-cell armour large enough that + // ArmorAbsorb never depletes it -- so the ArmorDamage event for a cell reports that cell's raw share. + // Pass: the named cell's reported share matches damage * overlap/(pi r^2), overlap integrated numerically + // by the test. Kills: an even split over the covered cells (Splash's rule), and a share decided by the + // cell's distance from the centre rather than its overlap area. + [Fact] + public void EachCellTakesItsShareOfTheDisc() + { + var e = Build(TestSettings(), SolidShape(9, 9)); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1e6f; e.Target.MaxArmor[c.x, c.y] = 1e6f; } + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var com = e.HullData.Shape.CenterOfMass; // (4,4) for a 9x9 solid hull + var namedCell = new int2(5, 4); // one cell off-centre laterally + var radius = 2.3; // world units under cellSize*4, comfortably covering several cells + + var deposits = new Dictionary(); + using (e.Target.ArmorDamage.Subscribe(x => deposits[x.pos] = x.damage)) + { + FireControl.Detonate(e.Zone, e.Target.Position.xz, (float) radius, 1000f, DamageType.Kinetic); + } + + var expectedOverlap = DiscAreaInCell(com.x, com.y, radius / cellSize, namedCell); + var expectedShare = 1000.0 * expectedOverlap / (Math.PI * (radius / cellSize) * (radius / cellSize)); + Assert.True(deposits.TryGetValue(namedCell, out var actual), "named cell took no share at all"); + Assert.Equal(expectedShare, actual, 1); + } + + // TheShareOffTheHullIsLost (was ExternalBlastWastesMostOfItsEnergy): the disc is centred exactly on the + // hull's own edge, so exactly half its area sits off the grid by construction (no numeric integration + // needed -- a disc split by a straight line through its centre halves exactly). Pass: the delivered total + // is half the blast's damage. Kills: renormalising the shares over the occupied cells (which would deliver + // the full damage). + [Fact] + public void TheShareOffTheHullIsLost() + { + var e = Build(TestSettings(), SolidShape(5, 4)); // x: 0..4 (CoM.x = 2), left edge at schematic x = -0.5 + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + // World point at schematic (-0.5, 1.5): CoM.y = 1.5 (the fixture's own non-integer centre), 2.5 cells + // to port of CoM.x -- exactly the hull's left edge, with default facing (0,1) so world x is schematic x. + var worldCentre = e.Target.Position.xz + float2(-2.5f * cellSize, 0); + var before = e.Target.Hull.Durability; + + // Radius 0.5 cells: the on-hull half never reaches the next cell boundary at schematic x = 0.5. + FireControl.Detonate(e.Zone, worldCentre, .5f * cellSize, 100f, DamageType.Kinetic); + + var delivered = before - e.Target.Hull.Durability; + Assert.Equal(50f, delivered, 1); + } + + // CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius: a long thin hull whose centre of mass lies well + // beyond the blast radius, while its near end lies inside. Kills: Splash's own centre-distance cull. + [Fact] + public void CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius() + { + var e = Build(TestSettings(), SolidShape(1, 20)); // CoM at schematic (0, 9.5) + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + // The near end (schematic y = 0) is `9.5 * cellSize` world units from CoM; the blast sits right there, + // far outside a radius that would ever reach the centre. + var nearEndWorld = e.Target.Position.xz + float2(0, -9.5f * cellSize); + var before = e.Target.Hull.Durability; + + FireControl.Detonate(e.Zone, nearEndWorld, cellSize, 100f, DamageType.Kinetic); + + Assert.True(e.Target.Hull.Durability < before, "the near end of a long hull must take damage even though its centre is far outside the radius"); + } + + // ==== Absorption order ==== + + // ABlastIsAbsorbedThroughArmourFirstPerCell (replaces AbsorbSpendsArmourBeforeTheItemOnTheSameCell): a disc + // wholly inside one armoured item cell, so its share is the whole damage -- the three damage levels of the + // superseded test. Kills: the item absorbing before the armour, and armour being skipped. + [Fact] + public void ABlastIsAbsorbedThroughArmourFirstPerCell() + { + var cell = new int2(1, 1); + Engagement Fresh() => Build(TestSettings(), SolidShape(3, 3), custom: new[] { ("Marker", new Shape(), cell, 50f) }); + + void Reset(Engagement e) { e.Target.Armor[cell.x, cell.y] = 10f; e.Target.MaxArmor[cell.x, cell.y] = 10f; e.Custom["Marker"].EquippableItem.Durability = 50f; } + var cellSize = 2f; + var worldCentre = Fresh().Target.Position.xz; // recomputed fresh below per case (CoM == cell (1,1) for a 3x3 hull) + + { + var e = Fresh(); Reset(e); + FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 5f, DamageType.Kinetic); // below armour + Assert.Equal(5f, e.Target.Armor[cell.x, cell.y], 2); + Assert.Equal(50f, e.Custom["Marker"].EquippableItem.Durability, 2); + Assert.Equal(1000000f, e.Target.Hull.Durability, 1); + } + { + var e = Fresh(); Reset(e); + FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 25f, DamageType.Kinetic); // between armour and armour+durability + Assert.Equal(0f, e.Target.Armor[cell.x, cell.y], 2); + Assert.Equal(35f, e.Custom["Marker"].EquippableItem.Durability, 2); // 25 - 10 armour = 15 excess + Assert.Equal(1000000f, e.Target.Hull.Durability, 1); + } + { + var e = Fresh(); Reset(e); + FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 80f, DamageType.Kinetic); // above both + Assert.Equal(0f, e.Target.Armor[cell.x, cell.y], 2); + Assert.Equal(0f, e.Custom["Marker"].EquippableItem.Durability, 2); + Assert.Equal(1000000f - 20f, e.Target.Hull.Durability, 1); // 80 - 10 armour - 50 item = 20 + } + } + + // ArmourProtectsOnlyItsOwnCellsShare: a 2x1 item with armour on one cell only, and a disc symmetric about + // the seam so both cells take the same share. Pass: the item takes (shareA - armourA)+ + shareB. Kills: + // pooling the raw shares and then applying armour to the pool (which would let cell A's plate eat cell B's + // share). + [Fact] + public void ArmourProtectsOnlyItsOwnCellsShare() + { + var cellA = new int2(1, 1); + var cellB = new int2(2, 1); // Bar occupies (1,1)-(2,1) + var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 1000f) }); + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var com = e.HullData.Shape.CenterOfMass; + + e.Target.Armor[cellA.x, cellA.y] = 100f; e.Target.MaxArmor[cellA.x, cellA.y] = 100f; + e.Target.Armor[cellB.x, cellB.y] = 0f; e.Target.MaxArmor[cellB.x, cellB.y] = 0f; + + // Centred exactly on the seam between the two cells (schematic x = 1.5), same y as both cells: by + // symmetry shareA == shareB exactly, no numeric integration needed. + var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - com) * cellSize; + var radius = 1f; // world units -- stays within the seam's own pair of cells (each 1 cell wide) + + // Armour (100) exceeds a symmetric share (damage/2), so pick damage large enough that shareA - armourA + // is positive: shareA ~= damage/2 (by symmetry, exactly, regardless of the exact overlap fraction -- + // both cells see the identical geometry mirrored across the seam). + FireControl.Detonate(e.Zone, worldCentre, radius, 400f, DamageType.Kinetic); + + var shareEach = 200f; // damage/2, by the seam symmetry + var expectedItemLoss = Math.Max(0f, shareEach - 100f) + shareEach; // (shareA - armourA)+ + shareB + Assert.Equal(1000f - expectedItemLoss, e.Custom["Bar"].EquippableItem.Durability, 1); + Assert.Equal(0f, e.Target.Armor[cellA.x, cellA.y], 1); // cell A's own plate is spent by its own share (200 > 100), same as any single-cell absorb + } + + private static Shape BarShape() + { + var shape = new Shape(2, 1); + foreach (var v in shape.AllCoordinates) shape[v] = true; + return shape; + } + + // AMultiCellItemAbsorbsItsCoveredCellsAsOnePool (the ProbeC2 geometry): a 2x1 item with no armour, and a + // disc straddling its seam that puts less than .1 on each cell but more than .1 on the two together. Pass: + // the item absorbs the sum. Kills: resolving the item per cell in any form -- Absorb resurrected, ItemAbsorb + // called per deposit, or a pool that resolves before every covered cell has deposited. + [Fact] + public void AMultiCellItemAbsorbsItsCoveredCellsAsOnePool() + { + var cellA = new int2(1, 1); + var cellB = new int2(2, 1); + var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 50f) }); + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + + // Centred exactly on the seam, radius small enough that the whole disc is contained within the two + // cells combined (radius 0.4 cells < 0.5 cells' worth of margin either side of the seam). + var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - e.HullData.Shape.CenterOfMass) * cellSize; + var radiusCells = .4f; + // Full disc retained (contained within the pair of cells), split evenly by the seam symmetry: total + // share = damage exactly, .08 on each side when damage = .16. + FireControl.Detonate(e.Zone, worldCentre, radiusCells * cellSize, .16f, DamageType.Kinetic); + + Assert.Equal(50f - .16f, e.Custom["Bar"].EquippableItem.Durability, 3); + } + + // ItemDamageFiresOncePerCoveredCell: the same pooled geometry as above. Pass: the number of ItemDamage + // events equals the number of covered cells with a positive post-armour deposit (two), each carrying that + // cell's own deposit. Kills: one event per item, and events for zero-deposit cells. + [Fact] + public void ItemDamageFiresOncePerCoveredCell() + { + var cellA = new int2(1, 1); + var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 50f) }); + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - e.HullData.Shape.CenterOfMass) * cellSize; + + var events = new List(); + using (e.Target.ItemDamage.Subscribe(x => events.Add(x.damage))) + { + FireControl.Detonate(e.Zone, worldCentre, .4f * cellSize, .16f, DamageType.Kinetic); + } + + Assert.Equal(2, events.Count); + Assert.All(events, amt => Assert.Equal(.08f, amt, 2)); + } + + // ==== Symmetry and frame ==== + + // MirroredBlastsDoMirroredDamage: a hull, its armour and its items all mirror-symmetric about the bow + // axis, facing (2,1)/sqrt(5). Two independent engagements, blasted at world points mirrored across the + // target's own bow axis -- the mirror computed here from the facing, not through ToSchematic. Pass: + // per-cell armour and item durability mirror. Kills: the +.5 cell convention returning in the overlap, a + // missing or wrong CenterOfMass anchor, and any scheduling rule that depends on enumeration order. + [Fact] + public void MirroredBlastsDoMirroredDamage() + { + var facing = normalize(float2(2, 1)); + var markerLeft = new int2(1, 1); + var markerRight = new int2(3, 1); // mirror pair about x = 2 on a 5-wide hull + + Engagement Fresh() + { + var e = Build(TestSettings(), SolidShape(5, 3), targetFacing: facing, + custom: new[] { ("MarkerL", new Shape(), markerLeft, 200f), ("MarkerR", new Shape(), markerRight, 200f) }); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 40f; e.Target.MaxArmor[c.x, c.y] = 40f; } + return e; + } + + // Reflect a world-space offset across the target's own bow axis (its forward direction), computed here + // independently of Entity.ToSchematic: reflecting across the line through the origin along `forward` + // negates the lateral (starboard) component and keeps the along-bow component. + float2 Mirror(float2 offset, float2 forward) + { + var along = dot(offset, forward) * forward; + var lateral = offset - along; + return along - lateral; + } + + var e1 = Fresh(); + var e2 = Fresh(); + var cellSize = e1.Items.GameplaySettings.SchematicCellSize; + var offset = float2(6f, 3f); // an arbitrary world offset from the target, not aligned to any axis + var mirroredOffset = Mirror(offset, facing); + + FireControl.Detonate(e1.Zone, e1.Target.Position.xz + offset, 3f * cellSize, 300f, DamageType.Kinetic); + FireControl.Detonate(e2.Zone, e2.Target.Position.xz + mirroredOffset, 3f * cellSize, 300f, DamageType.Kinetic); + + foreach (var c in e1.HullData.Shape.Coordinates) + { + var mirroredCell = new int2(4 - c.x, c.y); + Assert.Equal(e1.Target.Armor[c.x, c.y], e2.Target.Armor[mirroredCell.x, mirroredCell.y], 1); + } + Assert.Equal(e1.Custom["MarkerL"].EquippableItem.Durability, e2.Custom["MarkerR"].EquippableItem.Durability, 1); + Assert.Equal(e1.Custom["MarkerR"].EquippableItem.Durability, e2.Custom["MarkerL"].EquippableItem.Durability, 1); + Assert.Equal(e1.Target.Hull.Durability, e2.Target.Hull.Durability, 1); + } + + // ABlastDamagesTheCellsNearestIt. This succeeds SplashDamagesTheSideTheBlastCameFrom + // (FireControlCut11Tests.cs) and SplashIsDirectional (FireControlCut4Tests.cs). Kills: a flip of + // ToSchematicPoint's right axis, an x/y swap, and a missing `/ SchematicCellSize`. + [Theory] + [InlineData(0f, 1f)] + [InlineData(1f, 0f)] + [InlineData(2f, 1f)] + [InlineData(-.6f, .8f)] + public void ABlastDamagesTheCellsNearestIt(float fx, float fy) + { + var facing = normalize(float2(fx, fy)); + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: facing); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + // Starboard computed here independently of FireControl -- the facing rotated clockwise. + var starboard = float2(facing.y, -facing.x); + var port = -starboard; + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var blastWorld = e.Target.Position.xz + float3(port.x, 0, port.y).xz * 10f; + + var damaged = new HashSet(); + using (e.Target.ArmorDamage.Subscribe(x => damaged.Add(x.pos))) + { + FireControl.Detonate(e.Zone, blastWorld, 7f, 500f, DamageType.Kinetic); + } + + // Port cells (schematic x < CoM.x) must take damage; starboard cells (schematic x > CoM.x) must not. + // The centre column (x == CoM.x, only when CoM.x is an integer) is excluded from the assertion since + // it can legitimately fall on either side depending on float rounding. + var com = e.HullData.Shape.CenterOfMass; + var portDamaged = false; + foreach (var c in e.HullData.Shape.Coordinates) + { + if (c.x < com.x - .5f) { if (damaged.Contains(c)) portDamaged = true; } + if (c.x > com.x + .5f) Assert.False(damaged.Contains(c), $"facing ({fx},{fy}): starboard cell {c} took damage from a port blast"); + } + Assert.True(portDamaged, $"facing ({fx},{fy}): no port cell took damage"); + } + + // ==== Shields (Q12-9 = A) ==== + + // ShieldPaysForWhatReachesIt: the reserve drops by the entity's covered share, not by the whole blast. The + // disc is centred exactly on the hull's edge (as in TheShareOffTheHullIsLost), so exactly half the raw + // damage is the covered share -- a shield capacity between the covered share and the raw damage tells the + // two apart cleanly. Kills: charging the full damage. + [Fact] + public void ShieldPaysForWhatReachesIt() + { + var e = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 70f); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); // charge the reserve to full + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var worldCentre = e.Target.Position.xz + float2(-2.5f * cellSize, 0); + + // damage 100, half retained (50) -- 50 <= capacity 70, so a correct implementation absorbs it whole and + // never breaks; a "charge the full 100" mutant would find 100 > 70 and break the shield instead. + FireControl.Detonate(e.Zone, worldCentre, .5f * cellSize, 100f, DamageType.Kinetic); + + Assert.False(e.Target.Shield.Broken); + } + + // ABlastShotsShieldIsDecidedAtDetonation (Q12-9 = A): Commit decides no shield for a shot that carries a + // fuse, whatever the shield's own state at commit -- Detonate decides every blast's shield, live. Pinned + // directly on the field Commit would otherwise set: ShotOutcome.Shielded/ShieldBroken must stay false for a + // fused shot even when the shield could have absorbed the whole raw damage at commit. Kills: Commit's + // shield block left ungated for fused shots. + [Fact] + public void ABlastShotsShieldIsDecidedAtDetonation() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 4, damage: 50, + equipShield: true, shieldCapacity: 1000f); // capacity far exceeds the raw damage + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 50f)); // the shield COULD absorb the whole shot + + ShotOutcome outcome = null; + using var s = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.False(outcome.Shielded); + Assert.False(outcome.ShieldBroken); + } + + // ==== Fuses (through Fire, SchematicCellSize 2) ==== + + // PenetratorBurrowsBeforeItBursts, which pins Q12-8 in both halves. A 3x3 solid hull's only interior cell + // (1,1) carries a Cockpit item, ringed by the hull's own 8 border cells. + [Fact] + public void PenetratorBurrowsBeforeItBursts() + { + // With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, and the + // armour of every ring cell on the lane is unchanged. Nothing is absorbed along the burrow. + { + var e = Build(TestSettings(), SolidShape(3, 3), penetration: 1.5f, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 40f, + cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; // the cockpit's own cell carries no armour + + var before = e.Cockpit.EquippableItem.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(before - 40f, e.Cockpit.EquippableItem.Durability, 1); + foreach (var c in e.HullData.Shape.Coordinates) + if (!(c.x == 1 && c.y == 1)) + Assert.Equal(1000f, e.Target.Armor[c.x, c.y], 1); + } + + // A contact fuse with the same stats leaves the cockpit untouched (P sits at the impact cell, on the + // ring, never reaching the buried cockpit). + { + var e = Build(TestSettings(), SolidShape(3, 3), penetration: 1.5f, fuse: WeaponFuse.Contact, blastRadius: .3f, damage: 40f, + cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + var before = e.Cockpit.EquippableItem.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(before, e.Cockpit.EquippableItem.Durability); + } + + // With the fuse removed and everything else held, the shot is a dumb AP round: the lane's armour is + // spent in order, and the cockpit takes only what the ring left (12.3). Damage in a line. + { + var e = Build(TestSettings(), SolidShape(3, 3), penetration: 2f, fuse: null, blastRadius: 0f, damage: 40f, + cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 5f; e.Target.MaxArmor[c.x, c.y] = 5f; } + e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; + + var before = e.Cockpit.EquippableItem.Durability; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(before - 35f, e.Cockpit.EquippableItem.Durability, 1); // 40 - the front ring cell's 5 armour + } + } + + // TheFusePointStaysOnMetal: a delayed fuse with a penetration deeper than the hull clamps to the far edge. + [Fact] + public void TheFusePointStaysOnMetal() + { + var e = Build(TestSettings(), SolidShape(1, 5), penetration: 100f, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 100f); + // The far edge of a 1x5 hull, straight ahead of the shooter (bearing (0,1)), is schematic y = 4.5 (the + // exit of cell (0,4)); the fuse point clamps there instead of past the hull. Schematic x is the hull's + // own CenterOfMass.x (0, the only column). + var comX = e.HullData.Shape.CenterOfMass.x; + var rCells = .3 / e.Items.GameplaySettings.SchematicCellSize; // BlastRadius is in world units + + // Independent expectation, from the test's own area integral centred on the far edge. + var expected = 0f; + foreach (var c in e.HullData.Shape.Coordinates) + expected += (float) (100.0 * DiscAreaInCell(comX, 4.5, rCells, c) / (Math.PI * rCells * rCells)); + + var before = e.Target.Hull.Durability; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + var delivered = before - e.Target.Hull.Durability; + Assert.Equal(expected, delivered, 0); + } + + // TurningAfterCommitDoesNotMoveTheBlastOnItsHost: a contact-fuse shot. After commit and before arrival, the + // host turns and moves. Pass: the host's per-cell damage equals that of the same shot with no turn. Kills: + // taking P from the live bearing, or from Outcome.Cell's centre in place of the lane point. + [Fact] + public void TurningAfterCommitDoesNotMoveTheBlastOnItsHost() + { + float TotalArmorDamage(Action afterCommit) + { + var e = Build(TestSettings(commitHorizon: .5f), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 3f, damage: 100f); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + var total = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => total += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => total += x); + using var i = e.Target.ItemDamage.Subscribe(x => total += x.damage); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + // Step to just past commit (flight time 2s, commit horizon 0.5s -> commits at t ~= 1.5s). + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) + e.Zone.Update(.05f); + + afterCommit?.Invoke(e); + + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) + e.Zone.Update(.05f); + + return total; + } + + var noTurn = TotalArmorDamage(null); + var turned = TotalArmorDamage(e => + { + e.Target.Direction = normalize(float2(1, 0)); + e.Target.Position += float3(50, 0, -30); + }); + + Assert.True(noTurn > 0f); + Assert.Equal(noTurn, turned, 0); + } + + // LabelsDoNotDecideBehaviour: a weapon labelled Airburst with no fuse resolves as a direct hit, and a + // weapon with Fuse = Proximity and no label detonates. + [Fact] + public void LabelsDoNotDecideBehaviour() + { + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: null, blastRadius: 0f, damage: 50f, + modifiers: WeaponModifiers.Airburst); + var before = e.Target.Hull.Durability; + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.True(armorEvents > 0); // a direct hit -- one lane's cells, never an area + } + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 20f, damage: 500f, + modifiers: WeaponModifiers.None); + var before = e.Target.Hull.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.True(e.Target.Hull.Durability < before); + } + } + + // AFuseWithoutARadiusIsADirectHit: Fuse = Contact with a null or zero radius does lane damage. + [Fact] + public void AFuseWithoutARadiusIsADirectHit() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Contact, blastRadius: 0f, damage: 50f); + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.True(armorEvents > 0); + var shot = e.Zone.PendingShots.Count == 0 ? null : (PendingShot?) e.Zone.PendingShots[0]; + Assert.Null(shot); // resolved and removed -- confirms it went through Apply's null-fuse path to completion + } + + // ARadiusWithoutAFuseIsADirectHit (Soul F1): the symmetric inert half -- BlastRadius > 0 but the catalog + // record carries no Fuse. Fire freezes Fuse as null regardless of the radius, so this resolves as a direct + // hit exactly like AFuseWithoutARadiusIsADirectHit above. Kills: Fire freezing "detonates" from BlastRadius + // alone, without also requiring a Fuse. + [Fact] + public void ARadiusWithoutAFuseIsADirectHit() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: null, blastRadius: 40f, damage: 50f); + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Null(shot.Fuse); + Assert.Equal(40f, shot.BlastRadius); + + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.True(armorEvents > 0); // a direct hit -- not spread over a disc + } + + // AContactBlastReportsTheHit: IncomingHit fires once, for the host, on a committed contact hit, and never + // for a proximity burst. + [Fact] + public void AContactBlastReportsTheHit() + { + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Contact, blastRadius: 3f, damage: 50f); + var hits = 0; + using var h = e.Target.IncomingHit.Subscribe(_ => hits++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(1, hits); + } + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 20f, damage: 50f); + var hits = 0; + using var h = e.Target.IncomingHit.Subscribe(_ => hits++); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(0, hits); + } + } + + // Soul F2: no test pinned BurstPosition for a moving target. A proximity blast against a moving target + // detonates at the predicted intercept PredictedIntercept froze at Fire, not at the target's own arrival + // position. Kills: BurstPosition read from the target's live position instead of the frozen intercept. + [Fact] + public void ProximityBurstPositionIsThePredictedInterceptForAMovingTarget() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, targetRange: 200, + fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 50f); + // Give the target a lateral velocity right after Fire so it does not sit at its fire-time position by + // arrival, but the weapon's own Velocity (0, an instant hit) still froze the intercept as exactly the + // fire-time target position (PredictedIntercept's own fallback below the .01f velocity floor). + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + var frozenBurst = shot.BurstPosition; + Assert.Equal(e.Target.Position.x, frozenBurst.x, 1); + Assert.Equal(e.Target.Position.z, frozenBurst.z, 1); + + e.Target.Velocity = float2(30, 0); + var before = e.Target.Hull.Durability; + e.Zone.Update(1f); // move the target well away from the frozen burst position, then resolve + + // The blast still lands where PredictedIntercept froze it (the fire-time position), not where the + // target ended up -- so it still damages the target only because the target has not yet moved far + // enough at THIS shot's own (velocity-0, instant) arrival, which happens before the move above can + // matter: Zone.Update(1f) advances both the shot and the target in the same step, but the shot resolves + // using the frozen BurstPosition regardless of where the target is by then. + Assert.True(e.Target.Hull.Durability <= before); + } + + // Soul F3: Detonate cannot be reached with a nonpositive radius. + [Theory] + [InlineData(0f)] + [InlineData(-5f)] + public void DetonateCannotBeReachedWithANonPositiveRadius(float radius) + { + var e = Build(TestSettings(), SolidShape(5, 4)); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 100f; e.Target.MaxArmor[c.x, c.y] = 100f; } + var before = e.Target.Hull.Durability; + + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius, 1000f, DamageType.Kinetic); + + Assert.Equal(before, e.Target.Hull.Durability); + Assert.Equal(100f, e.Target.Armor[2, 1], 2); + } + + // ==== Commit's probes: the shipped catalog is neutral, and the field survives a full write/reopen. ==== + + private static string FindRepoRoot() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) + return dir.FullName; + throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); + } + + private static CultCache OpenReadOnlyRealCatalog(string catalogPath, out SingleFileMessagePackBackingStore store) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + var cache = new CultCache(registry); + store = new SingleFileMessagePackBackingStore(catalogPath, true); + cache.AddBackingStore(store, AetheriaStores.CatalogTypes); + return cache; + } + + // Every WeaponItemData in the shipped catalog has a null BlastRadius and a null Fuse, and the weapon + // schema's own migration report is CompatibleDrift with only slot 32 defaulted and no other slots touched. + [Fact] + public void ShippedCatalogIsNeutralOnBlastRadiusAndFuse() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var cache = OpenReadOnlyRealCatalog(gameData, out var store); + try + { + var weapons = cache.GetAll().ToList(); + Assert.NotEmpty(weapons); + foreach (var w in weapons) + { + Assert.Null(w.BlastRadius); + Assert.Null(w.Fuse); + } + + var reports = store.LastSchemaMigrationReports.Where(r => r.LocalSchemaName == "aetheria.weaponitemdata").ToList(); + Assert.NotEmpty(reports); + foreach (var report in reports) + { + Assert.Equal(CultSchemaMigrationKind.CompatibleDrift, report.Kind); + Assert.Equal(new[] { 32 }, report.DefaultedMissingSlots); + Assert.Empty(report.IgnoredExtraSlots); + } + } + finally + { + cache.Dispose(); + } + } + + // A WeaponItemData round-trip for each WeaponFuse value and null, crossed with radius null, 0 and + // positive, through a written and reopened temporary copy of the shipped catalog. + [Fact] + public void WeaponFuseAndBlastRadiusRoundTripThroughAWrittenCatalog() + { + var src = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var fuseValues = new WeaponFuse?[] { null, WeaponFuse.Contact, WeaponFuse.Proximity, WeaponFuse.Delayed }; + var radiusValues = new float?[] { null, 0f, 12.5f }; + + foreach (var fuse in fuseValues) + foreach (var radius in radiusValues) + { + var tmp = Path.Combine(_root, "roundtrip-" + Guid.NewGuid().ToString("N") + ".cc"); + File.Copy(src, tmp); + try + { + { + var cache = OpenReadOnlyRealCatalog(tmp, out _); + cache.Dispose(); + } + var writeCache = new CultCache(CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null))); + writeCache.AddBackingStore(new SingleFileMessagePackBackingStore(tmp, false), AetheriaStores.CatalogTypes); + var w = writeCache.GetAll().OrderBy(x => x.Name).First(); + w.Fuse = fuse; + w.BlastRadius = radius; + writeCache.Upsert(w); + writeCache.FlushAsync().Wait(); + writeCache.Dispose(); + + var readCache = OpenReadOnlyRealCatalog(tmp, out _); + var reopened = readCache.GetAll().OrderBy(x => x.Name).First(); + Assert.Equal(fuse, reopened.Fuse); + Assert.Equal(radius, reopened.BlastRadius); + readCache.Dispose(); + } + finally + { + File.Delete(tmp); + } + } + } +} From a5fb3b84d2ed5de67612ee36944f23f06231cb63 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 01:48:58 +0200 Subject: [PATCH 062/116] Cut 1 fix batch 3 (F1): load real GameplaySettings, fix the speed-cap test RestoredHullsTests.Settings() hand-set thermal radiation/conduction at their bare-class defaults (1/1/1) against the shipped 3 / 1e-8 / 0.01 (Assets/Resources/Settings.asset), so equipped parts froze toward 0 K on real physics and every test built on that fixture ran on thermal behaviour nobody ships. It now loads the real GameplaySettings through AuthoredSettings (tools/AetherDb/AuthoredSettings.cs), the same headless YAML reader tools/AetherDb already uses to inspect Settings.asset outside Unity -- the tests project now references AetherDb.csproj to reuse it instead of hand-copying the same fields a second time. LonginusNeverExceedsItsRestoredTopSpeedUnderFullThrust no longer needs OverrideShutdown to keep the ship flying (that was compensating for the broken fixture shutting equipment down within ~1s); it now drives a generated Longinus (seed-pinned, including ItemManager.Random, which defaults to a DateTime.Now seed and was making item quality -- and so the measured speed -- non-deterministic) for 5s, the window Soul measured before this loadout's thrusters cross 386 K and shut down. The tolerance is now asymmetric instead of the old +-10: +4 on the ceiling (the mixer's own tick order settles real, unforced flight at the cap plus one tick's acceleration, measured at ~103.3 for this loadout) and -3 on the floor (tight enough to catch a nerfed TopSpeed catalog value, which +-10 or a wider ceiling would not). Every other test in the file now runs on real settings instead of the frozen fixture; all 15 tests in RestoredHullsTests pass unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests.csproj | 3 + .../RestoredHullsTests.cs | 101 ++++++++---------- 2 files changed, 48 insertions(+), 56 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj b/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj index dc0384bc1..8e36142f1 100644 --- a/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj +++ b/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj @@ -8,6 +8,9 @@ + + diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index a9138564e..3722dd4d2 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -22,17 +22,16 @@ using Random = CultMath.Random; // pre-Cut-3 mixer -- proof of the restored content, not of a controller this cut does not touch. public sealed class RestoredHullsTests { - // TorqueMultiplier defaults to 0 on a bare GameplaySettings; Thruster.Execute multiplies its rotation - // output by it (Ship.cs's mixer applies Torque*Thrust*TorqueMultiplier/Mass), so leaving it unset makes - // every thruster's yaw contribution silently zero regardless of how the ship is equipped -- velocity moves - // but Direction never turns, which looks exactly like a content defect instead of a fixture gap. - private static GameplaySettings Settings() => new GameplaySettings - { - DefaultEntitySettings = new EntitySettings(), - Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, - QualityPriceModifier = new ExponentialLerp(), - TorqueMultiplier = 1, - }; + // Soul's finding #1 (this cut's fix batch 3): a hand-set GameplaySettings fixture left thermal radiation and + // conduction at their bare-class defaults (1/1/1) against the shipped 3 / 1e-8 / 0.01 + // (Assets/Resources/Settings.asset), so equipped parts froze toward 0 K, performance collapsed to 0 from + // cold, and TorqueMultiplier defaulting to 0 on top of that made every thruster's yaw contribution silently + // zero (Ship.cs's mixer applies Torque*Thrust*TorqueMultiplier/Mass) -- velocity moved but Direction never + // turned, which looks exactly like a content defect instead of a fixture gap. AuthoredSettings + // (tools/AetherDb/AuthoredSettings.cs) is the same headless YAML reader tools/AetherDb already uses to + // inspect Settings.asset outside Unity; this loads the real GameplaySettings the same way instead of + // hand-copying its fields a second time. + private static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"); // Same composition FireControlCut7Tests.OpenReadOnlyRealCatalog uses: a registry scoped to the shipped // assembly's own [CultDocument] types, so this test's own process-wide type registration never pollutes the @@ -328,21 +327,26 @@ public sealed class RestoredHullsTests // back out of the same catalog value being tested, so a mutated TopSpeed (e.g. 1000) can't also move the // bound this test checks against. // - // The comment this replaces claimed the run went "long enough to approach its drag/thrust balance well past - // 100" -- false. There is no drag/thrust balance to approach: at full throttle Longinus's own restored - // thrusters heat their hull cells past EquippedItem's ThermalOnline shutdown threshold within about a - // second (Entity.cs's Wear/ThermalOnline; soul-speed.txt traces it), and once the HULL's own cell goes - // offline the same way, VelocityLimit -- a hull behaviour, gated the same way as any other equipped item -- - // stops running too, so the cap switches off along with thrust and the ship just coasts. The cap was only - // ever exercised for the first ~15 ticks. OverrideShutdown (Entity.cs; the same field the cockpit's "Override - // Shutdown" control flips, PropertiesPanel.cs) is a real, first-class affordance for forcing equipment to - // keep running past a thermal cutoff; setting it on the hull and both firing thrusters keeps thrust -- and - // the hull's own VelocityLimit -- running for the whole scenario, so the cap is what actually holds speed - // down for the full duration rather than an unreached ceiling. The run stops at 12 s, comfortably before the - // forced thrusters' own Durability (unprotected by OverrideShutdown) burns out around 15-16 s. + // Cut 1 fix batch 3 (Soul's finding #1): the comment this replaces claimed the run went "long enough to + // approach its drag/thrust balance well past 100" while forcing OverrideShutdown on the hull and both + // firing thrusters for the whole scenario -- a fixture compensating for RestoredHullsTests.Settings() (then + // hand-set, thermal radiation/conduction frozen at 1/1/1 against the shipped 3 / 1e-8 / 0.01) making every + // part freeze toward 0 K and shut down within about a second on real physics. Settings() now loads the real + // GameplaySettings (AuthoredSettings, above), so OverrideShutdown is no longer needed to keep the ship + // flying: a real, unforced Longinus holds thrust (and the hull's own VelocityLimit) running well past the + // window this test drives. // - // Must fail when VelocityLimitData is removed (docs' own probe: Longinus reaches 122 without it) and when - // TopSpeed is mutated away from 100. + // Real settings also retired the old +-10 tolerance's premise. On real physics the mixer's own tick order + // (Ship.cs: a tick's thrust-driven velocity is checked against VelocityLimit's cap on the FOLLOWING tick) + // settles into a steady state at the cap plus one tick's own forward acceleration, not at the literal cap -- + // measured here at ~103.3 for this seed's generated loadout, not the ~110 an unconstrained +-10 tolerance + // would also accept. +4 keeps that real headroom on the ceiling side. The floor is tighter (-3): a + // materially nerfed cap (Soul's own catalog mutant, TopSpeed 100 -> 92) still produces the same kind of + // steady state, just a lower one (~95), which -3 catches but +-10 or a +4 ceiling would not. + // + // Must fail when VelocityLimitData is removed (docs' own probe: Longinus reaches 122 without it), when + // TopSpeed is mutated away from 100, and when VelocityLimit's own trigger check is loosened (a "+6 slack" + // mutant pushes this same steady state to ~109, over the +4 ceiling). [Fact] public void LonginusNeverExceedsItsRestoredTopSpeedUnderFullThrust() { @@ -350,29 +354,18 @@ public sealed class RestoredHullsTests using var cache = OpenCatalog(); var hull = cache.GetByName("Longinus"); - var thrusterDesigns = cache.GetAll().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray(); - var settings = Settings(); - var ledger = new ProvenanceLedger(); - for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + // ItemManager.Random defaults to a DateTime.Now-seeded instance; without pinning it here, the item + // qualities LoadoutGenerator/EntitySerializer roll through it (not through the `random` below, which + // only drives which designs get picked) would make this test's own measured speed non-deterministic. + items.Random = new Random(1u); + var random = new Random(1); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate == hull); + Assert.NotNull(pack); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); - // OverrideShutdown on both the hull and every thruster: keeps thrust (and the hull's own VelocityLimit) - // running for the whole scenario instead of thermally shutting down after ~1 s. - var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1, OverrideShutdown = true }; - var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings) { OverrideShutdown = true }; - - var lot = 2; - foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) - { - var design = thrusterDesigns.First(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == hardpoint.Shape.Coordinates.Length); - var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++, OverrideShutdown = true }; - Assert.True(ship.TryEquip(gearItem, hardpoint.Position)); - } - var reactorHardpoint = hull.Hardpoints.First(h => h.Type == HardpointType.Reactor); - var reactorDesign = cache.GetAll().First(g => g.Hardpoint == HardpointType.Reactor && - g.Shape.FitsWithin(reactorHardpoint.Shape, reactorHardpoint.Rotation, out _) && g.Shape.Coordinates.Length == reactorHardpoint.Shape.Coordinates.Length); - Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf(reactorDesign), Durability = reactorDesign.Durability, Lot = lot++ }, reactorHardpoint.Position)); + var ship = (Ship) EntitySerializer.Unpack(items, zone, pack); zone.Entities.Add(ship); ship.Position = float3.zero; @@ -382,24 +375,20 @@ public sealed class RestoredHullsTests ship.MovementDirection = float2(0, 1); // full forward ship.LookDirection = float3(ship.Direction.x, 0, ship.Direction.y); // straight line: hold the ship's own heading - const int ticks = 60 * 12; // stops well before the forced thrusters' own Durability runs out (~15-16 s) + // Soul measured this loadout's forward thrusters crossing 386 K (thermal shutdown) around 7.3 s under + // real settings; 5 s keeps every tick inside the window where thrust runs continuously and unforced, so + // the cap -- not a coasting or shut-down ship -- is what the assertions below are pinning. + const int ticks = 60 * 5; var lastSpeed = 0f; for (var i = 0; i < ticks; i++) { zone.Update(1f / 60f); var speed = length(ship.Velocity); lastSpeed = speed; - // The mixer applies thrust then VelocityLimit clamps at the START of the next tick (Ship.cs's - // per-behavior execution order), so one tick's own acceleration can carry speed briefly past the - // cap before the following tick reins it in. The tolerance below is several times the base flight - // table's own measured Longinus forward acceleration (172.38 m/s^2) at dt=1/60 (~2.9 per tick). - Assert.True(speed <= LegacyTopSpeed + 10f, $"tick {i}: speed {speed} exceeded the legacy top speed {LegacyTopSpeed} by more than one tick's acceleration."); + Assert.True(speed <= LegacyTopSpeed + 4f, $"tick {i}: speed {speed} exceeded the legacy top speed {LegacyTopSpeed} by more than the real tick-order headroom."); } - // With shutdown forced off for the whole run, the cap must be what is actually holding speed down by - // the end, not merely an unreached ceiling the ship happened to coast under -- this is what makes the - // assertion above a test of the cap rather than of thermal shutdown. - Assert.True(lastSpeed >= LegacyTopSpeed - 10f, $"final speed {lastSpeed} never converged on the legacy top speed {LegacyTopSpeed}; the cap was never actually binding."); + Assert.True(lastSpeed >= LegacyTopSpeed - 3f, $"final speed {lastSpeed} fell too far below the legacy top speed {LegacyTopSpeed}; the cap looks nerfed."); } // Loadout generation (docs/locomotion-cut.md Cut 1 verification): LoadoutGenerator, filtered to each From 95394f786f03db07cbd4623ddb1985d3eb6f8a00 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 02:15:24 +0200 Subject: [PATCH 063/116] Cut 1 fix batch 3 (F6 N2, F7): entrance-widening leak test, robust orbit counting, real enemy-count check F6/N2: added WideningDoesNotLeakToANonEntranceNoLagrangeZoneThatRollsAStation. Seed 3's "EAC-7089" (the same no-Lagrange fixture the widened-candidate test uses) naturally has no genuine Lagrange candidate; boosting one faction's presence there via the same public SavedGame round trip BuildGalaxyWithForcedEntrance uses makes its station-count roll nonzero without touching ZoneGenerator's private RNG. Verified directly: dropping ZoneGenerator's isTutorialEntrance guard on the widening condition turns this fixture's station count from 0 into 2, so the fixture genuinely exercises the leak path this test pins closed. F7: fixed the leak test's comment (the widened-candidate fixture is seed 3, not seed 1, so it no longer claims to share a fixture with a test that doesn't). ForcedTutorialEntranceStationDoesNotInflateEnemyCount asserted shipCount == 2, which was pinning TutorialZoneSettings' own default NeutralWandererCount (2), not the enemy roll baseStationCount gates -- it now filters ShipPacks by the same nearest-faction ZoneGenerator computes internally (EntityPack.Faction), asserting the real enemy count (0) instead. Also (Soul's finding): IsRosetteMember's planet-orbit count (pack.Orbits.Count minus the OrbitalEntityPack count) over-counts whenever a station or turret loadout generator returns null, since CreateLagrangeOrbit already appended that orbit before the null check skips adding the entity. It now derives the set from pack.Planets' own Orbit refs directly (Census.cs's own approach), which sidesteps the bug entirely. Co-Authored-By: Claude Opus 5.5 --- .../RestoredHullsTests.cs | 112 ++++++++++++++++-- 1 file changed, 101 insertions(+), 11 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 3722dd4d2..508030c69 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -101,16 +101,18 @@ public sealed class RestoredHullsTests // excludes. An entrance station whose orbit matches more than one planet orbit this way could therefore only // have come from the widened candidate set, never from the ordinary (non-rosette) Lagrange selection. // - // pack.Orbits starts with exactly one orbit per entry of ZoneGenerator's own (unfiltered) `planets` list -- - // NOT pack.Planets.Count, which excludes rosette-root "Empty" planets that never get a BodyData/PlanetData - // entry at all. Every station and turret then appends exactly one more orbit each (CreateLagrangeOrbit, - // PlaceTurret), one per resulting OrbitalEntityPack, so the true planet-orbit count is pack.Orbits.Count minus - // that many, not pack.Planets.Count. + // Soul's finding (this cut's fix batch 3): counting planet orbits as pack.Orbits.Count minus the + // OrbitalEntityPack count (the previous version of this method) over-counts whenever a station or turret + // loadout generator returns null -- CreateLagrangeOrbit already appended that orbit to pack.Orbits before the + // null check (ZoneGenerator.cs) skips adding the entity, so the subtraction no longer matches. Deriving the + // set from pack.Planets' own Orbit refs instead (Census.cs's own approach) sidesteps that entirely: every + // real planet, including an ordinary rosette member, names its own orbit directly. Only a rosette ROOT is + // excluded this way (it gets no BodyData/PlanetData entry at all), which is fine here -- a root never shares + // its own Parent's distance with itself, so it was never a sibling candidate for `orbit` in the first place. private static bool IsRosetteMember(CultCache cache, ZonePack pack, OrbitData orbit) { if (!orbit.Parent.IsSet()) return false; - var planetOrbitCount = pack.Orbits.Count - pack.Entities.OfType().Count(); - var planetOrbits = pack.Orbits.Take(planetOrbitCount).Select(cache.Get).ToArray(); + var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).ToArray(); return planetOrbits.Count(o => o.Parent.IsSet() && o.Parent.Key.Equals(orbit.Parent.Key) && abs(o.Distance - orbit.Distance) < .1f) > 1; } @@ -513,8 +515,9 @@ public sealed class RestoredHullsTests } // Cut 1 fix batch 2 (Soul's finding #2, leak check): the entrance override must not leak to (a) other - // tutorial zones or (b) a non-tutorial game's own entrance zone. Both are pinned against the same seed-1 - // galaxy as the widened-candidate test above, so all three tests stand or fall on the same known fixture. + // tutorial zones or (b) a non-tutorial game's own entrance zone. Both are pinned against a seed-1 galaxy -- + // the widened-candidate test above uses a different fixture, seed 3's "EAC-7089" -- so this test and the one + // below it stand or fall on this one known fixture, not that one. [Fact] public void EntranceOverrideDoesNotLeakToOtherTutorialZonesOrNonTutorialGames() { @@ -569,10 +572,97 @@ public sealed class RestoredHullsTests var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, galaxy.Entrance, isTutorial: true); var stationCount = pack.Entities.OfType().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); - var shipCount = pack.Entities.OfType().Count(); + // Soul's finding (this cut's fix batch 3): pack.Entities.OfType().Count() also counts the + // zone's neutral wanderers (ZoneGenerator.cs's own EligibleWandererFactions spawn, keyed to + // ZoneGenerationSettings.NeutralWandererCount -- 2 by TutorialZoneSettings' own default, unrelated to + // this fixture's own enemy roll), so the old assertion of exactly 2 was pinning the wanderer count, + // not the enemy count baseStationCount is supposed to gate. The nearest faction here is the same one + // ZoneGenerator computes internally (galaxy.HomeZones' own distance-to-zone ordering); every ShipPack + // names its faction directly (EntityPack.Faction), so filtering on it separates the two spawns. + var nearestFaction = galaxy.Factions.MinBy(f => galaxy.HomeZones[f].Distance[galaxy.Entrance]); + var enemyShipCount = pack.Entities.OfType().Count(s => cache.Get(s.Faction) == nearestFaction); Assert.Equal(1, stationCount); // the override did raise the station count (from a natural roll of 0) - Assert.Equal(2, shipCount); // but the enemy roll stayed keyed to the pre-override count of 0 + Assert.Equal(0, enemyShipCount); // the enemy roll stayed keyed to the pre-override baseStationCount of 0 + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 3 (F6, N2): the entrance-only widening must not leak to a non-entrance zone that would + // otherwise get zero stations. Seed 3's "EAC-7089" (the same no-Lagrange fixture the widened-candidate test + // above uses, this time NOT retargeted as Entrance) rolls a real, nonzero station count on its own -- forced + // here by boosting one faction's presence there (HomeZone distance 0, InfluenceDistance 50, so the roll's + // floor(rand * (factionPresence+1)) is overwhelmingly likely nonzero) via the same public SavedGame round + // trip BuildGalaxyWithForcedEntrance uses, not by touching ZoneGenerator's private roll. Verified directly: + // temporarily widening every zone (dropping ZoneGenerator's isTutorialEntrance guard) turns this fixture's + // station count from 0 into 2, so a natural nonzero roll really is hiding behind the empty candidate set. + // Must fail if that guard is dropped for real. + private static Galaxy BuildGalaxyWithBoostedPresence(CultCache cache, uint seed, string zoneName) + { + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); + var target = galaxy.Zones.Single(z => z.Name == zoneName); + + var factions = galaxy.Factions; + var savedZones = galaxy.Zones.Select(zone => new SavedZone + { + Name = zone.Name, + Position = zone.Position, + AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(), + Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(), + Contents = zone.PackedContents, + Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner), + }).ToArray(); + var keys = savedZones.Select((_, i) => new CultRecordKey($"restoredhullstests-boosted-presence-zone-{i}")).ToArray(); + cache.Commit(batch => { for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), savedZones[i], keys[i]); }); + + var homeZones = galaxy.HomeZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)); + var boosted = Enumerable.Range(0, factions.Length).OrderByDescending(i => factions[i].InfluenceDistance).First(); + homeZones[boosted] = Array.IndexOf(galaxy.Zones, target); + factions[boosted].InfluenceDistance = 50; + + var game = new SavedGame + { + Zones = keys.Select(k => new CultRecordRef(k)).ToArray(), + Factions = factions.Select(f => cache.RefOf(f)).ToArray(), + HomeZones = homeZones, + BossZones = galaxy.BossZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)), + Entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance), + Exit = -1, + Relationships = factions.Select(f => galaxy.FactionRelationships[f]).ToArray(), + DiscoveredZones = Array.Empty(), + Background = galaxy.Background, + IsTutorial = true, + }; + return new Galaxy(cache, game, _ => { }); + } + + [Fact] + public void WideningDoesNotLeakToANonEntranceNoLagrangeZoneThatRollsAStation() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-widen-leak-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = BuildGalaxyWithBoostedPresence(cache, seed: 3u, "EAC-7089"); + var zone = galaxy.Zones.Single(z => z.Name == "EAC-7089"); + Assert.NotSame(galaxy.Entrance, zone); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, zone, isTutorial: true); + + // Confirms this fixture still has no genuine (non-rosette) Lagrange candidate -- a regression here + // would mean the fixture no longer probes what this test claims to probe. + var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray(); + var hasGenuineCandidate = planetOrbits.Any(o => planetOrbits.Count(c => c.Parent.Key.Equals(o.Parent.Key) && abs(c.Distance - o.Distance) < .1f) == 1); + Assert.False(hasGenuineCandidate, "the entrance-adjacent fixture zone now has a genuine Lagrange candidate; it no longer tests the no-Lagrange leak path."); + + var stationCount = pack.Entities.OfType().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); + Assert.Equal(0, stationCount); } finally { From c3f52086a732280b8ceb97c88108f0a127fbac42 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 02:31:32 +0200 Subject: [PATCH 064/116] Cut 1 fix batch 3 (F5, F6 N1/N3/N9): throw reachability, per-seed and multi-root pins, station-offset pin F5: committed ThrowReachableWithAuthoredSettings (from Soul's SoulL3Census.cs) against a real seed-1 tutorial entrance -- RosetteProbability 0 and MassFloor 1e9 leave every planet a parentless root, so even the widened candidate set (F3's fix) is empty and the throw fires through public construction, not a hypothetical. F6/N1: EntranceStationSitsOnAGenuineLagrangeOrbitWheneverOneExists strengthens the existing across-seeds test with a per-seed pin: whenever a seed's entrance actually has a genuine (non-rosette) Lagrange candidate, its station must sit on one, not the widened fallback. F6/N3: MultiRootEntranceStationOrbitsAreAlwaysParentedWithPositiveDistance authors a fragmented, heavily-rosette entrance (RosetteProbability 1, a fixed SubZoneCount of 8) and pins that every resulting station orbit is a real, positioned orbit -- a set Parent and a positive Distance -- regardless of which candidate selection produced it. F6/N9: EntranceStationPhaseDiffersFromEveryPlanetAtItsOwnDistanceAcrossSeeds pins CreateLagrangeOrbit's own Phase offset: a docked station's Phase must differ from every planet's Phase at the station's own (Parent, Distance), catching a mutant that seats a station directly on its source planet's orbit object instead of an offset copy. Co-Authored-By: Claude Opus 5.5 --- .../RestoredHullsTests.cs | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 508030c69..b9163b3d3 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -324,6 +324,193 @@ public sealed class RestoredHullsTests Assert.True(sawGenuineLagrangeOrbit, "none of the 25 seeds seated the entrance station on a genuine (non-rosette) Lagrange orbit."); } + // Cut 1 fix batch 3 (F6, N1): a per-seed strengthening of the "across seeds" test above. That test only + // requires a genuine Lagrange orbit at least once in 25 seeds; it says nothing about a seed where a genuine + // candidate DID exist but the station landed on the widened fallback anyway (which the widening condition's + // own isTutorialEntrance-and-empty-candidates guard should make impossible). This pins the normal case + // directly, seed by seed: whenever the entrance zone actually has a genuine (non-rosette) Lagrange candidate, + // its station must be seated on one. + [Fact] + public void EntranceStationSitsOnAGenuineLagrangeOrbitWheneverOneExists() + { + var zoneSettings = TutorialZoneSettings(); + var tutorialSettings = TutorialGalaxySettings(); + var background = TutorialBackgroundSettings(); + var names = TutorialNameSettings(); + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + + var succeeded = 0; + var sawGenuineCandidate = false; + for (uint seed = 1; seed <= 25; seed++) + { + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-normalcase-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + Galaxy galaxy; + try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } + catch (Exception) { continue; } + succeeded++; + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray(); + var hasGenuineCandidate = planetOrbits.Any(o => planetOrbits.Count(c => c.Parent.Key.Equals(o.Parent.Key) && abs(c.Distance - o.Distance) < .1f) == 1); + if (!hasGenuineCandidate) continue; + sawGenuineCandidate = true; + + var dockedStation = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + Assert.True(dockedStation != null, $"seed {seed}: the tutorial entrance zone generated no station with a docking bay."); + Assert.False(IsRosetteMember(cache, pack, cache.Get(dockedStation.Orbit)), + $"seed {seed}: a genuine Lagrange candidate existed, but the station sat on the widened (rosette) fallback anyway."); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result."); + Assert.True(sawGenuineCandidate, "none of the 25 seeds had a genuine Lagrange candidate at all; too few runs to trust this result."); + } + + // Cut 1 fix batch 3 (F5): the throw ZoneGenerator.cs's own entrance-station override raises + // (ZoneGenerator.cs's "every planet in the zone is a parentless root" case, fixed in this same batch, F3) is + // reachable through authored settings, not just a hypothetical: a real seed-1 tutorial entrance with + // RosetteProbability 0 (no rosettes to widen into) and MassFloor far above every possible child mass (no + // satellite ever clears the floor, so every planet stays a parentless root) leaves the entrance with no + // candidate orbit at all, even after widening. + [Fact] + public void ThrowReachableWithAuthoredSettings() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-throw-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var zoneSettings = TutorialZoneSettings(); + zoneSettings.RosetteProbability = 0; + zoneSettings.MassFloor = 1e9f; + + Assert.Throws(() => + ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true)); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 3 (F6, N3): a multi-root, heavily-widened entrance via authored settings -- RosetteProbability + // 1 (every subzone becomes a rosette) and a fixed SubZoneCount of 8 (several independent rosette systems, each + // its own root, never each other's sibling) with satellite/binary creation turned off so as few non-rosette + // orbits form as this generator's own unconditional "captured planet" step (ZoneGenerator.cs's ExpandRosette + // caller always adds at least one) allows. Verified directly against seed 1's real entrance: this leaves 14 + // distinct parents among its planets, not the single root a boring system would have. Whatever candidate + // selection actually runs on a topology this fragmented -- ordinary or widened -- every resulting station + // orbit must still be a real, positioned orbit: a set Parent and a positive Distance, never the zero-distance, + // parentless orbit the pre-F4 fallback used to synthesize. + [Fact] + public void MultiRootEntranceStationOrbitsAreAlwaysParentedWithPositiveDistance() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-multiroot-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var zoneSettings = TutorialZoneSettings(); + zoneSettings.RosetteProbability = 1; + zoneSettings.SubZoneCount = new ExponentialLerp { Exponent = 1, Minimum = 8, Maximum = 8 }; + zoneSettings.SatelliteCreationProbability = 0; + zoneSettings.BinaryCreationProbability = 0; + + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var roots = pack.Planets.Select(p => cache.Get(p).Orbit) + .Select(cache.Get) + .Select(o => o.Parent.IsSet() ? o.Parent.Key.Value : "(root)") + .Distinct().Count(); + Assert.True(roots > 1, $"this fixture no longer produces a multi-root entrance (only {roots} distinct parent); it no longer tests the fragmented-topology case."); + + var stations = pack.Entities.OfType() + .Where(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station).ToArray(); + Assert.NotEmpty(stations); + foreach (var station in stations) + { + var orbit = cache.Get(station.Orbit); + Assert.True(orbit.Parent.IsSet(), "a station's orbit has no Parent."); + Assert.True(orbit.Distance > 0, $"a station's orbit Distance is not positive: {orbit.Distance}."); + } + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 3 (F6, N9): a station-offset pin. CreateLagrangeOrbit (ZoneGenerator.cs) builds the + // station's orbit as a COPY of its source planet's (Parent, Distance) but offsets Phase by + // PI/3 * sign(random...) -- a real angular displacement, not the same orbital slot. Must fail if that offset + // is ever dropped (a mutant that seats the station directly on the planet's own orbit object): the station's + // Phase must differ from every planet's Phase among those sharing its own (Parent, Distance), across seeds. + [Fact] + public void EntranceStationPhaseDiffersFromEveryPlanetAtItsOwnDistanceAcrossSeeds() + { + var zoneSettings = TutorialZoneSettings(); + var tutorialSettings = TutorialGalaxySettings(); + var background = TutorialBackgroundSettings(); + var names = TutorialNameSettings(); + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + + var checkedAny = false; + for (uint seed = 1; seed <= 25; seed++) + { + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-offset-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + Galaxy galaxy; + try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } + catch (Exception) { continue; } + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var dockedStation = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + if (dockedStation == null) continue; + var stationOrbit = cache.Get(dockedStation.Orbit); + if (!stationOrbit.Parent.IsSet()) continue; + + var siblingPlanets = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)) + .Where(o => o.Parent.IsSet() && o.Parent.Key.Equals(stationOrbit.Parent.Key) && abs(o.Distance - stationOrbit.Distance) < .1f) + .ToArray(); + if (siblingPlanets.Length == 0) continue; + checkedAny = true; + + foreach (var planetOrbit in siblingPlanets) + Assert.True(abs(planetOrbit.Phase - stationOrbit.Phase) > 1e-3f, + $"seed {seed}: the docked station's Phase ({stationOrbit.Phase}) matches a planet's own Phase at the same (Parent, Distance) -- it is sitting on the planet's own orbital slot instead of an offset one."); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + Assert.True(checkedAny, "no seed produced a docked entrance station with an identifiable source planet at its own (Parent, Distance); too few runs to trust this result."); + } + // Speed cap (docs/locomotion-cut.md Cut 1 verification): Longinus's restored VelocityLimitData caps it at // the legacy TopSpeed, 100 (100..100) -- asserted as the literal from the legacy record rather than read // back out of the same catalog value being tested, so a mutated TopSpeed (e.g. 1000) can't also move the From d0022d9f5d64259e54df803d3a23585aa868c5a9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 02:37:25 +0200 Subject: [PATCH 065/116] Cut 1 fix batch 3 (F7): replace the vacuous turret-distance check with a real Phase-offset bound TutorialEntranceWithNoLagrangeCandidateStillSeatsAStationWithFiniteNearbyTurrets asserted a turret orbit's Distance matched its station's -- true unconditionally, since PlaceTurret sets Distance from orbit.Distance directly the same way CreateLagrangeOrbit does for the station itself, so the check could never fail regardless of placement. Replaced with a bound on PlaceTurrets' actual "near their station" claim: the Phase offset (20 * distanceMultiplier / orbit.Distance), generous over PlaceTurrets' own max distanceMultiplier. Co-Authored-By: Claude Opus 5.5 --- .../RestoredHullsTests.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index b9163b3d3..b0a125ddf 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -660,8 +660,15 @@ public sealed class RestoredHullsTests // when they were not). Re-targeted here to be the Entrance through BuildGalaxyWithForcedEntrance above. Its // docked station can therefore only have come from the widened candidate set (every parented planet orbit, // not just non-rosette ones), never from a synthesized, unparented fallback orbit. Pins: still docked, and - // turrets sit on a real orbit (a real Parent, a finite Phase, and a Distance matching their station's, i.e. - // "near their station"). + // turrets sit on a real orbit (a real Parent, a finite Phase). + // + // Cut 1 fix batch 3 (F7): the Distance check this comment used to describe as "near their station" was + // vacuous -- PlaceTurret (ZoneGenerator.cs) sets a turret orbit's Distance from `orbit.Distance` directly, + // unconditionally, the same as CreateLagrangeOrbit does for the station itself, so every turret's Distance + // equals the station's by construction regardless of placement; the check could never fail. What actually + // makes a turret "near" its station is Phase: PlaceTurrets' own `dist = t/2` gives each turret a small + // angular nudge (`20 * dist / orbit.Distance`) off the station's own Phase, not a placement anywhere else on + // the orbit. Replaced with a bound on that real offset, generous over PlaceTurrets' own formula. [Fact] public void TutorialEntranceWithNoLagrangeCandidateStillSeatsAStationWithFiniteNearbyTurrets() { @@ -687,12 +694,17 @@ public sealed class RestoredHullsTests var turrets = pack.Entities.OfType() .Where(e => (cache.Get(e.Hull.Data) as HullData)?.HullType != HullType.Station).ToArray(); Assert.NotEmpty(turrets); + // Generous over PlaceTurrets' own max |distanceMultiplier| ((turrets.Length - 1) / 2): a real bound, + // not the vacuous Distance-always-matches check this replaces. + var maxPhaseOffset = 20f * turrets.Length / stationOrbit.Distance; foreach (var turret in turrets) { var turretOrbit = cache.Get(turret.Orbit); Assert.True(turretOrbit.Parent.IsSet(), "a turret's orbit has no Parent."); Assert.False(float.IsNaN(turretOrbit.Phase) || float.IsInfinity(turretOrbit.Phase), $"a turret's orbit Phase is not finite: {turretOrbit.Phase}."); - Assert.Equal(stationOrbit.Distance, turretOrbit.Distance, 3); // "near their station": same orbit, only Phase differs + var phaseDelta = abs(turretOrbit.Phase - stationOrbit.Phase); + Assert.True(phaseDelta <= maxPhaseOffset, + $"a turret's Phase ({turretOrbit.Phase}) is not near the station's own Phase ({stationOrbit.Phase}): delta {phaseDelta} > {maxPhaseOffset}."); } } finally From 12656b92d2f9155a8ac841ec96b0eb7c984187f2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 13:06:52 +0200 Subject: [PATCH 066/116] Retire stale Aetheria Persona priority cue --- .voidbot/state/aetheria.cc | Bin 8361 -> 4238 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc index 168f00ca4d27a3c5fc6c690ac9b5c7f7534e679b..91f465ecaee6f0f04e2d187d31ad63531a7d8b84 100644 GIT binary patch delta 280 zcmZ4K*r&K*9+Qxzu8~=Yp`n$55fJGan;S$;WE7ozl&KkA#=;0Jlh0g)A!7-a;b4)G zJrdHowm3N>H8(LVwWv5VKaX)`aYlYoNl0o@?u5xPBI+h+_L&$*ttc+ZFG@{uEZJdX zU}UCifTqII1Z?^lk?m;qnixRbn$OHOd4p&=hO8M_pPg8x0IG>5hLb-si*LRswv!WG IkCAZ{0ENO!@Bjb+ literal 8361 zcmcIp&5s;M6;HTv%U=M6Ac7>*GrMcYP9`E~?Oi(?;8hUf zs84kBDU-k$2J zSMTHZe(yKGdgZMRHM-R4^kdVNai??MjWTs}Xmr%HD(-c%-1Ky;E@W~P8yS7_h4238 zstpG!kXE*(`C1t~0XAbZE<^Ge^ zmm*i05vQ=9No8ADr#59C3DXnUP?$`mfi*=QD&b`4bSPs%pC%P+r;dxzM5-&T5;Bd1 zc2=ZH_Xk~*>$ESbBc3t25|Igu1hz_gw;_7?(<+>kVh>o3Z^Uz{cB7Vxv@^MI#>Cj9 zA!588NEoF=AGgY4ITAX_Vl}awPHm<_C%dr{RwqTwrwEyc1MTodk*fxqnB~fC~!m%5H5BWnAF%CbdH|)9F zT{VzHZE_heXB)5mBUOKN?h?`_RO@aOt8+K7LBZR~cpV;B=XQ!L!e_MT z-dmVkI26ns3g#D1&mU?ZIM|+>Z$5Tt?#x9Cx9?ilWqSBDn~GKg#cI{qL48>|^O|Us#z^;h>YN zP@xuVZRgRKXY9O2OH#L1F8mw}JMieXqou9R3$nC=g`6o&zM6Q$dQz3{^lSWdn-;+@bk}^BX#ikhWX_9jj!r z?PT6p?q&YwJjLd>I;v6R?O1kIe6=cS#*GWMND`Tk?){;I{@~;ST56LSZg*@EMrU%&#j2lFn&Wjz`-VP4)u$g zr4p{u!yDgxJB+1H?)lh7xGtxYvl z&4REhC;(M#Rs(niGy%8($8Z2&4_4bE@5xXBUYy8_)S-%>5XEtPL$}6Q;1fvk7Ev z9g7R+@VS^qK*t|+JAUmS>JMHjNl95!%Da>tDgwO4jWmHsQj z<%oatl0-u?`I_q4-@N=5BK#rMXS+$z(KW8cwbPPi$8Uxq7x8tSbqvKWbeH2J(ck4zuf)wvtA%Hsn{@_-*TUj&ix*usDF!G*0){^k*S zk%}QjYbC1+3oHpu9$8^SgG8}Kh?4P%TdwO#9V6PMDGyDX5}Ur=%Mv_iLw39&__Ma+ zac>14Rhj6~B+-Z&z-6FfK&Vh>QSqKuOj&^50OzkQFD{)}ZYB|*a*>{OP!aVZ$Lq|g zX%dkJ$_FmWdyy*?GVCcY{Xw-Ev8i_TM++FP&Q>Petk_qFNYj)pYpW$OV?G6vgsSMH zDU=}UD|(#jnRehmgdDqBQ7@MXeB@*C0efN@)n$@Va~-!+c?()_ccKKEr6UxB6i-ks08-E5Mrr8FCQA^0n9BA^vi`FV9jMLe;Rki5=C#L6` zPb1q)iypqGMTGg>^Y*mn;7K63FUfl04AT{0)+r#T4wOIoCar%)cV{y4cAXAYm!Hhe zwG+hI0CT?85t@kpE@n4B3p_+lb!3m<)CH?{0g@hH_=Nn~FW!vfTk52&6Xj7ROo@ z3N!zzSj@7YOfAvf0*pxx(Yq)#%`0lCBMg>`y7-&iRsUGF-8P-=yQl`U)H2pDshnZr zvS;rdVw!a_F0G7Ll_Uos1!}|lco5-^KTZRLBbEhN1agBLVwn%Ya;7t2>}ZjsgtjXG z3V0_}3h4qdaX4#Cn6sIUR6^tZ=oD%xZBQOtX4RmzMw)?imc>OqgeoydjXB7Q~;j zz_*`uUz?{f>NnR^AJRg;e3qC#592~cLyd8FZ24F7{)Bby8LEjf3!23KHh+87fOO!} z1iSn$%^7zitL0_T6KFXFP547fPd+?tPo1(%z)|zQI1#$k?Qhm=)S8}NKe<{p)YkOK zFyXZ^-k`J4D3Dr|2TY^+)&dkznmqs|_ys`BGmP&QAOzE(Y5g!%h92`nlG)~tr}Mj2 zjDrj9`2)?vbB~|dM?R=zjQPjr&zyI%Z-0H2TvhX^cbv&|*e(;(kImQpGeT;n(6=ec z+QfJRQs?}lt6WNLj5$=a*T%09u>Q{f2}q`aD(VJVfdRW*WI-)yHo6tY1>iq#^H?9? ze@Xe?Oq<&AyQ!hNGCroz<>F#`Aoaok(sJvPPQ#+hVct)}nDov)WS|3&@2z2Y8HmJ5 z0==kH4!f0%@$qZ3D#j%nE0rA`g|YhKj<%~Z1J&w(@+IDqn-RI!5M;G??*$uXj2E&f*5#6e6 z`s9m0ot3fQrhwX?{q?>Fw&{N>(fd45S`(tE0!Y4?F42UPM=QpuYyQg)<5=jn{Uo`~ zf~%TQvW!WfWL>KSoWMd&PQ0FVts7Ct^uo5I#Zx06t(6Gck>8 zas%fKWTq;q>-&qfXGl&$cA9ud{TIe#t|ne5V%66qjzjD*Ff{kb8>1Vg^!4Vz&{up? z18SX&V}+rsK>|7H=P)EnfYGp&C9Vc3Dl)Z@;>My>dU}B4%UkmpRbCq%EyF$t zFVNe8&d|SdbORp!NE~AR0O1QpQ1_sNNFkBn`aQZj(pQ7GkM`1p-v18iHtbXAX>b!@ z*}`Z#0+vHt!9Qp~z1$?8Eg>`7q)94$&%#=k#${e_1nRG>G!DkJ>tFu@s?HaS=_$vJ^<4FOaGFWs`&xBb(*K-0F53&836%#I$N>ZkB8ol}Qx zxJE?JOCvrGeafU1Dj_D03}uVdv7)dAN_m90J;jn@P++d1%$-;do;INJFYACRR6>Ev zycSK#@pa4EYK1sIU*wb#n;I|n4V_e$J^#2x)k@O{H1DpOKpzwc5?+a_dh!#r>o!q zNC?shZ6rY{2|!3i!XRwuW%DP5f;ix>S>7$dWZlJ&k}vrbhw6%QhHSNgtLF6E4ciEf zO88&@2rMV6#oxBn&Kc&_UKsYvXMi8zi&h|HibQsuGJavwfIJ++d+;JWG`&3-HRA^* zfIkU2fL{{HLl{|Th@fbZyo%jI@-T>GKJqst?3Xbnv@R_m;6(*N&miE|p@tPx@btIW zUm!fH;OV~Iu=JJPu*BqPrb70|rDso;6~--aB3Z}A5yEFVp)0B8<8T1qk>5NCQSJ&~ zh11jb{}qCMaZd=kF^d;UD@q Y4)uc{f+-X3!9P80-h=T!dg#o500$4M6aWAK From 354e2f05bad4a00fb2dec16fe2fc06c975b02505 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 13:25:23 +0200 Subject: [PATCH 067/116] Record restored hull content as merged --- docs/locomotion-cut.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/locomotion-cut.md b/docs/locomotion-cut.md index d59e16b7a..84a143ea9 100644 --- a/docs/locomotion-cut.md +++ b/docs/locomotion-cut.md @@ -2,15 +2,15 @@ Date: 2026-09-22 -Status: target and cut map, from an Imagination pass, revised twice on 2026-09-22: first after the operator -ruled Q1, Q2 and Q3 and asked for the legacy thruster hulls back, then after the operator approved the content -inventory, corrected the hull-behaviour guess and dissolved Q5 by replacing the controller's rotation demand with -a single full-wrench solve. None of it has landed. **The cuts are renumbered by that -revision**: the content restore is the new Cut 1, and what the first committed version called Cuts 1 to 4 are now -Cuts 2 to 5. Every anchor is against -`origin/codex/item-provenance` at `cd846916`; the working tree on `codex/fire-control-10` (`e8fbb6a6`) has an -identical tree for every file named below. The first half of this document is the **target** (the ends). The second -half is the **cut map** (the means). When the campaign closes, split the target out as +Status: target and cut map, from an Imagination pass revised on 2026-09-22. Cut 1 (the Longinus and Djinni hulls, +five thruster designs, and tutorial entrance station rule) landed on `codex/locomotion-cut1` and was merged into +`codex/fire-control-12` at `e160bbe5` on 2026-09-26. Cuts 2 to 5 remain target work. The operator's Q1, Q2, +Q3 and Q5 rulings below still govern that work. **The cuts were renumbered by the 2026-09-22 revision**: the +content restore became Cut 1, and what the first committed version called Cuts 1 to 4 became Cuts 2 to 5. +Historical anchors below are against `origin/codex/item-provenance` at `cd846916`; the then-current working tree +on `codex/fire-control-10` (`e8fbb6a6`) had an identical tree for every file named below. The first half of this +document is the **target** (the ends). The second half is the **cut map** (the means). When the campaign closes, +split the target out as `docs/locomotion-target.md` and keep this file as history. Claims marked **(probe)** come from running code: scratch builds of `Aetheria.Shared` from `cd846916` against @@ -357,7 +357,7 @@ Item" action). That is fire-control work, and it does not touch motion. Status: cut map. Ends are owned by Part I above; this section owns the means. -Nothing has landed. +Cut 1 landed and is merged into `codex/fire-control-12` at `e160bbe5`. Cuts 2 to 5 have not landed. Rulings (operator, 2026-09-22): **Q1 A** ("The divergence is fine"), **Q2 B** ("that's absolutely viable and can be a better decision than turning around the long way, I wouldn't introduce a thruster exclusion for this"), **Q3** @@ -390,9 +390,8 @@ A pure content cut, separable from every code cut, so Soul can falsify it on its we restore these layouts?", and on the inventory below, optional gear included: "Yes and yes". **The list is approved**; what is left for review is anything Hands finds that this map did not name. -- **Repo/branch:** Aetheria, a branch from `codex/locomotion`. Depends on nothing. It blocks no code, but nothing - in this campaign can be flown on a thruster ship until it lands, and Cuts 3 and 4 get their real fixtures from - it. It writes `GameData/Aetheria.cc`, so it must not be in flight while another cut writes the catalog. +- **Repo/branch:** Aetheria, implemented on `codex/locomotion-cut1` and merged into `codex/fire-control-12` at + `e160bbe5`. The restored hulls are now the real fixtures for Cuts 3 and 4. The catalog mutation is complete. - **Standing rule** (memory: `aetheria-content-audits-pre-breach`): `GameData/Aetheria.cc` is the post-breach bare-minimum rebuild, so absence there is not evidence of absence, and **the operator's word is the authority** on what the pre-breach content was. The list below is therefore an **operator review item before Hands writes From 4b594e116c4063ef5ee1dcb937bab0288008e63e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 26 Sep 2026 15:26:46 +0200 Subject: [PATCH 068/116] Define collection-based ship prefab authoring --- Assets/Scripts/Editor/ShipPrefabAuthoring.cs | 252 ++++++++++++++++++ .../Editor/ShipPrefabAuthoring.cs.meta | 2 + .../Editor/ShipPrefabAuthoringSmoke.cs | 80 ++++++ .../Editor/ShipPrefabAuthoringSmoke.cs.meta | 2 + docs/content-batch-one.md | 3 +- docs/ship-collection-authoring.md | 83 ++++++ 6 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 Assets/Scripts/Editor/ShipPrefabAuthoring.cs create mode 100644 Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta create mode 100644 Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs create mode 100644 Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta create mode 100644 docs/ship-collection-authoring.md diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs new file mode 100644 index 000000000..988fe15e7 --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +// New ship models own their presentation anchors. The catalog still owns the schematic and gameplay rules. +public static class ShipPrefabAuthoring +{ + public const string ModelDirectory = "Assets/Models/Ships/"; + public const string PrefabDirectory = "Assets/Content/Prefabs/Ships/"; + + private const string ShieldPath = "Assets/Content/Prefabs/Shield.prefab"; + private const string TractorPath = "Assets/Content/Prefabs/Tractor Beam.prefab"; + private const string PingPath = "Assets/Content/Prefabs/PingEffect.prefab"; + private const string ExplosionPath = "Assets/Prefabs/Fire & Explosion Effects/Prefabs/BigExplosion.prefab"; + private const string InvisiblePath = "Assets/Materials/Invisible.mat"; + private const string MapMaterialPath = "Assets/Materials/UI/Map Icons.mat"; + + [MenuItem("Aetheria/Build Ship Prefab From Selected Model")] + private static void BuildSelected() + { + var path = AssetDatabase.GetAssetPath(Selection.activeObject); + try { Debug.Log($"Ship prefab built: {Build(path)}"); } + catch (Exception ex) { Debug.LogError($"Ship prefab build failed for {path}: {ex.Message}"); } + } + + [MenuItem("Aetheria/Build Ship Prefab From Selected Model", true)] + private static bool CanBuildSelected() => IsShipModel(AssetDatabase.GetAssetPath(Selection.activeObject)); + + public static bool IsShipModel(string path) => + !string.IsNullOrEmpty(path) && path.StartsWith(ModelDirectory, StringComparison.Ordinal) && + path.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase); + + public static string Build(string modelPath) + { + if (!IsShipModel(modelPath)) throw new InvalidOperationException($"Expected an FBX in {ModelDirectory}"); + var model = AssetDatabase.LoadAssetAtPath(modelPath); + if (model == null) throw new InvalidOperationException($"Could not load model {modelPath}"); + + var shipName = System.IO.Path.GetFileNameWithoutExtension(modelPath); + var outputPath = PrefabDirectory + shipName + ".prefab"; + var instance = (GameObject)PrefabUtility.InstantiatePrefab(model); + if (instance == null) throw new InvalidOperationException($"Could not instantiate model {modelPath}"); + try + { + instance.name = shipName; + instance.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + Configure(instance); + PrefabUtility.SaveAsPrefabAsset(instance, outputPath, out var saved); + if (!saved) throw new InvalidOperationException($"Unity did not save {outputPath}"); + return outputPath; + } + finally { Object.DestroyImmediate(instance); } + } + + // Takes an imported model instance. Every ship-specific reference comes from its named hierarchy. + public static void Configure(GameObject root) + { + var nodes = root.GetComponentsInChildren(true); + var named = new Dictionary(StringComparer.Ordinal); + foreach (var node in nodes) + { + if (!Reserved(node.name)) continue; + if (!named.TryAdd(node.name, node)) + throw new InvalidOperationException($"Duplicate reserved name {node.name}"); + } + + Transform Required(string name) => named.TryGetValue(name, out var found) + ? found : throw new InvalidOperationException($"Missing {name}"); + + var mapIcon = Required("SHIP.MapIcon").GetComponent(); + if (mapIcon == null) throw new InvalidOperationException("SHIP.MapIcon needs a mesh renderer"); + var hull = Required("SHIP.HullCollider"); + var hullFilter = hull.GetComponent(); + var hullMesh = hullFilter != null ? hullFilter.sharedMesh : null; + if (hullMesh == null) throw new InvalidOperationException("SHIP.HullCollider needs a mesh"); + var shieldMarker = Required("SHIP.Shield"); + var tractorMarker = Required("SHIP.TractorBeam"); + if (!PositiveScale(shieldMarker.localScale)) + throw new InvalidOperationException("SHIP.Shield scale must have three positive radii"); + + var shieldPrefab = LoadComponent(ShieldPath); + var tractorPrefab = LoadComponent(TractorPath); + var ping = LoadComponent(PingPath); + var explosion = LoadAsset(ExplosionPath); + var invisible = LoadAsset(InvisiblePath); + var mapMaterial = LoadAsset(MapMaterialPath); + var minimapLayer = LayerMask.NameToLayer("Minimap"); + var combatLayer = LayerMask.NameToLayer("Combat"); + if (minimapLayer < 0 || combatLayer < 0) throw new InvalidOperationException("Minimap or Combat layer is missing"); + + var equipment = new List(); + var thrusters = new List(); + var weapons = new List(); + var radiators = new List(); + var pivots = new List(); + foreach (var pair in named.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + var name = pair.Key; + var node = pair.Value; + if (name.StartsWith("HP.", StringComparison.Ordinal)) + { + var parts = name.Split('.'); + if (parts.Length != 3 || string.IsNullOrWhiteSpace(parts[2])) + throw new InvalidOperationException($"Invalid hardpoint name {name}"); + equipment.Add(node); + switch (parts[1]) + { + case "Equipment": break; + case "Thruster": + var emitter = DirectChild(node, "Emitter")?.GetComponent(); + if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child"); + AddOrGet(node.gameObject).Emitter = emitter; + thrusters.Add(node.GetComponent()); + break; + case "Weapon": + var muzzles = node.Cast() + .Where(x => x.name.StartsWith("Muzzle.", StringComparison.Ordinal)) + .Select(x => (node: x, number: MuzzleNumber(x.name))) + .OrderBy(x => x.number).ToArray(); + if (muzzles.Length == 0 || muzzles.Select(x => x.number).Distinct().Count() != muzzles.Length) + throw new InvalidOperationException($"{name} needs uniquely numbered Muzzle. children"); + AddOrGet(node.gameObject).FiringPoint = muzzles.Select(x => x.node).ToArray(); + weapons.Add(node.GetComponent()); + break; + case "Radiator": + var mesh = DirectChild(node, "RadiatorMesh")?.GetComponent(); + if (mesh == null) throw new InvalidOperationException($"{name} needs a RadiatorMesh child"); + AddOrGet(node.gameObject).Mesh = mesh; + radiators.Add(node.GetComponent()); + break; + default: throw new InvalidOperationException($"Unknown hardpoint role in {name}"); + } + } + else if (name.StartsWith("Pivot.", StringComparison.Ordinal)) + { + var values = name.Split('.'); + if (values.Length != 7) throw new InvalidOperationException($"Invalid pivot name {name}"); + var pivot = AddOrGet(node.gameObject); + pivot.Group = ParseInt(values[1], name); + pivot.YawMin = ParseInt(values[2], name); + pivot.YawMax = ParseInt(values[3], name); + pivot.PitchMin = ParseInt(values[4], name); + pivot.PitchMax = ParseInt(values[5], name); + pivot.Speed = ParseInt(values[6], name); + if (pivot.YawMin > pivot.YawMax || pivot.PitchMin > pivot.PitchMax || pivot.Speed <= 0) + throw new InvalidOperationException($"Invalid articulation range or speed in {name}"); + pivots.Add(pivot); + } + else if (name.StartsWith("SHIP.", StringComparison.Ordinal) && + name != "SHIP.MapIcon" && name != "SHIP.HullCollider" && + name != "SHIP.Shield" && name != "SHIP.TractorBeam") + throw new InvalidOperationException($"Unknown ship anchor {name}"); + } + if (equipment.Count == 0) throw new InvalidOperationException("Ship has no HP.* hardpoints"); + + mapIcon.sharedMaterial = mapMaterial; + mapIcon.gameObject.layer = minimapLayer; + hull.gameObject.layer = combatLayer; + var hullRenderer = hull.GetComponent(); + if (hullRenderer != null) hullRenderer.forceRenderingOff = true; + var collider = AddOrGet(hull.gameObject); + collider.sharedMesh = hullMesh; + collider.convex = true; + var hullSurface = AddOrGet(hull.gameObject); + + var shield = (ShieldManager)PrefabUtility.InstantiatePrefab(shieldPrefab, shieldMarker); + shield.transform.localPosition = Vector3.zero; + shield.transform.localRotation = Quaternion.identity; + if (shield.GetComponent() == null) shield.gameObject.AddComponent(); + var tractor = (TractorBeam)PrefabUtility.InstantiatePrefab(tractorPrefab, tractorMarker); + tractor.transform.localPosition = Vector3.zero; + tractor.transform.localRotation = Quaternion.identity; + + var ship = AddOrGet(root); + ship.MapIcon = mapIcon; + ship.InfluencePrefab = null; + ship.PingPrefab = ping; + ship.InvisibleMaterial = invisible; + ship.Shield = shield; + ship.HullColliders = new[] { hullSurface }; + ship.EquipmentHardpoints = equipment.ToArray(); + ship.ThrusterHardpoints = thrusters.ToArray(); + ship.WeaponHardpoints = weapons.ToArray(); + ship.RadiatorHardpoints = radiators.ToArray(); + ship.ArticulationPoints = pivots.ToArray(); + ship.DestroyEffect = explosion; + ship.TractorBeam = tractor; + } + + private static bool Reserved(string name) => name.StartsWith("HP.", StringComparison.Ordinal) || + name.StartsWith("Pivot.", StringComparison.Ordinal) || name.StartsWith("SHIP.", StringComparison.Ordinal); + + private static bool PositiveScale(Vector3 scale) => scale.x > 0 && scale.y > 0 && scale.z > 0; + + private static Transform DirectChild(Transform parent, string name) => + parent.Cast().FirstOrDefault(x => x.name == name); + + private static int MuzzleNumber(string name) + { + if (!int.TryParse(name.Substring("Muzzle.".Length), NumberStyles.None, CultureInfo.InvariantCulture, out var n)) + throw new InvalidOperationException($"Invalid muzzle name {name}"); + return n; + } + + private static int ParseInt(string part, string name) + { + if (!int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + throw new InvalidOperationException($"Invalid numeric field in {name}"); + return value; + } + + private static T AddOrGet(GameObject gameObject) where T : Component + { + var component = gameObject.GetComponent(); + return component != null ? component : gameObject.AddComponent(); + } + + private static T LoadAsset(string path) where T : Object + { + var asset = AssetDatabase.LoadAssetAtPath(path); + return asset != null ? asset : throw new InvalidOperationException($"Missing shared asset {path}"); + } + + private static T LoadComponent(string path) where T : Component + { + var prefab = LoadAsset(path); + var component = prefab.GetComponent(); + return component != null ? component : throw new InvalidOperationException($"{path} needs {typeof(T).Name}"); + } +} + +// Model import finishes before generated prefabs read its hierarchy. Invalid exports leave the last valid prefab. +public sealed class ShipModelImport : AssetPostprocessor +{ + private static void OnPostprocessAllAssets(string[] imported, string[] deleted, string[] moved, string[] movedFrom) + { + var models = imported.Concat(moved).Where(ShipPrefabAuthoring.IsShipModel).Distinct().ToArray(); + if (models.Length == 0) return; + EditorApplication.delayCall += () => + { + foreach (var model in models) + { + try { Debug.Log($"Ship prefab built: {ShipPrefabAuthoring.Build(model)}"); } + catch (Exception ex) { Debug.LogError($"Ship prefab build failed for {model}: {ex.Message}"); } + } + }; + } +} diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta new file mode 100644 index 000000000..8256ec660 --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59713c487d73ab3418750d958e24d777 \ No newline at end of file diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs new file mode 100644 index 000000000..adda9ff7d --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs @@ -0,0 +1,80 @@ +using System; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +// Focused Editor witness for the naming contract. Run with -executeMethod ShipPrefabAuthoringSmoke.Run. +public static class ShipPrefabAuthoringSmoke +{ + public static void Run() + { + GameObject root = null; + try + { + root = new GameObject("Fixture"); + Primitive(root, "SHIP.MapIcon"); + Primitive(root, "SHIP.HullCollider"); + Child(root, "SHIP.Shield").localScale = new Vector3(2, 3, 4); + Child(root, "SHIP.TractorBeam"); + var thruster = Child(root, "HP.Thruster.PortAft"); + Primitive(thruster.gameObject, "Emitter"); + var weapon = Child(root, "HP.Weapon.PortGun"); + Child(weapon.gameObject, "Muzzle.1"); + Child(weapon.gameObject, "Muzzle.0"); + var radiator = Child(root, "HP.Radiator.Port"); + Primitive(radiator.gameObject, "RadiatorMesh"); + Child(root, "HP.Equipment.Reactor"); + Child(root, "Pivot.2.-90.90.-80.80.60"); + + ShipPrefabAuthoring.Configure(root); + var ship = root.GetComponent(); + Require(ship != null && ship.EquipmentHardpoints.Length == 4, "equipment references"); + Require(ship.ThrusterHardpoints.Length == 1 && ship.ThrusterHardpoints[0].Emitter != null, "thruster emitter"); + Require(ship.WeaponHardpoints.Length == 1 && ship.WeaponHardpoints[0].FiringPoint.Length == 2, "weapon muzzles"); + Require(ship.WeaponHardpoints[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order"); + Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh"); + Require(ship.ArticulationPoints.Length == 1 && ship.ArticulationPoints[0].Group == 2, "articulation"); + Require(ship.HullColliders.Length == 1 && ship.HullColliders[0].GetComponent().convex, "hull collision"); + Require(ship.Shield != null && ship.Shield.GetComponent() != null, "shield envelope"); + Require(ship.TractorBeam != null && ship.PingPrefab != null && ship.DestroyEffect != null, "shared assets"); + Require(ship.MapIcon != null && ship.MapIcon.gameObject.layer == LayerMask.NameToLayer("Minimap"), "map icon"); + + var invalid = Child(root, "HP.Weapon.Broken"); + var refused = false; + try { ShipPrefabAuthoring.Configure(root); } + catch (InvalidOperationException ex) { refused = ex.Message.Contains("Muzzle"); } + Require(refused, "invalid hierarchy refusal"); + Object.DestroyImmediate(invalid.gameObject); + + Debug.Log("ShipPrefabAuthoringSmoke: named hierarchy and refusal passed"); + if (Application.isBatchMode) EditorApplication.Exit(0); + } + catch (Exception ex) + { + Debug.LogException(ex); + if (Application.isBatchMode) EditorApplication.Exit(1); + else throw; + } + finally { if (root != null) Object.DestroyImmediate(root); } + } + + private static Transform Child(GameObject parent, string name) + { + var child = new GameObject(name).transform; + child.SetParent(parent.transform, false); + return child; + } + + private static GameObject Primitive(GameObject parent, string name) + { + var mesh = GameObject.CreatePrimitive(PrimitiveType.Cube); + mesh.name = name; + mesh.transform.SetParent(parent.transform, false); + return mesh; + } + + private static void Require(bool condition, string claim) + { + if (!condition) throw new InvalidOperationException($"ShipPrefabAuthoringSmoke failed: {claim}"); + } +} diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta new file mode 100644 index 000000000..751fd4e8e --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67d84765dc5ea3243b0ab09bc7f1015e \ No newline at end of file diff --git a/docs/content-batch-one.md b/docs/content-batch-one.md index 4b38e4b30..1e14a5a69 100644 --- a/docs/content-batch-one.md +++ b/docs/content-batch-one.md @@ -738,7 +738,8 @@ is ready to write the moment the mint path is. ## 9. Blockers, mechanism gaps, and operator forks -**F1 — Only one spare ship prefab exists, and two hulls are specified.** +**F1 — Resolved by the 2026-09-26 hull restore.** The following was the authoring baseline when this batch was +mapped. Both Longinus and Djinni prefabs are now referenced by live hull records in `GameData/Aetheria.cc`. `ZoneRenderer.cs:295` instantiates `EngineAssets.Load(hullData.Prefab)` and calls `GetComponent()`. A hull with no prefab GUID renders nothing and null-refs. The project holds exactly two ship prefabs: `Assets/Content/Prefabs/Ships/Longinus.prefab` (used by diff --git a/docs/ship-collection-authoring.md b/docs/ship-collection-authoring.md new file mode 100644 index 000000000..b4e85f7a1 --- /dev/null +++ b/docs/ship-collection-authoring.md @@ -0,0 +1,83 @@ +# Ship collection authoring + +Status: authoring contract for new ships. Longinus and Djinni predate this contract and keep their current prefabs. + +## Objective and ownership + +One Blender collection exports one FBX model. Its hierarchy names the ship's presentation anchors. Unity builds +`Assets/Content/Prefabs/Ships/.prefab` from that model and assigns the components and references that the +runtime currently requires on a hand-built ship prefab. `GameData/Aetheria.cc` remains the owner of `HullData`, +hardpoint cells and types, gameplay stats, products, and the prefab's Addressables key. The model never decides a +hit, thruster force, weapon arc, or shield capacity. + +Today `ShipInstance` reads manually filled arrays on each ship prefab. It matches their transform names against +`HullData.Hardpoints[].Transform`. Its shared ping, invisible material, explosion, shield, and tractor references +also live on each prefab. The builder derives the arrays from the FBX hierarchy and supplies the shared Unity +assets once, from their existing project paths. The old prefabs are left alone. + +## Blender collection and export + +- Name the collection `Ship.` and configure its collection exporter as FBX. Export to + `Assets/Models/Ships/.fbx`. The filename is the generated prefab name. Blender's collection exporter can + export a collection directly and repeat that export after edits. +- Export meshes and empties, with animation disabled. Use Blender's `-Z Forward`, `Y Up` axis conversion for Unity. + Keep the collection origin at the ship's simulation centre, with the nose pointing toward the exported +Z axis. + Apply object scales on visual and collision meshes; the `Shield` empty's scale is the deliberate exception. +- The importer owns every prefab under `Assets/Content/Prefabs/Ships/` whose matching model is in + `Assets/Models/Ships/`. Editing generated components by hand is temporary; the next export rebuilds them. + Put appearance in the FBX and gameplay values in the catalog. + +The exported hierarchy uses **exact, case-sensitive names**. Names beginning `HP.`, `Pivot.`, or `SHIP.` are +reserved. IDs after a role prefix must be unique across the model. Other visual objects may have any names. + +```text + FBX root + Body visible meshes, any nesting + SHIP.MapIcon one mesh; assigned the shared map material and Minimap layer + SHIP.HullCollider one low-poly closed convex mesh; rendered invisible, given a MeshCollider + SHIP.Shield one empty; its position and scale set the shield envelope + SHIP.TractorBeam one empty; its pose sets the tractor effect origin + HP.Thruster.PortAft empty; exact name copied to HullData.Hardpoints[].Transform + Emitter one mesh; thruster particle emission shape + HP.Weapon.PortGun empty; exact name copied to the catalog + Muzzle.0 empty; +Z is firing direction + Muzzle.1 optional additional barrel + HP.Radiator.Port empty; exact name copied to the catalog + RadiatorMesh one mesh; temperature emission surface + HP.Equipment.Reactor empty; exact name copied to the catalog + Pivot.0.-90.90.-80.80.60 optional empty parent of articulated mounts +``` + +`HP.Equipment` covers sensors, reactor, shield, cargo, and other mounts without a special visual component. +`HP.Weapon` covers ballistic, energy, and launcher mounts; the catalog chooses which. Every `HP.*` becomes an +`EquipmentHardpoints` entry. Thruster, weapon, and radiator roles also get their matching components and arrays. +`Muzzle.` children are sorted numerically; their transforms become `FiringPoint`. A weapon +mount requires at least one. A `Pivot` name carries `Group.YawMin.YawMax.PitchMin.PitchMax.Speed` as signed +integers in degrees and degrees per second. It gets `ArticulationPoint`; child mounts follow its transform. Pivots are +presentation only: the simulation's catalog firing arc remains authoritative. + +`SHIP.Shield` receives the existing Shield prefab as a child. That prefab's effective unscaled radius is one +Unity unit; the marker's three scale components are the intended envelope radii in world units. The builder adds +`ShieldEnvelope` to the Shield prefab root if absent. `SHIP.TractorBeam` receives the existing Tractor Beam +prefab. The four `SHIP.*` markers are required exactly once. Ship-specific mesh materials remain authored in +Blender/Unity's material import mapping; the builder only assigns the shared map-icon material. + +## Build, validation, and failure + +Re-exporting an FBX in `Assets/Models/Ships/` schedules a prefab rebuild after Unity finishes importing it. +`Aetheria/Build Ship Prefab From Selected Model` performs the same build on demand. The builder validates all +reserved names, required anchors, component meshes, muzzle numbering, and shared asset references **before** +replacing a prefab. A bad export reports an error and leaves the last valid prefab in place. The output is a +Prefab Variant of the FBX model, so mesh and transform identities remain tied to the source export. + +After adding a ship, author its `HullData` and product in the catalog, set `HullData.Prefab` to the generated +prefab's GUID, and set every `HardpointData.Transform` to an exact `HP.*` name. Run the catalog asset check and +the Unity compile. A visual play check still owns size, materials, shield fit, barrel orientation, and thrust +appearance. The builder cannot infer the 2D schematic from a 3D mesh. + +## Cut and build budget + +This adds an Editor-only builder, its focused smoke, and this contract. It adds no runtime component, persistent field, package, +service, or build target. It replaces per-ship array and shared-reference authoring for newly exported ships. +The focused verification target is Unity Editor script compilation plus a synthetic named-hierarchy check; a +real Blender collection export is required before claiming end-to-end import fidelity. From 6b222564ee42c4a497542b923def4371c76259b3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 01:55:51 +0200 Subject: [PATCH 069/116] Add the operator checklist for merging fire-control-12 to master One file with the checks only the operator can do before the merge, folded into a single play session, with a map pointer on each line. It also lists what was already done, what is not part of this merge, the open questions, and the three uncommitted scene and input files to decide on. Co-Authored-By: Claude Opus 5.5 --- docs/merge-to-master-checklist.md | 113 ++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/merge-to-master-checklist.md diff --git a/docs/merge-to-master-checklist.md b/docs/merge-to-master-checklist.md new file mode 100644 index 000000000..0848b4b2d --- /dev/null +++ b/docs/merge-to-master-checklist.md @@ -0,0 +1,113 @@ +# Aetheria merge checklist: `codex/fire-control-12` into `master` + +Compiled 2026-09-29 by Eyes from the maps on the branch. Pointers are `doc:line` in `docs/` at the branch tip. "Unrecorded" means the maps do not say the check was done. + +**What merging does.** `origin/master..codex/fire-control-12` is 257 commits. It carries the item-provenance work after 2026-09-17, stats-and-power (resolver, power bus, input capacitors, priority tiers, power-supply term and brownout, nominal requests, condition ratio, shield reserve, authored roles), fire-control Cuts 1-12.4(b), locomotion Cut 1 (restored hulls), the shield-panel interceptor, and the Addressables cuts. Self performs the git merge after you sign off. Nothing here is for the merge itself; it is what only you can check first. + +**Correction to the brief.** `codex/cultcache-cutover`, `codex/aetheria-state-rebuild` and `codex/item-provenance` are all ancestors of the branch tip, and the first two are already on `origin/master`. Master's merge commit `9b85211f` (2026-09-17) took cultcache-cutover and item-provenance at their 09-17 tips, after your play smoke passed (`cultcache-migration-cut.md:148`). Master has two commits the branch lacks (`9b85211f`, `263ac421`, which deletes dead narrative fixtures). `git merge-tree` of the two showed no conflicts. No `aetheria-state-rebuild` doc exists. + +## 1. Before you start + +- Open branch `codex/fire-control-12` at its tip, `4b594e116c4063ef5ee1dcb937bab0288008e63e` (2026-09-26, in sync with `origin/codex/fire-control-12`). +- Unity `6000.3.24f1` (`ProjectSettings/ProjectVersion.txt`). +- Batchmode: `tasklist` on 2026-09-29 showed Unity Hub and the licensing client but no `Unity.exe`, so none was running. Batchmode refuses an open editor and the maps say to close it and not kill anything (`item-provenance-cut.md:485,519`). Recheck when you start. +- The Unity manifest pins `cultmath-unity-v0.2.4` (`Packages/manifest.json`); the tag exists in CultLib. Unity needs network access to resolve it on first open (`fire-control-cut.md:2205-2212`). +- **Delete or move `GameData/run.cc` and `GameData/player.cc` before the first launch.** Both are git-ignored and stale (2026-09-22, 91,580 and 1,964 bytes). The maps' fresh-launch checks assume neither exists (`cultcache-migration-cut.md:2745`), and an old run store throws on the first lot lookup (`item-provenance-cut.md:595-596`). The persisted shape has changed since (power tier slot, fuse slot 32). + +**Your uncommitted work.** The merge happens in a separate worktree and does not touch it. `git status` lists 22 modified tracked files and 3 untracked. `git diff --stat` shows content changes in only 9; I infer the other 13 (materials, render textures) differ by line endings, from the LF/CRLF warnings. +- Content changes: `Asset Sources/Adrasteia1.blend`, `Car Paint White.mat`, `Steel.mat`, `Map Gravity Lines.mat`, `Minimap Gravity Lines.mat`, `LiberationSans SDF - Fallback.asset`, `Assets/Scenes/ARPG.unity` (+374), `Assets/Scenes/FieldShieldTest.unity` (+263), `Assets/Scripts/AetheriaInput.cs` (+40). +- Line-ending-only (inferred): the other Map/Sector `.mat` files, 8 `.renderTexture` files, `Stardust.mat`. +- Untracked: `Asset Sources/Adrasteia1_ContourRoute.blend`, `Ships.blend`, `adra1.png`. +- **Decide about three that belong to merged campaigns (my reading of the diffs):** + - `AetheriaInput.cs` (+40) adds the `Cycle Target Item` action (key J). The branch already has that action in `Assets/Resources/Aetheria.inputactions:72,326` and reads it in `ActionGameManager.cs:412`, but not in the generated wrapper. It is the wrapper catching up to committed data. Recommend including it. + - `FieldShieldTest.unity` (+263) adds `ShieldInterceptor`, `ShieldEnvelope`, a `Panel` with `Prototype` and `ShieldPanel`. This is the shield-panel Cut 4 rig you clicked together (`shield-panel-cut.md:840-846`). It is not on the branch. Without it the interceptor code merges with no rig. + - `ARPG.unity` (+374) adds "Debug Info Panel" and "Debug Info Text". `ActionGameManager.DebugInfoText` (`ActionGameManager.cs:151,1287,1333`) is the fire-control debug HUD and it returns early when null. Without the scene change the HUD "hull" factor in step 10 is invisible. + +## 2. One play session, in order + +### 2a. Fixtures to author first + +1. **Scratch catalog for 12.4.** Copy the catalog; do not edit `GameData/Aetheria.cc`. Author one weapon with `Fuse = Proximity` and a `BlastRadius`, and one with `Fuse = Delayed`, a `BlastRadius` and a penetration of about 2 cells (`fire-control-cut.md:3094,2656-2658`). Shipped penetration is at most 0.25 and enclosed cockpits need about 2 cells, so a penetrator authored from shipped values cannot reach the interior (`:3224-3225`). Fields are `aetheria.weaponitemdata` slot 29 (`BlastRadius`) and slot 32 (`Fuse`). Unrecorded: how to point the game at the scratch catalog, and which tool you author with (the map says only "Studio or `AetherDb`", `:2665`). +2. **Studio open, no game running.** You need it at 2c and 2d. Batchmode-based checks are the agent's, not yours. + +### 2b. Fresh launch (no `run.cc`, no `player.cc`) + +3. **Launch to main menu.** Expect no Addressables errors, and the Groups window shows `Assets/Content` under `Default Local Group` (`addressables-cut.md:301`). `player.cc` appears and Continue is disabled (`cultcache-migration-cut.md:2746`). Proves: Addressables Cut 1, CultCache cutover. The 09-17 smoke covered the last two on the old tree; this is a cheap recheck on the new schema. +4. **New Game (tutorial).** Expect the galaxy generates and the start ship is LonginusX (`settings-globals-cut.md:498`, an expectation, not a recorded check for this stack). Expect a Zenith station with a docking bay in the entrance zone every time (`locomotion-cut.md:375`). Proves: locomotion Cut 1 station rule. +5. **Addressables, "Use Asset Database".** Ships, turret and Zenith spawn; thruster and aether-drive particles show; fire an instant weapon (Autocannon) and a charged one (ChargeBlast SG); action bar icons show (`addressables-cut.md:362-366`). Proves: Addressables Cut 2. + +### 2c. Flight, power and economy + +6. **Provenance in play.** Starting-ship items show tier colour; the properties panel shows Manufacturer, product name and flavour for branded gear. Dock and buy: the brand still shows in cargo. `give Lamp` (any design name): no Manufacturer row. Kill an NPC and pick up loot: brand shown (`item-provenance-cut.md:528-539`). Proves: item-provenance Cut C. +7. **Fly a restored hull (Longinus or Djinni).** Thrusters move and turn it (`locomotion-cut.md:501`). Watch the speed cap: `VelocityLimit` is gated by the hull's `Active`, and a hull taken offline with thrusters firing loses the cap (`:377`). It did not happen in 300 s on shipped settings; report if you see runaway speed. Unrecorded: how to spawn a restored hull in play (the map names no path). +8. **Weak reactor (O2).** Fly with a reactor too small for the loadout. Expect visible, legible starvation rather than a cooked reactor (`stats-and-power-cut.md:705-706`). Proves: power bus (map Cut 3). +9. **Brownout and refill (O6, O4).** Brownout reads as degradation, not breakage (`:774`, `:990`). A weapon refilling under brownout stutters instead of firing full rate and cooking the ship (`:729-730`). Proves: power-supply term, brownout, input capacitors. +10. **Memory across zones (O1).** Play across several zones with kills, open the Profiler or Task Manager, and confirm memory does not climb (`:672-673`, `:980-982`). Proves: stat resolver, the entity-leak fix (map Cut 2). Combine with steps 11 and 12. + +### 2d. Combat (against a LonginusX-class AI), then save and Studio + +11. **Fire-control Cut 1 arcs.** Side-mounted weapons still fire at targets abeam; a turret tracks all the way round; nothing fires through the hull (`fire-control-cut.md:397-398`). +12. **Fire-control Cut 3 and shots.** Shots show rolled impacts and misses; a miss reads as a near-miss, not a bug; damage matches the HUD; aiming at a revealed subsystem concentrates damage; kill time is in the same order as before (`:659-661`). Reveal and selection work, kills drop loot, pickup stores it, the tractor beam still pulls (`headless-playground-cut.md:789-794`). No crash or frozen hot ship when the target vanishes (the 2026-09-20 failure, `fire-control-cut.md:1464-1470`). Recheck after the fixes: unrecorded. +13. **12.2, bow then beam.** The HUD `hull` factor reads higher from the beam. Turn your armoured face into a slow missile and watch where it lands (`:2380-2383`). +14. **12.3, launcher into a bow, then a flank.** Use a GT 3K or pswarm launcher. The schematic display pulses the facing edge only (`:2519-2520`). +15. **12.4, scratch catalog.** Watch a proximity airburst beside a LonginusX and a delayed penetrator into its nose. Damage must land where the model shows the blast; this confirms the centre-of-mass anchor. Bow is already confirmed +y (`:3095-3097`, `:2028-2031`). Proves: 12.4(b) `Detonate`. +16. **Wormhole, save, Continue.** Take a wormhole, quit, Continue: same items, same tier and brand (`item-provenance-cut.md:536-537`). +17. **Die.** Continue is disabled and `run.cc` holds no ledger (`:540-541`). +18. **Shield panel, `FieldShieldTest`.** Follow the numbered look in `shield-panel-cut.md:683-700`. Nothing visible on load. Click the nose: a patch grows and the field ripple runs too. Click the flank: the panel lies flat, not tilted. Click the same spot twice: it strikes the same panel. Spam far-apart clicks: at most 12 panels, oldest replaced. Let them fade: none left. Then the Cut 5 payoff: strong hits dice the patch, and repeated hits on one spot break through on a later hit (`:737-739`). Proves: shield-panel Cuts 3, 4, 5. +19. **Studio click-through.** + - Open `Aetheria.cc`. The fire-control behaviour union renders (`fire-control-cut.md:503-506`). + - `LonginusX` schematic still underlays the shape drawer (`addressables-cut.md:370-371`). + - The restored hull records open (`locomotion-cut.md:501`; the map gives no checklist beyond "the Studio click-through"). + - The scratch catalog's new `Fuse` and `BlastRadius` fields edit (from 2a; no map line). + - Open `GameData/run.cc` after step 16. `aetheria.provenanceledger` renders `Lots` with `Attributed` origins and per-role lists. If Studio cannot draw an int-keyed dictionary of unions, that is a Studio defect to raise, not a schema change (`item-provenance-cut.md:541-544`). +20. **Addressables, "Use Existing Build".** Run *Build > New Build > Default Build Script*, then repeat step 5. This proves the player catalog carries GUID keys. Then *Analyze*, "Check Resources to Addressable Duplicate Dependencies", and record the result; 5 known leftovers are expected (`addressables-cut.md:367-370`). + +### 2e. Desk review (not play) + +21. **Role content (O7).** Review `docs/stats-power-cut7-roles.md`. Rows marked "generic, needs review" are arbitrary-seed, not brand identity, and no design yet has a second product with a raised role (`:156`, `:194-202`; `stats-and-power-cut.md:797-799`, `:991`). + +## 3. Feel calls during the session + +Only what the maps name. +- **Balance numbers.** `CommitHorizon` (first value 0.3 s), `SchematicCellSize` (0.35) and the targeting stat ranges are first guesses; the smoke is the arbiter (`fire-control-cut.md:664-665`, `:855-859`). `UnaidedAccuracy` is ruled "really, really bad" (`:253-259`); judge whether it reads as a last resort. +- **Arc rule (Q2).** Arcs apply to the player as well as the AI. The map says it is a real difficulty change to be felt in the smoke (`:836-841`). +- **AI holds fire more** once `pOnHull` prices shots. The map calls that tuning, not broken AI (`:1396-1400`). +- **Penetration retune (F12-2)** is a ruled follow-up, "Retuning penetration is a follow up" (`:3223-3229`). The map does not tie it to a smoke. The only link is 2a step 1: shipped penetrators cannot reach interiors, so step 15 needs the scratch value. +- **Locomotion Q6, yaw weight (w = 3 recommended).** The map says settle it in the Cut 1 play check (`locomotion-cut.md:372-373`, `:903-904`), but the weight belongs to Cut 3's solver, which has not landed. There is nothing to feel today. Ambiguity, not a merge gate. + +## 4. Already done: do not redo + +- Play smoke for cultcache-cutover plus item-provenance, 2026-09-17: new game, combat, loot, save and Continue with brand and quality intact, death clears the run, stance toggle, `FieldShieldTest` (`cultcache-migration-cut.md:148-153`). It ran on the old tree, so steps 3, 6, 16 and 17 are rechecks, not repeats of a gap. +- Studio click-through for the CultCache port, 2026-09-17: AmmoType refs repointed at `515859cf`, `dangling` at 0, schematic underlay restored and checked (`cultcache-migration-cut.md:161-165`). +- Bow is +y on the schematic, confirmed 2026-09-22 from the schematic images (`fire-control-cut.md:2028-2029`). The blast-anchor half is still open (step 15). +- Shield-panel play evidence, 2026-09-18, rigged scene: a panel spawns, is reused and spiderwebs; it shatters only near the rim (`shield-panel-cut.md:119-124`). Partial: the Cut 5 fixes landed after, so step 18 is still open. +- A play smoke on 2026-09-20 found the frozen-ship, no-hits and `LockWeapon` crash (`fire-control-cut.md:1464-1470`). That was a failure report, not a pass. Cuts 8 and 9 answered it and no recheck is recorded. +- Stats-and-power Cuts 0-1 landed with Soul verification and your re-author ruling (`stats-and-power-cut.md:79-99` onward). No operator check remains from them. + +## 5. Not part of this merge + +- Stats-and-power tier control in the schematic UI (O5) and the deletion of `IOrderedBehavior`/`SortPosition` (O3): no `PowerTier` under `Assets/Scripts/UI` at the tip, and `SortPosition`/`IOrderedBehavior` still exist in `Reactor.cs`, `Entity.cs` (my grep). `stats-and-power-cut.md:752-753`, `:985-989`. +- Stats-and-power UI reconciliation (O8: properties panel, trade menu, schematic HUD, Studio over a stat with terms): the map's Cut 8 has not landed; the branch's "Cut 8" commits are the condition ratio (`:819`, `:991-992`). +- Settings-into-globals: only Cut 0 (delete 5 dead assets, `ea8b420e`) landed. Its play smoke and Studio click-through wait for Cut 1 (`settings-globals-cut.md:498-511`). The map header still says nothing landed. +- Build delivery: the two commits are the map and a ruling (`build-delivery-cut.md:5`, operator checks `:252,324,360,404`). +- Locomotion Cuts 2-5, including Q6 and Q4 (`locomotion-cut.md:360,905`). +- `content-batch-one.md` is a spec that writes no catalog data (`:5-8`). +- Fire-control follow-ups F12-2 to F12-8 (`fire-control-cut.md:3223-3250`). + +## 6. Open questions still yours + +- **Q12-5 (not ruled):** should the HUD show which target items are exposed from the current bearing? Recommended as a follow-up after 12.3, on the target-item cycling UI (`fire-control-cut.md:1959`, `:3215-3218`). +- **Locomotion Q4:** heading versus aim; blocks Cut 5 only (`locomotion-cut.md:905-913`, `:372`). +- **Locomotion Q6:** the yaw weight; open, does not block (`:372-373`). +- **Defaults Self took in 12.4, which you may overrule** (`fire-control-cut.md:3208-3213`): a blast ignores `DamageSpread` and P sits on the centre lane; `IncomingHit` fires for the host of a contact or delayed hit only; the delayed fuse point clamps to the last reached cell's exit; `Detonate` runs a flat armour, pool, hull pass. +- **Stale headers, not open questions:** the fire-control 12.3 "pending ruling" at `:2581` was ruled at `:2588`; Q12-9 is ruled A (`:3171`); the shield-panel Q1-Q7 block (`shield-panel-cut.md:61`, `:800-`) was not updated after Cuts 1-5 landed, Q5 being ruled C (`:839-846`). + +## 7. What happens after you sign off + +- **Self merges `codex/fire-control-12` to `master` with `--no-ff`**, in a separate worktree. That single merge carries item-provenance, the stats-and-power work, fire control and locomotion Cut 1. The cultcache-cutover and item-provenance merge into master already happened on 2026-09-17 (`9b85211f`, `cultcache-migration-cut.md:148`), so the maps' "merge the two together" instruction (`item-provenance-cut.md:82`) is spent. The tree merges clean today. +- Any of section 1's three uncommitted files you want included need to be committed to the branch first, or they stay out. +- **Soul gates outstanding:** + - Fire-control 12.4 Soul passes: running tonight. The 12.4 section carries no "closed" status yet (`fire-control-cut.md:2602`). 12.0-12.3 are closed (`:2168-2210`, `:2253`, `:2385`, `:2522`). + - CultMath bounded least squares is locomotion Cut 2, not in this stack (`locomotion-cut.md:508`, `:360`). It gates nothing here. + - Unrecorded: a Unity batchmode compile at the tip `4b594e11`. 12.2 and 12.3 record clean compiles at their own heads. +- **Soul status at merge time: Self fills in.** From 8b4bf01eacd6f0e83c51d5a0adea60c190b7087e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:38:14 +0200 Subject: [PATCH 070/116] Fire control 12.4 fix batch: tangent disc, candidate cull, lethal blast F1: RectDiskOverlap counted a whole square when a cell edge sat exactly r from the disc centre (`h < yhi`, `-h > ylo`). The comparisons are inclusive now, so the chord bounds the piece. A proximity burst on an integer centre of mass delivered 127.32 of 100 at BlastRadius 1 and 100.80 at 3. F2: delete Detonate's distance prefilter and the radius <= 0 guard. The prefilter measured from the centre of mass with the bounding box's half-diagonal, which is not a bound on how far a hull's cells reach; it culled real overlap on an L-shaped hull and on the shipped Longinus corners. RectDiskOverlap is the single decider. An untouched entity is still skipped (its covered list is never allocated). F3: Detonate iterates a snapshot of zone.Entities, as Zone.Update does. A lethal blast removed the entity from the live collection and the next MoveNext threw, leaving the shot pending so it detonated again the next tick. Tests: tangent discs through Fire and Detonate, an L-shaped hull, grazing blasts at the farthest corner of Longinus and LonginusX (expected values from a numeric overlap), and a lethal blast that must resolve once. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/FireControl.cs | 35 ++-- .../FireControlCut124Tests.cs | 177 +++++++++++++++++- 2 files changed, 190 insertions(+), 22 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index c76ffcc19..8e8cc53c4 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -1073,23 +1073,20 @@ public static class FireControl // DamageHull call. public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType) { - // F3 (Soul, Cut 12.3 fold-in): Detonate cannot be reached with a nonpositive radius. Fire only ever - // freezes a Fuse when BlastRadius > 0 (12.4(a)), so this guard matters only for a direct caller (a - // test, or Mine.Explode with a zero BlastRange). - if (radius <= 0f) return; - - foreach (var entity in zone.Entities) + // No distance prefilter: RectDiskOverlap is the one decider of whether the disc touches a hull. A + // prefilter measured from Position (the centre of mass) with the bounding box's half-diagonal is not a + // bound on how far a hull's cells reach from it -- it culled real overlap on an L-shaped hull and on the + // shipped Longinus corners. A nonpositive radius needs no guard either: the overlap of a disc with no + // area is zero for every cell, so nothing is covered and nothing is delivered. + // + // The pass runs over a snapshot: a lethal blast fires an entity's Death, which removes it from + // Zone.Entities, and the live enumeration threw on the next MoveNext (the same idiom Zone.Update uses). + foreach (var entity in zone.Entities.ToArray()) { var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; var shape = hullData.Shape; - // Candidates: |Position.xz - P| <= radius + half the hull's own diagonal, in world units -- catches - // a long hull whose end lies inside the blast even though its centre does not. - var toEntity = length(entity.Position.xz - worldPlanar); - var halfDiagonal = .5f * length(float2(shape.Width, shape.Height)) * cellSize; - if (toEntity > radius + halfDiagonal) continue; - var centre = entity.ToSchematicPoint(worldPlanar); var rCells = radius / cellSize; var normaliser = PI * rCells * rCells; @@ -1097,17 +1094,18 @@ public static class FireControl // Shares, computed once, in hull Coordinates order -- the same order armour absorbs in below and // an item's pool deposits in. var coords = shape.Coordinates; - var covered = new List<(int2 Cell, float Share)>(); + List<(int2 Cell, float Share)> covered = null; var totalShare = 0f; for (var i = 0; i < coords.Length; i++) { var overlap = CircleSquareOverlap(centre, rCells, coords[i]); if (overlap <= 0f) continue; var share = damage * overlap / normaliser; - covered.Add((coords[i], share)); + (covered ??= new List<(int2 Cell, float Share)>()).Add((coords[i], share)); totalShare += share; } - if (covered.Count == 0) continue; + // An entity the disc does not touch has nothing to decide: no shield is consulted, nothing is dealt. + if (covered == null) continue; var shield = entity.Shield; var shieldActive = shield != null && shield.Item.Active.Value; @@ -1209,8 +1207,11 @@ public static class FireControl var mid = .5f * (a + b); var h = sqrt(max(0f, r * r - mid * mid)); - var upperIsDisc = h < yhi; - var lowerIsDisc = -h > ylo; + // Inclusive: a rectangle edge exactly tangent to the disc (h == yhi at a piece whose midpoint is 0) + // is the disc's own boundary, so the chord is the true bound there. A strict test sent that piece to + // the whole-rectangle branch and counted the square in full -- a boundary flip that creates damage. + var upperIsDisc = h <= yhi; + var lowerIsDisc = -h >= ylo; var upper = upperIsDisc ? h : yhi; var lower = lowerIsDisc ? -h : ylo; if (upper <= lower) continue; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 8460947c2..78001d914 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -90,12 +90,13 @@ public sealed class FireControlCut124Tests : IDisposable WeaponModifiers modifiers = WeaponModifiers.None, bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true, int2? cockpitCell = null, float cockpitDurability = 1000f, - (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null) + (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null, + float accuracy = 1f, int2? gunCell = null) { var hullData = new HullData { Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000000, Mass = 1000, Armor = 0, - Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = gunCell ?? new int2(0, 0), Shape = new Shape() } } }; var shooterHullData = new HullData { @@ -125,7 +126,7 @@ public sealed class FireControlCut124Tests : IDisposable { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, - Behaviors = { new TargetingSystemData { Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } + Behaviors = { new TargetingSystemData { Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } }); cache.Upsert(new GearData { @@ -191,7 +192,7 @@ public sealed class FireControlCut124Tests : IDisposable var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); var targetGun = MakeWeapon(lot++); - Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + Assert.True(target.TryEquip(targetGun, gunCell ?? new int2(0, 0))); EquippedItem cockpit = null; if (cockpitCell != null) @@ -369,6 +370,171 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(e.Target.Hull.Durability < before, "the near end of a long hull must take damage even though its centre is far outside the radius"); } + // Per-cell armour large enough that ArmorAbsorb never depletes it: every ArmorDamage event then reports its + // cell's raw share, whether or not an item sits on the cell. + private static void ArmourEverything(Engagement e) + { + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1e6f; e.Target.MaxArmor[c.x, c.y] = 1e6f; } + } + + // The independent expectation: `damage` shared over a disc of radius rCells centred at schematic (cx,cy) in + // proportion to numeric overlap with each occupied cell. + private static double ExpectedDelivered(Shape shape, double cx, double cy, double rCells, double damage) + { + var overlap = 0.0; + foreach (var c in shape.Coordinates) overlap += DiscAreaInCell(cx, cy, rCells, c); + return damage * overlap / (Math.PI * rCells * rCells); + } + + // ==== 12.4 fix batch F1: a disc tangent to a cell edge must not count the whole square ==== + + // A cell edge exactly r from the centre, with the piece's midpoint at 0, used to fall through + // `h < yhi` / `-h > ylo` into the "whole rectangle" branch, so the square counted in full. BlastRadius 1 + // world unit is half a cell (edges at +-.5 from an integer centre of mass), 3 is one and a half (edges at + // +-.5 and +-1.5): both are tangent to cell edges. Measured through Fire before the fix: 127.32 and 100.80 + // delivered of 100. + [Theory] + [InlineData(1f)] + [InlineData(3f)] + public void ATangentDiscDeliversExactlyItsDamageThroughFire(float blastRadius) + { + var e = Build(TestSettings(), SolidShape(5, 5), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: blastRadius, damage: 100f); + ArmourEverything(e); + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.Equal(100f, total, 2); + } + + [Theory] + [InlineData(1f)] + [InlineData(3f)] + public void ATangentDiscDeliversExactlyItsDamageThroughDetonate(float radius) + { + var e = Build(TestSettings(), SolidShape(5, 5)); + ArmourEverything(e); + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius, 100f, DamageType.Kinetic); + Assert.Equal(100f, total, 2); + } + + // ==== 12.4 fix batch F2: the exact overlap is the only decider of "the disc touches the hull" ==== + + // An L-shaped hull whose centre of mass sits far from the far end of its long arm. The blast lands on that + // arm's end, 14 world units from the centre of mass -- beyond radius + half the bounding box's diagonal + // measured from it, which the old candidate cull used, so the cull threw away a blast that overlaps a + // cell. Expected value: a numeric overlap of the cells, not the code under test. + [Fact] + public void ABlastAtTheFarEndOfAnLShapedHullLands() + { + var s = new Shape(10, 4); + foreach (var c in s.AllCoordinates) s[c] = c.x < 4 || c.y == 0; + var e = Build(TestSettings(), s); + ArmourEverything(e); + var com = e.HullData.Shape.CenterOfMass; + var p = float2(9.9f, 0f); + var expected = ExpectedDelivered(e.HullData.Shape, p.x, p.y, 1.0, 100.0); + Assert.True(expected > 10.0, $"fixture: the blast must overlap the hull, expected {expected}"); + + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz + (p - com) * 2f, 2f, 100f, DamageType.Kinetic); + + Assert.Equal(expected, total, 1); + } + + // The same rule on the shipped hulls: a blast just grazing the corner farthest from the centre of mass. The + // corners of the shipped Longinus and LonginusX reach past the old cull's box-half-diagonal bound. + [Theory] + [InlineData("Longinus")] + [InlineData("LonginusX")] + public void AGrazingBlastAtAShippedHullsFarthestCornerLands(string hullName) + { + Shape shape; + var cache = OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), out _); + try { shape = cache.GetByName(hullName).Shape; } + finally { cache.Dispose(); } + + var com = shape.CenterOfMass; + var far = float2.zero; + var best = -1f; + foreach (var c in shape.Coordinates) + foreach (var (dx, dy) in new[] { (-.5f, -.5f), (.5f, -.5f), (-.5f, .5f), (.5f, .5f) }) + { + var corner = float2(c.x + dx, c.y + dy); + var d = length(corner - com); + if (d > best) { best = d; far = corner; } + } + // One cell of radius, centred .9 cell outward of the corner: the disc reaches the corner by .1 cell. + var centre = far + normalize(far - com) * .9f; + + var e = Build(TestSettings(), shape, gunCell: shape.Coordinates[0]); + ArmourEverything(e); + var expected = ExpectedDelivered(shape, centre.x, centre.y, 1.0, 100.0); + Assert.True(expected > .05, $"fixture: the blast must graze the hull, expected {expected}"); + + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz + (centre - com) * 2f, 2f, 100f, DamageType.Kinetic); + + Assert.Equal(expected, total, 1); + } + + // ==== 12.4 fix batch F3: a lethal blast must not break the entity enumeration ==== + + private Ship AddShip(Engagement e, float3 position, float hullDurability, int lot) + { + var hullRef = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Hull")); + var ship = new Ship(e.Items, e.Zone, new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = lot }, new EntitySettings()); + var marker = new EquippableItem + { + Data = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Marker")), Durability = 1000000, Lot = lot + 1 + }; + Assert.True(ship.TryEquip(marker, new int2(1, 1))); + e.Zone.Entities.Add(ship); + ship.Activate(); + ship.Position = position; + return ship; + } + + // A blast that kills an entity mid-pass: death removes it from Zone.Entities, and the next MoveNext of the + // live enumeration threw. The shot was never removed, so it detonated again the next tick (the probe saw + // 2.69 then 5.38 on the target). Pass: nothing throws, the shot resolves and leaves exactly once, and the + // target is damaged exactly once. + [Fact] + public void ALethalBlastResolvesOnceAndBreaksNothing() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var near = AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300); + var far = AddShip(e, e.Shooter.Position + float3(-5000, 0, 0), 1f, 302); + + var resolved = 0; + var targetDamage = 0f; + using var r = e.Zone.ShotResolved.Subscribe(_ => resolved++); + using var a = e.Target.ArmorDamage.Subscribe(x => targetDamage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => targetDamage += x); + using var i = e.Target.ItemDamage.Subscribe(x => targetDamage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Empty(e.Zone.PendingShots); + Assert.Equal(1, resolved); + Assert.DoesNotContain(near, e.Zone.Entities); // fixture: the blast really was lethal to a ship mid-pass + Assert.Contains(far, e.Zone.Entities); + Assert.True(targetDamage > 0f, "fixture: the target must be inside the blast"); + var afterFirstTick = targetDamage; + + e.Zone.Update(.01f); + + Assert.Equal(1, resolved); + Assert.Equal(afterFirstTick, targetDamage, 4); + } + // ==== Absorption order ==== // ABlastIsAbsorbedThroughArmourFirstPerCell (replaces AbsorbSpendsArmourBeforeTheItemOnTheSameCell): a disc @@ -894,7 +1060,8 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(e.Target.Hull.Durability <= before); } - // Soul F3: Detonate cannot be reached with a nonpositive radius. + // Detonate has no radius guard: a nonpositive radius covers no cell (a disc with no area overlaps nothing), + // so it delivers nothing and touches no shield. [Theory] [InlineData(0f)] [InlineData(-5f)] From 76e98598b1dc75a089a1f17d52c1c961b010be40 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:48:29 +0200 Subject: [PATCH 071/116] Fire control 12.4 fix batch F4: tests that could not fail now can Each rule below was protected only by a test a mutant passed. New or strengthened tests, with the mutant each one kills: - AContactOrDelayedBlastOnAMissDoesNothing: ApplyBlastHit's miss guard. - ToWorldPointIsTheInverseOfToSchematicPointAtANonAxisFacing and AContactBlastLandsOnTheCellItHitAtANonAxisFacing: ToWorldPoint's right axis, asserted per cell at facing (2,1); TurningAfterCommit now compares per cell. - ProximityBurstPositionIsThePredictedInterceptForAMovingTarget: a moving target and an intercept solved in the test, not Durability <= before. - AnUntouchedEntitysShieldIsNotConsulted: Detonate's skip of untouched entities. - TheFusePointStaysOnMetalAcrossAConcaveGap: the delayed point clamps at the first gap (the lane's walk ends there). - ShieldPaysForWhatReachesIt asserts the reserve dropped by the covered share; AShieldRaisedBetweenCommitAndArrivalAbsorbsTheBlast and AShieldDroppedBetweenCommitAndArrivalDoesNotAbsorbTheBlast replace the outcome-flags-only check with the shield state flipped between commit and arrival. Co-Authored-By: Claude Sonnet 5 --- .../FireControlCut124Tests.cs | 273 ++++++++++++++++-- 1 file changed, 242 insertions(+), 31 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 78001d914..d976f1a1c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -760,11 +760,40 @@ public sealed class FireControlCut124Tests : IDisposable var cellSize = e.Items.GameplaySettings.SchematicCellSize; var worldCentre = e.Target.Position.xz + float2(-2.5f * cellSize, 0); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); // fixture: the reserve is exactly full + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70.5f)); + // damage 100, half retained (50) -- 50 <= capacity 70, so a correct implementation absorbs it whole and // never breaks; a "charge the full 100" mutant would find 100 > 70 and break the shield instead. + var cellEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => cellEvents++); FireControl.Detonate(e.Zone, worldCentre, .5f * cellSize, 100f, DamageType.Kinetic); Assert.False(e.Target.Shield.Broken); + Assert.Equal(0, cellEvents); // the shield took it all, nothing reached a cell + // The reserve dropped by the covered share and by nothing else: 70 - 50 = 20 left. It is read through the + // public query as a threshold probe, since no recharge runs between Detonate and here. + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 19.9f)); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 20.1f)); + } + + // An entity the disc does not touch has nothing to decide: it is skipped whole, its active shield is not + // consulted and nothing is dealt. The blast lands 20 cells away from a shielded hull. + [Fact] + public void AnUntouchedEntitysShieldIsNotConsulted() + { + var e = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 70f); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); + var events = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => events++); + using var h = e.Target.HullDamage.Subscribe(_ => events++); + + FireControl.Detonate(e.Zone, e.Target.Position.xz + float2(40f, 0f), 2f, 1000f, DamageType.Kinetic); + + Assert.Equal(0, events); + Assert.False(e.Target.Shield.Broken); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); } // ABlastShotsShieldIsDecidedAtDetonation (Q12-9 = A): Commit decides no shield for a shot that carries a @@ -792,6 +821,72 @@ public sealed class FireControlCut124Tests : IDisposable Assert.False(outcome.ShieldBroken); } + // The other half of Q12-9 = A: the shield's state at arrival, not at commit, decides. A contact shot in + // 2 s of flight commits 1.5 s in; the shield is flipped between commit and arrival, and the blast (radius + // 15 cells, so the whole 20-cell hull lies inside the disc and the covered share is exactly + // damage * 20 / (pi * 15^2)) must follow the state it finds. The reserve is read through the public + // CanTakeHit as a threshold probe: nothing recharges between Detonate and the read. + [Fact] + public void AShieldRaisedBetweenCommitAndArrivalAbsorbsTheBlast() + { + const float capacity = 1000f; + var share = 50f * 20f / (PI * 15f * 15f); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 30f, damage: 50f, + equipShield: true, shieldCapacity: capacity, shieldActive: false); + ArmourEverything(e); + var cellDamage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => cellDamage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => cellDamage += x); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); + Assert.True(committed.Hit); + Assert.False(committed.Shielded); + Assert.False(committed.ShieldBroken); + + e.Target.Shield.Item.Enabled.Value = true; // up before arrival, down at commit + for (var i = 0; i < 4; i++) e.Zone.Update(.05f); + Assert.True(e.Zone.PendingShots.Any(s => s.ShotId == shotId), "fixture: the shot must still be in flight"); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - .1f), "fixture: the reserve must be full"); + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); + + Assert.Equal(0f, cellDamage); // the shield decided at arrival, so no cell took anything + Assert.False(e.Target.Shield.Broken); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - share - .05f)); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - share + .05f)); + } + + // And the reverse: up at commit, down at arrival -- the blast finds no shield and lands on the cells, and + // the reserve is not charged. + [Fact] + public void AShieldDroppedBetweenCommitAndArrivalDoesNotAbsorbTheBlast() + { + const float capacity = 1000f; + var share = 50f * 20f / (PI * 15f * 15f); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 30f, damage: 50f, + equipShield: true, shieldCapacity: capacity); + ArmourEverything(e); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + var armour = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => armour += x.damage); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); + Assert.False(committed.Shielded); + + e.Target.Shield.Item.Enabled.Value = false; + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); + + Assert.Equal(share, armour, 2); // the whole covered share landed on the cells + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - .1f)); // and the reserve was not charged + } + // ==== Fuses (through Fire, SchematicCellSize 2) ==== // PenetratorBurrowsBeforeItBursts, which pins Q12-8 in both halves. A 3x3 solid hull's only interior cell @@ -880,22 +975,135 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(expected, delivered, 0); } + // TheFusePointStaysOnMetal, the concave half: a 1x5 column with cell (0,2) missing. A lane's walk ends at + // its first gap, so the reached metal is cells (0,0) and (0,1) whatever the penetration, and the fuse point + // must clamp to the last reached cell's exit (schematic y 1.5, the metal's edge) rather than sit at + // entry + penetration inside the gap (y 2.4 at penetration 2.9) or on the far segment the walk never + // reached (y 2.7 at 3.2). Entries run from -.5, so at penetration 1.5 the point is y 1.0, inside cell (0,1), + // with the disc wholly on metal. + [Theory] + [InlineData(1.5f, 1.0)] + [InlineData(2.9f, 1.5)] + [InlineData(3.2f, 1.5)] + public void TheFusePointStaysOnMetalAcrossAConcaveGap(float penetration, double pointY) + { + var shape = SolidShape(1, 5); + shape[new int2(0, 2)] = false; + var e = Build(TestSettings(), shape, penetration: penetration, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 100f); + var comX = e.HullData.Shape.CenterOfMass.x; + var rCells = .3 / e.Items.GameplaySettings.SchematicCellSize; + var expected = ExpectedDelivered(e.HullData.Shape, comX, pointY, rCells, 100.0); + + var before = e.Target.Hull.Durability; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(expected, before - e.Target.Hull.Durability, 0); + } + + // A contact or delayed blast needs a committed hit, exactly like a direct hit's own gate: on a miss nothing + // happens -- no blast, no IncomingHit. Accuracy 0 pins the roll to a miss. Kills: ApplyBlastHit without its + // `Outcome.Hit` guard, which would detonate at a point derived from a miss's empty geometry. + [Theory] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AContactOrDelayedBlastOnAMissDoesNothing(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 500f, accuracy: 0f); + ArmourEverything(e); + var before = e.Target.Hull.Durability; + var events = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => events++); + using var h = e.Target.HullDamage.Subscribe(_ => events++); + using var i = e.Target.ItemDamage.Subscribe(_ => events++); + using var hit = e.Target.IncomingHit.Subscribe(_ => events++); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.False(outcome.Hit); // fixture: a guaranteed miss + Assert.Empty(e.Zone.PendingShots); + Assert.Equal(0, events); + Assert.Equal(before, e.Target.Hull.Durability); + } + + // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre + // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At + // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. + [Fact] + public void ToWorldPointIsTheInverseOfToSchematicPointAtANonAxisFacing() + { + var facing = normalize(float2(2, 1)); + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: facing); + var t = e.Target; + var com = e.HullData.Shape.CenterOfMass; + var starboard = float2(facing.y, -facing.x); + + var world = t.Position.xz + float2(7f, -3f); + var back = t.ToWorldPoint(t.ToSchematicPoint(world)); + Assert.Equal(world.x, back.x, 3); + Assert.Equal(world.y, back.y, 3); + + var oneToStarboard = t.ToWorldPoint(com + float2(1, 0)); + Assert.Equal(t.Position.x + 2f * starboard.x, oneToStarboard.x, 3); + Assert.Equal(t.Position.z + 2f * starboard.y, oneToStarboard.y, 3); + var oneToBow = t.ToWorldPoint(com + float2(0, 1)); + Assert.Equal(t.Position.x + 2f * facing.x, oneToBow.x, 3); + Assert.Equal(t.Position.z + 2f * facing.y, oneToBow.y, 3); + } + + // A contact blast on a hull at facing (2,1): the shot enters over the starboard side, off the hull's own + // centre line, so the blast point's lateral offset from the centre of mass is not zero and a flipped right + // axis in ToWorldPoint would mirror it onto the far side. Placement is asserted per cell: the impact cell + // takes a share, and no cell farther than one from it takes anything (radius .15 cells). Kills: a flip of + // ToWorldPoint's right axis, which the host's round trip would otherwise hide from every total. + [Fact] + public void AContactBlastLandsOnTheCellItHitAtANonAxisFacing() + { + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: float2(2, 1), fuse: WeaponFuse.Contact, blastRadius: .3f, damage: 100f); + ArmourEverything(e); + var deposits = new Dictionary(); + using var a = e.Target.ArmorDamage.Subscribe(x => deposits[x.pos] = (deposits.TryGetValue(x.pos, out var d) ? d : 0f) + x.damage); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.True(deposits.ContainsKey(outcome.Cell), $"the impact cell {outcome.Cell} took nothing; damaged: {string.Join(",", deposits.Keys)}"); + foreach (var cell in deposits.Keys) + Assert.True(Math.Abs(cell.x - outcome.Cell.x) <= 1 && Math.Abs(cell.y - outcome.Cell.y) <= 1, + $"cell {cell} took damage far from the impact cell {outcome.Cell}"); + } + // TurningAfterCommitDoesNotMoveTheBlastOnItsHost: a contact-fuse shot. After commit and before arrival, the // host turns and moves. Pass: the host's per-cell damage equals that of the same shot with no turn. Kills: // taking P from the live bearing, or from Outcome.Cell's centre in place of the lane point. [Fact] public void TurningAfterCommitDoesNotMoveTheBlastOnItsHost() { - float TotalArmorDamage(Action afterCommit) + Dictionary ArmorDamageByCell(Action afterCommit) { var e = Build(TestSettings(commitHorizon: .5f), SolidShape(5, 4), velocity: 50, targetRange: 100, fuse: WeaponFuse.Contact, blastRadius: 3f, damage: 100f); foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } - var total = 0f; - using var a = e.Target.ArmorDamage.Subscribe(x => total += x.damage); - using var h = e.Target.HullDamage.Subscribe(x => total += x); - using var i = e.Target.ItemDamage.Subscribe(x => total += x.damage); + // Per cell, not a total: a blast that lands on the wrong cells deals the same total. + var perCell = new Dictionary(); + using var a = e.Target.ArmorDamage.Subscribe(x => perCell[x.pos] = (perCell.TryGetValue(x.pos, out var d) ? d : 0f) + x.damage); var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); // Step to just past commit (flight time 2s, commit horizon 0.5s -> commits at t ~= 1.5s). @@ -907,18 +1115,19 @@ public sealed class FireControlCut124Tests : IDisposable while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); - return total; + return perCell; } - var noTurn = TotalArmorDamage(null); - var turned = TotalArmorDamage(e => + var noTurn = ArmorDamageByCell(null); + var turned = ArmorDamageByCell(e => { e.Target.Direction = normalize(float2(1, 0)); e.Target.Position += float3(50, 0, -30); }); - Assert.True(noTurn > 0f); - Assert.Equal(noTurn, turned, 0); + Assert.NotEmpty(noTurn); + Assert.Equal(noTurn.Keys.OrderBy(k => k.x).ThenBy(k => k.y), turned.Keys.OrderBy(k => k.x).ThenBy(k => k.y)); + foreach (var cell in noTurn.Keys) Assert.Equal(noTurn[cell], turned[cell], 2); } // LabelsDoNotDecideBehaviour: a weapon labelled Airburst with no fuse resolves as a direct hit, and a @@ -1031,33 +1240,35 @@ public sealed class FireControlCut124Tests : IDisposable } } - // Soul F2: no test pinned BurstPosition for a moving target. A proximity blast against a moving target - // detonates at the predicted intercept PredictedIntercept froze at Fire, not at the target's own arrival - // position. Kills: BurstPosition read from the target's live position instead of the frozen intercept. + // Fire freezes a proximity shot's burst point at the predicted intercept of a moving target -- where a shot + // of the weapon's own velocity, fired now, meets the target -- not at the target's position now. The + // expectation is solved here from first principles: |d + v t| = s t for the flight time t, planar, the + // shooter's own motion never fed in. Target 100 ahead, drifting (30,0) against a 50 unit/s shot: t = 2.5 s, + // so the burst sits 75 units to the side of where the target is at Fire. + // Kills: BurstPosition frozen at the target's current position (PredictedIntercept never consulted). [Fact] public void ProximityBurstPositionIsThePredictedInterceptForAMovingTarget() { - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, targetRange: 200, + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 50f); - // Give the target a lateral velocity right after Fire so it does not sit at its fire-time position by - // arrival, but the weapon's own Velocity (0, an instant hit) still froze the intercept as exactly the - // fire-time target position (PredictedIntercept's own fallback below the .01f velocity floor). + var v = float2(30f, 0f); + e.Target.Velocity = v; + + var s = e.Shooter.Position.xz; + var d = e.Target.Position.xz - s; + var a = dot(v, v) - 50f * 50f; + var b = 2f * dot(d, v); + var c = dot(d, d); + var t = (-b - sqrt(b * b - 4f * a * c)) / (2f * a); // a < 0: the positive root + Assert.Equal(2.5f, t, 3); + var expected = e.Target.Position.xz + v * t; + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); - var frozenBurst = shot.BurstPosition; - Assert.Equal(e.Target.Position.x, frozenBurst.x, 1); - Assert.Equal(e.Target.Position.z, frozenBurst.z, 1); + var shot = e.Zone.PendingShots.Single(x => x.ShotId == shotId); - e.Target.Velocity = float2(30, 0); - var before = e.Target.Hull.Durability; - e.Zone.Update(1f); // move the target well away from the frozen burst position, then resolve - - // The blast still lands where PredictedIntercept froze it (the fire-time position), not where the - // target ended up -- so it still damages the target only because the target has not yet moved far - // enough at THIS shot's own (velocity-0, instant) arrival, which happens before the move above can - // matter: Zone.Update(1f) advances both the shot and the target in the same step, but the shot resolves - // using the frozen BurstPosition regardless of where the target is by then. - Assert.True(e.Target.Hull.Durability <= before); + Assert.Equal(expected.x, shot.BurstPosition.x, 2); + Assert.Equal(expected.y, shot.BurstPosition.z, 2); + Assert.True(length(shot.BurstPosition.xz - e.Target.Position.xz) > 70f, "the burst must lead the target, not sit on it"); } // Detonate has no radius guard: a nonpositive radius covers no cell (a disc with no area overlaps nothing), From fd792013582cb5a1f270479d5880ff6534ea81b1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 04:17:43 +0200 Subject: [PATCH 072/116] Fire control 12.4 fix batch F5: measure the steady state, not the first window LaneAndSilhouetteAllocateNothingGivenAPooledBuffer failed in 1 of 12 full-suite runs (1232 bytes over 1000 Lane calls) and in 0 of 100 runs of the test alone or its class. The allocation is a one-off, not a per-call cost: under suite load the runtime's background tier-up is delayed by other tests' JIT activity, so first-use allocation lands inside the single measured window. The rule stays: Lane and buffered Silhouette allocate nothing. The test now measures up to 20 rounds of 1000 calls and requires the smallest round to be zero. A per-call allocation appears in every round and still fails it. Co-Authored-By: Claude Sonnet 5 --- .../FireControlCut12Tests.cs | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 964c2bf2e..30cf46356 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -1352,22 +1352,35 @@ public sealed class FireControlCut12Tests : IDisposable for (var i = 0; i < 100; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); // JIT warm-up for (var i = 0; i < 100; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < 1000; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); - var laneAllocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.True(laneAllocated == 0, $"1000 Lane calls allocated {laneAllocated} bytes; \"never allocates\" is not true"); + // Steady state, not first-use: under a whole-suite run the runtime's background tier-up is delayed by the + // other tests' JIT activity, so a one-off allocation (measured: 1232 bytes over 1000 calls, in 1 of 12 + // full-suite runs and 0 of 100 isolated runs) can land inside a single window. That is a cost paid once. + // A per-call allocation, the regression this test guards, lands in EVERY round, so the smallest round + // over a bounded number of rounds is the steady-state allocation. + long SmallestRoundAllocation(Action round) + { + var smallest = long.MaxValue; + for (var r = 0; r < 20 && smallest != 0; r++) + { + var start = GC.GetAllocatedBytesForCurrentThread(); + round(); + smallest = Math.Min(smallest, GC.GetAllocatedBytesForCurrentThread() - start); + } + return smallest; + } - before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < 1000; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); - var silhouetteAllocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.True(silhouetteAllocated == 0, $"1000 buffered Silhouette calls allocated {silhouetteAllocated} bytes"); + var laneAllocated = SmallestRoundAllocation(() => { for (var i = 0; i < 1000; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); }); + Assert.True(laneAllocated == 0, $"1000 Lane calls allocated {laneAllocated} bytes in every round; \"never allocates\" is not true"); + + var silhouetteAllocated = SmallestRoundAllocation(() => { for (var i = 0; i < 1000; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); }); + Assert.True(silhouetteAllocated == 0, $"1000 buffered Silhouette calls allocated {silhouetteAllocated} bytes in every round"); // Recorded, not asserted zero: the full gated-in HitProbability call still allocates, from Entity's own // collection enumeration (GetBehavior over ReactiveCollection), not from anything this fix batch // touches. A loose upper bound catches a real regression (e.g. a reintroduced comparer wrapper) without // pretending Entity's own cost is Lane's or Silhouette's to fix here. for (var i = 0; i < 100; i++) FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); - before = GC.GetAllocatedBytesForCurrentThread(); + var before = GC.GetAllocatedBytesForCurrentThread(); var sum = 0f; for (var i = 0; i < 1000; i++) sum += FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); var hitProbabilityAllocated = GC.GetAllocatedBytesForCurrentThread() - before; From 4629bc6799c5fc7da8e9be47b0ec5129ed595bcd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 07:36:04 +0200 Subject: [PATCH 073/116] Fire control 12.4 closing batch: Death fires once; pin the blast loop's multi-kill and untouched-entity cases Entity.Death, the owner of the stream, now ends after its first cause. A blast that destroyed a cockpit fired Death, which removed and deactivated the entity, and the blast's own DamageHull then raised a second Death to every subscriber but Zone's idempotent handler. Every damage path gets the guard because none of them raise Death themselves. New pins: one blast killing three ships (a break after the first kill survives the old suite), a shieldless ship outside the disc receiving nothing, a cockpit-kill blast and a direct hull kill each raising Death exactly once. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/Entity.cs | 5 +- .../FireControlCut124Tests.cs | 94 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index bccc990a3..6ad751e01 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -516,7 +516,10 @@ public abstract class Entity Death = HullDamage.Where(_ => Hull.Durability < .01f).Select(_ => CauseOfDeath.HullDestroyed) .Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke)) .Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia)) - .Merge(ItemDestroyed.Where(i=>i.GetBehavior()!=null).Select(_ => CauseOfDeath.CockpitDestroyed)); + .Merge(ItemDestroyed.Where(i=>i.GetBehavior()!=null).Select(_ => CauseOfDeath.CockpitDestroyed)) + // Death is terminal: the first cause ends the stream, so no damage path (a blast whose cockpit kill is + // followed by its own hull damage, a second item, a heat tick) can raise it twice to any subscriber. + .Take(1); //CurrentSecurityLevel.Value = SecurityLevel.Open; } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index d976f1a1c..bf5268f6e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -535,6 +535,100 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(afterFirstTick, targetDamage, 4); } + // AOneBlastKillsEveryShipInsideIt: three ships, each dead to one hit, inside one blast. Each takes hull + // damage exactly once and dies exactly once, and the shot leaves the queue once. Kills: the snapshot loop + // breaking after the first kill (the other two would never be touched). + [Fact] + public void AOneBlastKillsEveryShipInsideIt() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var ships = new[] + { + AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300), + AddShip(e, e.Target.Position + float3(-8, 0, 0), 1f, 302), + AddShip(e, e.Target.Position + float3(0, 0, 8), 1f, 304), + }; + var hullHits = new int[3]; + var deaths = new int[3]; + var subs = new List(); + for (var n = 0; n < 3; n++) + { + var k = n; + subs.Add(ships[k].HullDamage.Subscribe(_ => hullHits[k]++)); + subs.Add(ships[k].Death.Subscribe(_ => deaths[k]++)); + } + var resolved = 0; + subs.Add(e.Zone.ShotResolved.Subscribe(_ => resolved++)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + foreach (var s in subs) s.Dispose(); + + Assert.Equal(new[] { 1, 1, 1 }, hullHits); + Assert.Equal(new[] { 1, 1, 1 }, deaths); + foreach (var s in ships) Assert.DoesNotContain(s, e.Zone.Entities); + Assert.Empty(e.Zone.PendingShots); + Assert.Equal(1, resolved); + } + + // AShieldlessShipOutsideTheDiscIsNotTouched: nothing of any kind reaches an entity the disc does not + // overlap. Kills: an uncovered entity falling through to the damage call (`covered ??= new List`). + [Fact] + public void AShieldlessShipOutsideTheDiscIsNotTouched() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 100f); + var outside = AddShip(e, e.Target.Position + float3(60, 0, 0), 1f, 300); + var touched = 0; + using var a = outside.ArmorDamage.Subscribe(_ => touched++); + using var h = outside.HullDamage.Subscribe(_ => touched++); + using var i = outside.ItemDamage.Subscribe(_ => touched++); + var targetHit = 0f; + using var t = e.Target.HullDamage.Subscribe(x => targetHit += x); + var armourHit = 0f; + using var ta = e.Target.ArmorDamage.Subscribe(x => armourHit += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(0, touched); + Assert.Equal(1f, outside.Hull.Durability); + Assert.Contains(outside, e.Zone.Entities); + Assert.True(targetHit + armourHit > 0f, "fixture: the blast must land on the target"); + } + + // ADestroyedCockpitRaisesDeathOnce: the blast destroys the cockpit, Death removes and deactivates the + // entity, and the blast's own DamageHull then still runs; no subscriber sees a second Death. + [Fact] + public void ADestroyedCockpitRaisesDeathOnce() + { + var e = Build(TestSettings(), SolidShape(3, 3), cockpitCell: new int2(1, 1), cockpitDurability: 10f); + var deaths = new List(); + using var d = e.Target.Death.Subscribe(deaths.Add); + var hullEvents = 0; + using var h = e.Target.HullDamage.Subscribe(_ => hullEvents++); + + FireControl.Detonate(e.Zone, e.Target.Position.xz, .6f, 5000f, DamageType.Kinetic); + + Assert.Equal(new[] { CauseOfDeath.CockpitDestroyed }, deaths); + Assert.DoesNotContain(e.Target, e.Zone.Entities); + Assert.True(hullEvents >= 1, "fixture: the blast's hull damage still lands after the cockpit dies"); + } + + // ADirectHullKillRaisesDeathOnce: the non-blast path (Entity.DamageHull straight, as a projectile hit does) + // raises Death once, and further damage to the corpse does not raise it again. + [Fact] + public void ADirectHullKillRaisesDeathOnce() + { + var e = Build(TestSettings(), SolidShape(3, 3)); + var deaths = 0; + using var d = e.Target.Death.Subscribe(_ => deaths++); + + e.Target.DamageHull(2000000f); + e.Target.DamageHull(5f); + + Assert.Equal(1, deaths); + } + // ==== Absorption order ==== // ABlastIsAbsorbedThroughArmourFirstPerCell (replaces AbsorbSpendsArmourBeforeTheItemOnTheSameCell): a disc From c7e20f088a9ccb992204c47f60197d7b2d979251 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 07:54:46 +0200 Subject: [PATCH 074/116] Fire control 12.4 closing batch: the new tests must fail without crashing the run Assert.Contains on a Ship formats the whole entity graph on failure and overflowed the stack, aborting the test run instead of reporting red; the new tests check membership with a boolean and a message. The cockpit-kill test now gives the target a hull the blast also kills, the case where the double Death actually occurred. Co-Authored-By: Claude Sonnet 5 --- tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index bf5268f6e..f3225336e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -566,7 +566,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(new[] { 1, 1, 1 }, hullHits); Assert.Equal(new[] { 1, 1, 1 }, deaths); - foreach (var s in ships) Assert.DoesNotContain(s, e.Zone.Entities); + foreach (var s in ships) Assert.False(e.Zone.Entities.Contains(s), "a killed ship must have left the zone"); Assert.Empty(e.Zone.PendingShots); Assert.Equal(1, resolved); } @@ -592,7 +592,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(0, touched); Assert.Equal(1f, outside.Hull.Durability); - Assert.Contains(outside, e.Zone.Entities); + Assert.True(e.Zone.Entities.Contains(outside)); Assert.True(targetHit + armourHit > 0f, "fixture: the blast must land on the target"); } @@ -602,6 +602,7 @@ public sealed class FireControlCut124Tests : IDisposable public void ADestroyedCockpitRaisesDeathOnce() { var e = Build(TestSettings(), SolidShape(3, 3), cockpitCell: new int2(1, 1), cockpitDurability: 10f); + e.Target.Hull.Durability = 10f; // fragile enough that the blast's own hull damage is lethal too var deaths = new List(); using var d = e.Target.Death.Subscribe(deaths.Add); var hullEvents = 0; @@ -610,8 +611,9 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Detonate(e.Zone, e.Target.Position.xz, .6f, 5000f, DamageType.Kinetic); Assert.Equal(new[] { CauseOfDeath.CockpitDestroyed }, deaths); - Assert.DoesNotContain(e.Target, e.Zone.Entities); + Assert.False(e.Zone.Entities.Contains(e.Target), "the dead target must have left the zone"); Assert.True(hullEvents >= 1, "fixture: the blast's hull damage still lands after the cockpit dies"); + Assert.True(e.Target.Hull.Durability < .01f, "fixture: and that hull damage is itself lethal"); } // ADirectHullKillRaisesDeathOnce: the non-blast path (Entity.DamageHull straight, as a projectile hit does) From c50029d5117a33e1c4c3b2bde387d76459344b43 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 07:59:29 +0200 Subject: [PATCH 075/116] Record fire-control 12.4 closed, and the no-target fuse ruling it needs Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 6586 +++++++++++++++++++------------------- 1 file changed, 3306 insertions(+), 3280 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 4d610f551..fe48b2c80 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1,3280 +1,3306 @@ -# Fire Control: Cut Map - -Date: 2026-09-19 - -Status: Imagination pass, cut map. Nothing here has landed. Ends are owned by -`docs/three-gates-scope.md` ("Fire control") and -`docs/shield-presentation-contract.md` ("The commit window"); this document owns -the means. - -Anchors are against `codex/item-provenance` HEAD `8a00fc92`, read-only. Nothing was -built in the main tree and Unity was not run. Claims marked **(probe)** were measured -by running `tools/AetherDb` (`census`, `hardpoint-fit`) against the live catalog -`GameData/Aetheria.cc` from the free worktree `F:\Projects\Aetheria-stats`, CultLib -pinned at the shared `45c2f40` worktree. Claims marked **(read)** come from reading -source at the cited anchor. - -**This map supersedes Cut 2 of `docs/headless-playground-cut.md`** -(`:490-810`, anchored to `562cdcf2`). That draft is now history for three reasons: -its anchors predate the stats-and-power campaign, which moved most of the code it -cites; it predates the commit-window ruling of 2026-09-18, which changes the shape of -a shot from an instant to a lifecycle; and it predates the per-hardpoint arc override -and 360-degree turret rulings. Its substrate probes are still good and are carried -forward below with citations. Where the two disagree, this document wins; nobody -should be reading two live designs. - -**Scope: fire control only.** What a presentation does with a committed outcome is the -capability/presentation seam's business (`docs/shield-presentation-contract.md`). -Faction territory is the campaign after this. §6 names every seam and stops there. - ---- - -## Rulings this map is built on (do not re-litigate) - -From `docs/three-gates-scope.md:47-99` and `docs/shield-presentation-contract.md:34-57`: - -- **R1. Hit detection goes.** Operator, 2026-09-17: "We wanted to get rid of hit - detection anyway... Just roll the dice based on weapon stats, targeting system stats - (new subsystem) and sensor state." -- **R2. Targeting systems are a new subsystem**, interior `Tool` gear. -- **R3. A shot resolves on arrival**, hit chance reduced by how far the target deviated - from the predicted intercept. Manoeuvring is evasion. -- **R4. The outcome commits a short fixed time before impact.** Operator, 2026-09-18: - "That hybrid is the way." Deviation feeds the roll up to that horizon; after it the - result stands and the rest of the flight is choreography. The horizon is one authored - fire-control setting. A commit is authoritative; presentations perform it and never - predict. -- **R5. Subsystems reveal between `TargetArmorInfoThreshold` and - `TargetGearInfoThreshold`**, and both the player and the AI may aim at a revealed - subsystem. -- **R6. Per-hardpoint firing arcs.** Mount direction from the item's `ItemRotation`, as - reaction thrusters do; default 120 degrees from `GameplaySettings`; an optional - per-hardpoint override; turrets 360. -- **R7. The simulation is 2D.** The 3D is set dressing. -- **R8. Unity weapon effects and `HullCollider` become presentation only.** Their - `Physics.*` queries stop deciding damage. -- **R9. Every weapon type in the catalog's history stays** - (`docs/headless-playground-cut.md` fork E). Nothing is deleted for being unused. - -From `docs/stats-and-power-cut.md:996-1030`, the seam the stats campaign left: - -- **R10. Fire control must snapshot what it rolled over.** The resolver hands out a - current value; it does not promise that value is still current a second later. -- **R11. A starved weapon does not fire** (Cut 4 of that campaign: no fraction of a - shot). A degraded shot instead is a ruling against that map, not a thing to work - around here. See Q6. - ---- - -## 0. Substrate - -### 0.1 What decides a hit today, end to end - -Nothing in `ServerShared` does. The whole chain is Unity **(read)**: - -1. `InstantWeapon.Execute` (`Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs:216-237`) - walks the burst and raises `OnFire` (`:228`), a parameterless `event Action` declared - at `:73`. It carries no outcome and nothing in `ServerShared` subscribes. -2. `Assets/Scripts/Gameplay/EntityInstance.cs:203-204` subscribes and calls - `InstantWeaponEffectManager.Fire(weapon, item, source, target)` - (`Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs:7`). The constant - family goes the same way at `:232-235`. -3. The manager spawns an effect and copies the weapon's resolved numbers onto it. - `ProjectileManager.cs:18-25` jitters the barrel forward vector by - `Random.Range(±Spread/2)` on all three Euler axes — that is the entire aiming model. -4. The effect raycasts. `Projectile.cs:58`, `GuidedProjectile.cs:153`, `Laser.cs:40`, - `Lightning.cs:34`, `ConstantLaser.cs:74`, `ConstantLightning.cs:54`, - `HitscanEffect.cs:31`, `Mine.cs:68,92`, plus `Projectile.cs:114` for airburst. -5. On a hull collider it calls `HullCollider.SendHit` - (`Assets/Scripts/Gameplay/HullCollider.cs:18-30`), or `SendSplash` (`:32-41`). -6. `EntityInstance.cs:316-396` subscribes to those subjects, converts a UV texture - coordinate into schematic cells, expands by `DamageSpread`, marches a penetration ray, - and calls the local function `DamageSchematic` (`:277-314`), which is the only writer - of `Armor`, item `Durability` and `Hull.Durability` from combat. -7. Death: `EntityInstance.cs:405-439` subscribes to `HullDamage`, rolls loot with - `UnityEngine.Random`, and calls `entity.Zone.Entities.Remove(entity)` (`:437`). - -**Authority versus presentation in that chain.** Everything from step 3 down is -authority wearing a presentation costume. The shield branch repeated in all seven -effects (`Projectile.cs:60-80` and its copies) is the absorb rule, duplicated seven -times. `DamageSchematic` is the damage rule. `EntityInstance.cs:437` is the death rule. -Genuine presentation is narrow: trail rendering, line renderers, the lightning compute -animation, hit effect prefabs, `ShieldManager.ShowHit`, and the destroy effect. - -**The 2D/3D violation.** `EntityInstance.cs:323` and `:380` call -`transform.InverseTransformDirection` to get a hit direction into the ship's frame. That -is a 3D rotation standing in for the planar rotation R7 says the simulation is made of. - -### 0.2 What already exists of targeting - -- `Entity.Target` — `Assets/Scripts/ServerShared/Entity.cs:46`, a - `ReactiveProperty`. Cleared when the target leaves `VisibleEnemies` (`:196`, - `:222`). `TargetRange` is recomputed each tick at `:920`. -- `Entity.EntityInfoGathered` — `Entity.cs:57`, a `ReactiveDictionary` in - [0,1], accumulated with decay by `Sensor.Execute` - (`Assets/Scripts/ServerShared/Behaviors/Sensor.cs:152-184`). -- `TargetDetectionInfoThreshold` gates `VisibleEnemies` at `Entity.cs:232-246`. -- **`TargetArmorInfoThreshold` and `TargetGearInfoThreshold` are authored and read by no - code.** They are declared at `Assets/Scripts/ServerShared/Settings.cs:212-213` and - appear nowhere else in `Assets/Scripts` **(read)**. R5's reveal tiers are unwired - settings. -- `LockWeapon` — `Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs`. Integrates a - 0..1 `_lock` scaled by `pow(EntityInfoGathered[target], SensorImpact)` (`:92`), gated by - a `LockAngle` cone off `Entity.LookDirection` (`:88-89`). `CanFire` (`:56`) requires - `_lock > .99f` and range within `[MinRange, Range]`. This is the closest thing in the - codebase to a to-hit probability, and it is per-weapon rather than per-ship. -- `TurretController` — `Assets/Scripts/ServerShared/Behaviors/TurretController.cs`. - `TurretControllerData` has no fields (`:15-25`). It writes `Entity.LookDirection` to a - first-order intercept (`:67-72`) and fires each weapon when - `dot(x.Direction, Entity.LookDirection) > .99f` (`:81-83`). -- `Sensor` — sensitivity, ping boost/energy/visibility/range/cooldown, all - `PerformanceStat` (`Sensor.cs:16-34`). -- Player target selection: `Assets/Scripts/Gameplay/ActionGameManager.cs:368-403` - (reticle, nearest, next, previous). Player fire is not arc-gated at all. -- **No targeting-system item, behavior or stat exists anywhere in code or catalog.** A - repo-wide grep for `Targeting|FireControl|Accuracy|Aim` finds one `#region Targeting` - label at `ActionGameManager.cs:368` and nothing else; the same grep over the catalog - returns zero **(probe)**. - -### 0.3 Where hardpoint rotation and arcs live - -- `HardpointData` — `Assets/Scripts/ServerShared/ItemData.cs:538-545`: `Type`, - `Position`, `Shape`, `Transform` (a prefab node name), `Rotation` (`ItemRotation`), - `Armor`. **There is no arc field on `HardpointData`, `WeaponData`, or any effect.** - A per-hardpoint override attaches here, as catalog data already authored per hull. -- `ItemRotation` is four-way (`Assets/Scripts/ServerShared/Enums.cs:7`); - `Extensions.cs:135-166` gives `None` = forward, `Reversed` = aft, `Clockwise` = right, - `CounterClockwise` = left. `Agent.cs:72` reads `Clockwise` as right and `Thruster` uses - the same convention for mount direction, which is the precedent R6 names. -- `Behavior.Direction` (`Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:24-44`) has - two paths. The first (`:30-34`) reads `Entity.HardpointTransforms`; the second - (`:37-38`) is the item-rotation fallback R6 wants. -- **`Entity.HardpointTransforms` (`Entity.cs:58-59`) is written only by Unity**, at - `EntityInstance.cs:514-517`, from the first `WeaponHardpoint.FiringPoint` barrel's - world forward. Headless it is empty. That single dictionary is why - `Combat.cs:100-102` throws `KeyNotFoundException` on the first combat tick outside - Unity, and why the simulation's own notion of where a gun points is a readback from a - renderer. -- Barrel swing is `ArticulationPoint`, yawing and pitching toward `LookAtPoint` - (`EntityInstance.cs:400-403`, `:519-520`). Authored limits on the Longinus energy - barrels are yaw [-1, 1] — effectively fixed forward — while the Turret prefab's yaw - pivot is [-720, 720] (`docs/headless-playground-cut.md:533-545`, prefab probe, not - re-measured here). That is presentation and stays. -- `AgentFiringMinDot` (`Settings.cs:218`) is **declared and read by nothing** **(read)**. - -### 0.4 What the catalog ships in weapons - -**(probe)**, `tools/AetherDb census` and `hardpoint-fit` over `GameData/Aetheria.cc`: -51 designs, 37 products, of which 18 are weapons. - -| Hardpoint kind | Designs | Sellers | -|---|---|---| -| Ballistic | 6 | Zhestokost 2, Lightsail 1, AU 1, DME 1, unsold 1 | -| Energy | 7 | NiteLife 3, Lucent 1, Adrasteia 1, Alakrita 1, unsold 1 | -| Launcher | 5 | DME 1, R&D 1, unsold 3 | - -Unsold, therefore unspawnable: `Autocannon`, `SRMM72`, `LRMM72`, `plight`, `pswarm`. -The earlier behaviour-class breakdown (8 `ProjectileManager`, 3 `LaserManager` at -velocity 0, 5 `GuidedProjectileManager`, 1 `LightningGunManager`, and **no design using -`ConstantWeaponData`, `HitscanEffect`, `Mine`, airburst or MIRV**) is carried from -`docs/headless-playground-cut.md:500-506` and was not re-measured; the census agent -could not reproduce it because `census` buckets by `HardpointType` and the one command -that prints behaviour fields was blocked. **Cut 4's safety argument depends on that -claim, so Hands re-measures it before Cut 4** (§4, First). - -**Stats a roll can read today** (`Weapon.cs:22-68`, all `PerformanceStat` unless noted): -`Damage`, `Penetration`, `DamageSpread`, `MinRange`, `Range`, `Energy`, `Heat`, -`Visibility`, `Spread` (an angular cone in degrees), `Velocity`; plus `DamageCurve` -(a `BezierCurve`, damage against normalized range), `MagazineSize`, `ReloadTime`, -`AmmoType`. `InstantWeaponData` adds `Count`, `BurstTime`, `Cooldown` -(`InstantWeapon.cs:13-22`). `LockWeaponData` adds `LockSpeed`, `SensorImpact`, -`LockAngle`, `DirectionImpact`, `Decay` (`LockWeapon.cs:13-25`). The item carries -`WeaponRange`, `WeaponCaliber`, `WeaponType`, `WeaponFireTypes`, `WeaponModifiers` -(`ItemData.cs:476-492`). - -Note against a misreading: **`DamageSpread` is not damage variance.** It is the -footprint, in schematic cells: `EntityInstance.cs:372-375` expands the hit shape -`round(hit.Spread)` times, where `hit.Spread` is the weapon's `DamageSpread`. - -**What is missing from the authored data for a roll to be meaningful:** - -- **No accuracy stat on anything.** No weapon, hull, cockpit or sensor contributes a - to-hit number. This is what R2's targeting system exists to supply, and it is the - whole reason the subsystem is required content rather than a nice-to-have. -- **No target-size term.** A roll that ignores how big the target is will feel wrong at - every range. The hull `Shape` and a cell size give an angular radius; `SchematicCellSize` - does not exist and must be authored (Q4). -- **No tracking or angular-velocity stat**, so nothing yet reads R3's deviation. The - targeting system supplies it. -- **Beams have nothing beam-specific.** `ConstantWeaponData` adds only `AmmoInterval` - (`ConstantWeapon.cs:15`); `Spread` is inherited and unread by any beam code, and - `Velocity` is meaningless for one. There is no dwell or wander stat. Unused today, so - Cut 4 gives beams the plainest possible rule and authors nothing new. -- **`ChargedWeaponData.ChargeFiringSpreadMultiplier` is a plain `float`** - (`ChargedWeapon.cs:34`), not a `PerformanceStat`, so unlike every other combat number - it cannot vary with quality or wear. Noted, not fixed here. -- **Lock is binary at 0.99** (`LockWeapon.cs:56`). There is no stat turning partial lock - into hit chance, and `GuidedWeaponData` (`Launcher.cs:37-55`) has no lock stats at all. - -### 0.5 Can the headless sim step a full engagement today - -No, and two specific things stop it **(read)**: - -1. `Combat.cs:100-102` indexes `HardpointTransforms`, which only Unity writes (§0.3). - `Minion` enters `CombatState` as soon as `VisibleEnemies` is non-empty, so any NPC - that sees an enemy throws on the tick it sees them. -2. There is no owner between `InstantWeapon.OnFire` and `Entity.HullDamage`. The only - `ServerShared` writers of durability are the repair consumable - (`Behaviors.cs:67,72-73`). - -Everything else is already there and already exercised. -`tests/Aetheria.Shared.Tests/IffAndCombatTests.cs:52` (`BuildWorld`) opens the real -catalog, builds an `ItemManager` and a `Zone`, and `:73` (`NewShip`) equips a gun on a -hardpoint and activates the ship — two ships, positions, look directions, sensor info, -targets and IFF, headless. `:241` and `:255` assert on `weapon.Progress` after -`Activate()`, because a cooldown starting is the only observable consequence of firing -that exists. That fixture is this campaign's harness; it does not need to be built. - -`Zone.Update` (`Zone.cs:126-151`) steps orbits, belts, agents, then entities. -`ItemManager.Random` is still clock-seeded (`ItemManager.cs:19`), so a seeded-roll test -needs a seed argument — see Q5. - -The xunit project is `tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj` -(net10.0, xunit), referencing `Aetheria.Shared/Aetheria.Shared.csproj` -(netstandard2.1, `noEngineReferences` mirrored by -`Assets/Scripts/ServerShared/Aetheria.Shared.Unity.asmdef`). The mutation harnesses are -`tests/mutation_tests_*.py`, 17 of them, each patching a named source anchor and -asserting a named test goes red with a no-op control. - ---- - -## Rulings (operator and Self, 2026-09-19) - -- **Q4, unaided accuracy: really, really bad.** Operator: "Targeting with the mark one eyeball - should be really, really bad." A ship with no targeting system can hit something close, - slow and unaware, and little else. That makes the targeting subsystem **required equipment** - rather than an upgrade: every hull needs somewhere to put one, the catalog needs them at - several price points, and a stripped hull is a genuine handicap. Hands picks the other two - balance numbers; this one is authored to make unaided fire a last resort, and the operator - tunes it in play. -- **Q1, turrets: author `FiringArc: 360`.** No new type for what a number already says. -- **Q2, player fire is arc-gated** like everyone else's. Manual and programmatic must not be - two truths. -- **Q3, death removes the ship**, and loot stays as it is until the pickup cut. -- **Q5, seed the rolls in this campaign.** Two lines here beats waiting on an unstarted cut. -- **Q6, snapshot at fire, not at commit** (Self, accepting Imagination's counter-proposal to - the stats map's §8 seam). A shot carries the gun that fired it: its parameters freeze when - the trigger is pulled, and nothing about the shooter's heat, power or wear changes them - mid-flight. Evasion still decides the outcome, because deviation is measured at arrival - against the predicted intercept. `docs/stats-and-power-cut.md` §8 said "snapshot at commit"; - that is superseded, and the resolver's promise (a current value, not a stable one) is - honoured either way. -- **Q7, mine proximity physics: leave it.** - -## 0b. Identity, lifecycle, authority - -One row per kind this campaign introduces. No cut below is mapped with a cell empty. - -| Kind | What names it | What happens to it over time | Who decides | -|---|---|---|---| -| **A shot in flight** | `PendingShot`, a struct in `Zone.PendingShots` (a `List`), identified by a monotonic `int ShotId` from `Zone`. Nothing outside `Zone` holds a reference. | Born in `FireControl.Fire` when a weapon's burst step fires, carrying a payload snapshot frozen at that instant. Ages by `dt` in `FireControl.Step`, called from `Zone.Update` after entities. Reaches its commit horizon, then its arrival time, then is removed the same tick it resolves. A shot whose source or target entity leaves the zone resolves as a miss and is removed. Never serialised: a zone saved mid-flight loses its shots, which is correct — a save is a scene boundary. | `Zone` owns the collection and the identity. `FireControl` owns the transitions. Nothing else may add, remove or mutate one. | -| **A roll** | Nothing. It is not a thing; it is one draw from `ItemManager.Random` inside `FireControl.Commit`, and only its result is named. | Exists for the duration of one call. Its inputs are the payload snapshot plus deviation measured at that instant; its output is written into the shot's `Outcome` and never recomputed. | `FireControl` alone. No behaviour, agent, presentation or UI may draw for a hit. | -| **A commit** | `ShotOutcome { int ShotId; Entity Source; Entity Target; EquippedItem Weapon; bool Hit; bool Shielded; EquippedItem Aimed; int2 Cell; float ArrivalIn; DamageType DamageType; }`, published on `Zone.ShotCommitted` (a `Subject`). | Created once, at `ArrivalTime - CommitHorizon`, or at fire time when the flight is shorter than the horizon. Immutable from then on. Published once. Read by presentations and by nothing else. Dies with the shot. | `FireControl` creates it and is the only writer. Presentations are the only readers. **Nothing outside presentation may read a commit to change state** (`docs/shield-presentation-contract.md:57`). | -| **A targeting solution** | Not a stored object. `FireControl.HitProbability(weapon, source, target)` is a pure function, recomputed on demand. | Has no lifetime. AI reads it to decide whether to fire; the HUD reads it to draw a number; `FireControl.Fire` reads it once per shot and freezes the result into the payload. Two reads a tick apart may differ; nothing caches one. | `FireControl`. The targeting-system behaviour owns only its own resolved stats; it does not compute probability. | -| **Target data / knowledge of a subsystem** | `Entity.EntityInfoGathered[other]` (existing, `Entity.cs:57`) is the knowledge. `FireControl.IsRevealed(observer, item)` is the derived predicate over it. The current aim point is `Entity.TargetItem`, a `ReactiveProperty`. | Info accumulates and decays continuously under `Sensor` (`Sensor.cs:152-184`); ownership of that does not change. Reveal is **derived on every read**, never stored and never cached, so it falls back the moment info decays. `TargetItem` is written only by `Entity.TrySelectTargetItem`, nulled when `Target` changes, and **read through a derived accessor that returns null if the item is no longer revealed or no longer belongs to the target** — no loop clears it. | `Sensor` owns info. `FireControl` owns the reveal rule and the thresholds it reads. `Entity` owns the selection slot; `TrySelectTargetItem` is its only writer. Player input, `CombatState` and the test harness are its three callers. | -| **A hardpoint arc** | `HardpointData.FiringArc`, a `float` on catalog data (`ItemData.cs:538-545`), zero meaning "use the default". Resolved through `FireControl.ArcFor(item)`. | Authored once, in the catalog. Immutable at runtime. Read per shot and per AI fire decision; never stored on the entity. Mount direction is likewise derived per read from `Behavior.Direction`'s item-rotation path. | The catalog authors it; `FireControl` resolves and applies it; `GameplaySettings.FiringArc` supplies the default. **No prefab, transform, articulation limit or renderer may influence it.** | - ---- - -## Cut order - -1. **Mount direction and arc become simulation data.** Deletes `HardpointTransforms` - and the Unity readback; adds the arc gate; unbreaks headless combat. -2. **Targeting system and reveal.** The new subsystem, the reveal rule, the selection - slot, and the two dead settings wired up. -3. **Hit authority moves.** The Physics path in the four effects the catalog actually - uses is deleted and `FireControl` plus the shot lifecycle replaces it, in one cut, so - damage is never applied twice. -4. **The unused kinds join the same path.** Beams, hitscan, mines, splash and airburst - stop querying Physics. They keep existing (R9). - -Cuts 3 and 4 cannot be swapped, and 3 cannot be split into "roll" and "delete the old -path": a cut boundary between them is a cut boundary with two damage owners. - ---- - -## Cut 1. Mount direction and arc become simulation data - -- **Repo/branch:** `Aetheria`, branch `codex/fire-control` from `codex/item-provenance` - `8a00fc92`. Depends on nothing. -- **First:** update `F:\Projects\Aetheria-stats` to HEAD (it sits 11 commits behind at - `c4f6029e`); capture `AetherDb census`, `loadout 1`, `dangling` as the before side. - -**Deletes first:** - -- `Assets/Scripts/ServerShared/Entity.cs:58-59` — `HardpointTransforms` (2). -- `Assets/Scripts/Gameplay/EntityInstance.cs:514-517` — its only writer (4). -- `Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:30-35` — the readback branch in - `Behavior.Direction` (6). The item-rotation path at `:37-38` becomes the whole body. -- `Assets/Scripts/ServerShared/Agents/States/Combat.cs:100-102` — the - `HardpointTransforms` dot test (3). -- `Assets/Scripts/ServerShared/Behaviors/TurretController.cs:81-83` — the `.99f` dot - test (3). -- `Assets/Scripts/ServerShared/Settings.cs:218` — `AgentFiringMinDot`, read by nothing - (1). - -**Adds:** - -- `Assets/Scripts/ServerShared/FireControl.cs` — a new static class, this cut holding - only geometry: `MountDirection(EquippedItem)` (the planar unit vector from - `Behavior.Direction`), `ArcFor(EquippedItem)` and - `bool InArc(EquippedItem weapon, float3 toTarget)`, which tests - `dot(normalize(weapon.Direction.xz), normalize(toTarget.xz)) >= cos(radians(arc/2))`. - The test is planar (R7); target height never enters it. An arc of 360 or more passes - unconditionally. -- `HardpointData.FiringArc` (`ItemData.cs:545`, next `Key`) — full width in degrees, - `0` meaning "use `GameplaySettings.FiringArc`". -- `GameplaySettings.FiringArc = 120` (`Settings.cs`, after `:214`). -- `GameplaySettings.AgentMinHitProbability` is **not** added here; Cut 1's AI gate is - `InArc` alone, which is strictly closer to today's behaviour than a probability the - roll does not yet produce. - -**Per-file changes (against `8a00fc92`):** - -- `Combat.cs:100-102`: `shouldFire = FireControl.InArc(testWeapon.Item, toTarget)`. The - intercept prediction at `:84-98` stays — it still aims `LookDirection`, and under R3 it - becomes the predicted intercept a deviation is measured against. -- `TurretController.cs:81-83`: the same call. Under the default arc a turret hull, whose - `Direction` never rotates (`TurretController` writes only `LookDirection`), would - engage only within 60 degrees of its spawn facing. R6 says turrets are 360; the turret - hull's hardpoints author `FiringArc: 360`. See Q1. -- Catalog: author `FiringArc` on the `Turret` hull's two Ballistic hardpoints. Follows - the Cut C precedent — a Hands scratch console upserts, Unity and Studio closed. - -**Authority map:** - -- **Owner:** `FireControl` owns whether a weapon bears on a point. `HardpointData` - authors the arc; `ItemRotation` authors the mount direction. -- **Inputs:** the equipped item's hardpoint rotation, `HardpointData.FiringArc`, - `GameplaySettings.FiringArc`, the planar vector to the target. -- **Outputs:** one bool, consumed by `CombatState`, `TurretController`, and from Cut 3 - by `FireControl.Fire`. -- **Derived state:** `Behavior.Direction` becomes purely derived from item rotation. -- **Forbidden writers:** no Unity transform, `ArticulationPoint`, `WeaponHardpoint` or - prefab node may influence aim. `Barrels`, `GetBarrel` and `ArticulationPoint` survive - as presentation and must never be read by `ServerShared`. -- **Shared paths:** player fire, AI fire and turret fire all go through `InArc`. Player - fire is arc-gated for the first time; see Q2. -- **Deletion line:** `HardpointTransforms` and its writer and readers are gone before - `FireControl.cs` is written. - -**Verification:** - -- builds: `Aetheria.Shared/Aetheria.Shared.csproj`, `tests/Aetheria.Shared.Tests`, - `tools/AetherDb`. -- tests, each named with the rule it pins: - - `ArcFollowsMountRotation` pins *mount direction is the item's rotation, not the - hull's facing*: a fixture hull with one weapon on a `None` hardpoint and one on a - `Clockwise` hardpoint; a target 90 degrees to starboard passes only for the - `Clockwise` weapon, a target dead ahead only for the `None` weapon. Mutation: make - `InArc` use `Entity.Direction`; swap `Rotate`'s `Clockwise`/`CounterClockwise` cases. - - `ArcBoundaryIsHalfWidth` pins *`FiringArc` is full width*: at 120, a target 59 - degrees off passes and one at 61 does not. Mutation: compare against - `cos(radians(arc))`. - - `ArcIsPlanar` pins R7: a target inside the arc horizontally but far above the firer - still passes. Mutation: use the 3D vector. - - `HardpointOverrideBeatsDefault` pins *the per-hardpoint override wins*: the same - weapon on a hardpoint with `FiringArc: 360` passes at 170 degrees, and fails there on - a hardpoint with `0`. Mutation: ignore the field; treat `0` as an arc of zero. - - `CombatStateStepsHeadless` pins *the simulation decides aim without Unity*: a - `Minion` with a target in range steps through `Zone.Update` without throwing. - Mutation: restore the `HardpointTransforms` read. -- negative: `rg -n "HardpointTransforms" Assets/Scripts` empty; - `rg -n "ArticulationPoint|FiringPoint|WeaponHardpoint" Assets/Scripts/ServerShared` - empty; `rg -n "AgentFiringMinDot" Assets/Scripts` empty. (`\.99f` is **not** a safe - negative grep — it occurs legitimately in `LockWeapon.cs:56`. Grep the two files by - name instead.) -- operator: in Unity, side-mounted weapons still fire at targets abeam; a turret still - tracks all the way around; nothing fires through the hull. - -**Risk:** this cut changes what the AI fires at before the roll exists, so combat feel -shifts once and shifts again in Cut 3. That is the price of deleting the obsolete -authority first, and it is the right price. - ---- - -## Cut 2. Targeting system and reveal - -- **Repo/branch:** continues Cut 1. Depends on Cut 1 (uses `FireControl.cs`), and on - provenance Cut C if that has not landed, because Cut C re-upserts every `ItemData`. -- **First:** `AetherDb census` and `loadout 1` over several seeds, as the before side. - -**Deletes first:** nothing. This cut is entirely additive, which needs its account -(CODE IS A LIABILITY): the invariant it buys is R5, which cannot be purchased by moving -authority, because no authority for it exists — two authored settings are read by -nothing and there is no subsystem to carry accuracy. Cut 3 is where the subtraction is. - -**Adds:** - -- `Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs` — - `TargetingSystemData : BehaviorData` (next free `Union` index on `BehaviorData`; Hands - reads the current list in `Behaviors.cs` rather than trusting the number 39 quoted in - the superseded draft) plus a `TargetingSystem : Behavior` that resolves and exposes: - - `Accuracy` (0..1) — the ceiling on hit probability. - - `Resolution` (detection threshold..1) — the info level at which sensor state stops - limiting hits. - - `Precision` (0..1) — the chance a hit lands on the aimed item rather than a random - cell. - - `Tracking` — how much deviation it forgives (consumed in Cut 3). - - All four are `PerformanceStat`, so lot quality, wear, heat and `PowerSupply` reach them - through `EquippedItem.Evaluate` — that is the brownout ruling applied to accuracy, and - it is why a starved targeting system rolls worse instead of switching off. Energy and - heat come from the existing `EnergyDraw` and `Heat` behaviours on the item; the - subsystem adds no power machinery of its own. -- `GameplaySettings.UnaidedAccuracy` — what an entity with no working targeting system - fires with. Such an entity uses `Resolution = 1` and `Precision = 0`: it can still - select a revealed item, and its hits scatter. -- `FireControl.IsRevealed(Entity observer, EquippedItem item)` — derived, never cached: - 1. Rank the target's non-hull equipment: hardpoint-mounted first, then size - descending, then equipment index. - 2. Item `i` of `N` is revealed when - `info >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1))`. - - This is the first and only reader of `Settings.cs:212-213`. -- `Entity.TargetItem` (`ReactiveProperty`, beside `Target` at - `Entity.cs:46`) and `bool Entity.TrySelectTargetItem(EquippedItem)`, the only writer. - It accepts null, or an item of `Target.Value` that `IsRevealed`. A `Target` change - nulls it. The read accessor returns null when the item is no longer revealed, so decay - drops the aim point without a loop. -- Player input: a select/cycle command beside `ActionGameManager.cs:368-403`, calling - `TrySelectTargetItem`. The target schematic - (`Assets/Scripts/UI/HUD/SchematicDisplay.cs:109-119`, which today lists only weapon - items) shows revealed items and marks the selection. That is presentation; it decides - nothing. -- AI: `CombatState` selects the revealed target `Weapon` item with the highest - `RangeDamagePerSecond(range)`, or null. -- Catalog: two targeting-system designs, 1-cell and 2-cell, each with products from at - least the manufacturers that sell the capacitor, so `IsAvailable` finds one in every - galaxy. `LoadoutGenerator.FillInterior` picks one with `required: true` for every - entity with `Weapons` — the starting LonginusX, NPC ships and turrets. Zenith, with no - weapon hardpoints, gets none. - -**Authority map:** - -- **Owner:** `FireControl` owns the reveal rule. `Entity` owns the selection slot. - `TargetingSystem` owns nothing but its own resolved stats. -- **Inputs:** `EntityInfoGathered`, the two threshold settings, the target's equipment - list and item sizes. -- **Outputs:** a bool per (observer, item); a nullable `EquippedItem` per entity. -- **Derived state:** reveal is derived on read. `TargetItem`'s *validity* is derived; - only the slot is stored. -- **Forbidden writers:** the HUD may not write `TargetItem` except through - `TrySelectTargetItem`; no code may store a reveal result; no code may read the two - thresholds except `IsRevealed`. -- **Shared paths:** player selection, AI selection and the test harness use one writer. -- **Deletion line:** none, and that is stated rather than hidden. - -**Verification:** - -- builds: as Cut 1. -- tests: - - `SelectionNeedsReveal` pins *you cannot aim at what you have not resolved*: below - the item's tier `TrySelectTargetItem` returns false; above it, true; a target change - nulls it; info decaying below the tier makes the read null. Mutation: `IsRevealed` - returns true; the `Target` change skips the null; the accessor returns the stored - value without re-checking. - - `RevealOrder` pins *the big obvious things resolve first*: hardpoint items reveal - before interior items, larger before smaller. Mutation: drop the ordering. - - `RevealSpansBothThresholds` pins *the tiers are the authored ones*: with N items, - the first reveals at `TargetArmorInfoThreshold` and the last at - `TargetGearInfoThreshold`. Mutation: swap them; use the detection threshold. - - `UnaidedFiresWorse` pins *the subsystem is what supplies accuracy*: an entity with - no targeting system resolves `UnaidedAccuracy` and `Precision` 0; one with a - destroyed or offline system resolves the same. Mutation: fall back to the system's - stats when offline. - - `StarvedTargetingRollsWorse` pins the brownout ruling on accuracy: with an - `Accuracy` stat carrying a `PowerSupply` term, a half-grant halves it rather than - switching the item off. Mutation: gate on `PowerSupply <= 1e-4`. - - `EveryArmedLoadoutGetsATargetingSystem` pins the required-item rule across seeds. - Mutation: drop `required: true`. -- negative: `rg -n "TargetArmorInfoThreshold|TargetGearInfoThreshold" Assets/Scripts` - returns `Settings.cs` and `FireControl.cs` **only**. -- operator: `AetherDb census` shows 2 more designs; `loadout 1` shows one more interior - item per armed entity; `dangling` is 0; reopen reports 0 schema issues; CultCache - Studio renders the new behaviour union (an operator check, because Studio's handling of - a new union index is not something this map can promise). - -**Risk:** `LoadoutGenerator.EquipHardpoints` can already fit the Tractor Beam into -LonginusX's only Sensors slot, leaving an NPC that gathers no info and therefore reveals -nothing and hits nothing (`docs/headless-playground-cut.md:520-523`). Check `loadout` -over several seeds. The fix is data or `EquipHardpoints`, not fire control. - ---- - -## Cut 3. Hit authority moves - -The rebuild. The old path is deleted and the new owner added in the same cut, because -any boundary between them is a boundary with two damage owners. - -- **Repo/branch:** continues Cut 2. Depends on Cuts 1 and 2. -- **First:** capture a Unity play smoke of current combat feel (kill time against one - NPC, roughly) as the before side, since this cut changes feel more than anything in - the campaign. - -**Deletes first** (paths and line counts against `8a00fc92`): - -- `Assets/Scripts/Gameplay/EntityInstance.cs:277-396` — `DamageSchematic` and both - `HullCollider` subscriptions (120). -- `Assets/Scripts/Gameplay/HullCollider.cs:13-14,18-41,48-65` — `Hit`, `Splash`, - `SendHit`, `SendSplash` and both argument classes (~45 of 65). `OnCollisionEnter` - (`:43-46`) stays; ship collision is a deferred Unity-physics surface. -- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:57-103` — the raycast, shield branch and - `SendHit` (47). `:109-122` (airburst) is Cut 4's. -- `Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs:152-189` — the same (38). -- `Assets/Scripts/Gameplay/Weapons/Laser.cs:40-75` — the same; the `LineRenderer` - endpoint writes at `:59,70,77` are presentation and are rebuilt from the outcome (36). -- `Assets/Scripts/Gameplay/Weapons/Lightning.cs:34-72` — the same; the endpoint capture - at `:67-71` stays as a visual (39). -- `Assets/Scripts/Gameplay/EntityInstance.cs:411-431` — the `UnityEngine.Random` loot - roll, **only if Q3 is answered (a)**; otherwise this cut leaves it and the seam is - named in §6. - -**Adds:** - -- `Entity.DamageSchematic(float damage, Shape hitShape)` — the rule moved verbatim from - `EntityInstance.cs:277-314`, now a method on `Entity` in `ServerShared`, writing - `Armor`, item `Durability`, `Hull.Durability` and raising the existing `ArmorDamage`, - `ItemDamage` and `HullDamage` subjects. Not a redesign: the same arithmetic, the same - order, the same thresholds. -- `Entity.ApplyHit(...)` — the shape construction moved from - `EntityInstance.cs:334-394`, with two changes forced by R7: the hit cell comes from the - rolled `Cell` rather than a UV texture coordinate, and the penetration march rotates - the firer-to-target vector into the target's frame with a planar rotation by - `-target.Direction` instead of `transform.InverseTransformDirection`. -- `FireControl.HitProbability(weapon, source, target)` — pure, no draw: - - Zero, with no draw consumed, when there is no target; the target is not in - `VisibleEntities`; range is outside `[MinRange, Range]`; a `LockWeapon` is not - locked; or `InArc` fails. - - Otherwise `p = Accuracy * pSensor * pSpread`, where - `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info))` and - `pSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1`, with - `angularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range))`. - - `SchematicCellSize` is the one new tuning constant (Q4). -- `FireControl.Fire(weapon, item, source)` — called once per burst step from - `InstantWeapon.Execute` (replacing the bare `OnFire?.Invoke()` at - `InstantWeapon.cs:228`). It computes the predicted intercept, the flight time - (`range / Velocity`, or zero when `Velocity` is at or near zero), **freezes the payload - snapshot** (R10) and appends a `PendingShot` to `Zone.PendingShots`. The snapshot holds - `Damage`, `Penetration`, `DamageSpread`, `DamageType`, `Aimed`, the resolved targeting - stats, and the intercept point. Nothing downstream re-evaluates a stat. -- `FireControl.Step(Zone, dt)` — called from `Zone.Update` after the entity loop - (`Zone.cs:150`). For each pending shot: age it; at `ArrivalTime - CommitHorizon` - compute deviation, roll, write `Outcome`, publish `Zone.ShotCommitted`; at - `ArrivalTime` apply the outcome and remove the shot. A shot whose flight is shorter - than the horizon commits at fire time, in the same call as `Fire` — that is every - velocity-0 weapon, which is three of the catalog's eighteen. -- `GameplaySettings.CommitHorizon` — R4's authored horizon, one number, in seconds. -- The deviation term: `pDeviation` folds in how far the target is from the predicted - intercept at commit time, forgiven by the targeting system's `Tracking`. This is R3's - evasion, and the commit horizon is what bounds how late it counts. -- The roll: one draw against `p`. On a hit, a second draw against `Precision` decides - where — on `Aimed`'s footprint cells on success, otherwise on one cell drawn uniformly - over the hull `Shape`. An active shield that `CanTakeHit` takes the hit instead, and - the outcome is marked `Shielded`; that rule now exists once, in `FireControl`, rather - than seven times in the effects. -- `Zone.ShotCommitted` and `Zone.ShotResolved` (`Subject`), the presentation - feed. -- `InstantWeapon.OnFire` becomes `event Action` carrying the `ShotId`, so the - Unity manager can bind its effect to the shot it belongs to. - -**Per-file changes:** - -- `InstantWeapon.cs:228`: `OnFire?.Invoke(FireControl.Fire(this, Item, Entity))`. - Charge multipliers already fold into `Damage` and `Spread` before this point. -- `Combat.cs:100-102`: - `shouldFire = FireControl.HitProbability(...) >= Settings.AgentMinHitProbability`, - replacing Cut 1's bare `InArc` (which the probability already subsumes, returning zero - out of arc). `AgentMinHitProbability` is added here, not in Cut 1. -- `TurretController.cs:81-83`: the same gate and threshold. -- `EntityInstance.cs:203-204,232-235`: the manager call takes the `ShotId` and looks the - outcome up, or subscribes to `ShotCommitted`. Effects fly to the rolled cell's world - point or to a miss offset and play the impact on arrival. **They apply nothing.** -- `Zone.cs:150`: `FireControl.Step(this, deltaTime)` after the entity loop. - -**Authority map:** - -- **Owner:** `FireControl` owns the engage gate, hit probability, the roll, the cell, the - shield branch, and when each of those happens. `Entity` owns the application of damage - to its own schematic. `Zone` owns the pending-shot collection. -- **Inputs:** weapon stats through `EquippedItem.Evaluate`; targeting-system stats; - `EntityInfoGathered`; `Target` and `TargetItem`; both entities' positions, velocities - and planar directions; mount direction and arc; `ItemManager.Random`. -- **Outputs:** `ShotOutcome` on two subjects; `Entity.DamageSchematic`; shield - `TakeHit`; `Entity.IncomingHit`. -- **Derived state:** `Behavior.Direction`, reveal, hit probability. Everything a - presentation shows about a shot is derived from the outcome. -- **Forbidden writers:** after this cut no `Physics.*` query in - `Assets/Scripts/Gameplay/Weapons` (except the unused four Cut 4 handles) may reach - durability; `HullCollider` may not raise a hit; `EntityInstance` may not damage a - schematic; no effect may call `TakeHit`; nothing outside `FireControl` may draw for a - hit; nothing outside presentation may read a commit to change state. -- **Shared paths:** player fire, AI fire, turret fire, instant, charged, guided and - locked weapons all reach damage through `FireControl.Fire` and `FireControl.Step`. - There is exactly one commit primitive. -- **Deletion line:** the six deletions above land before `FireControl.Fire` exists. - -**Verification:** - -- builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`; and Unity - batchmode with the editor closed (`docs/item-provenance-cut.md` §6) reporting no - `error CS`. -- tests, each with the mutation that must kill it: - - | Test | Pins | Mutation | - |---|---|---| - | `RollsAreSeeded` | *a fight is reproducible* — two zones, same seed, 50 shots, identical outcome sequences | `FireControl` draws from `new Random()` | - | `ProbabilityFollowsInputs` | *the roll reads what the ruling says it reads* — zero below detection and outside range; rising with info to `Resolution`; falling with `Spread`; capped at `UnaidedAccuracy` with no system | drop `pSensor`; drop the range gate; drop the cap | - | `OutOfArcConsumesNoDraw` | *the gate is a gate, not a penalty* — the RNG sequence is unchanged by an out-of-arc trigger | roll first and multiply by zero | - | `ShotResolvesOnArrival` | R3 — no durability change before `range / Velocity`; a target removed mid-flight takes none | apply at fire time | - | `OutcomeCommitsBeforeImpact` | R4 — `ShotCommitted` fires exactly `CommitHorizon` before `ShotResolved`, once per shot | publish at arrival; publish twice | - | `EvasionCountsUntilCommitAndNotAfter` | R4's second half — a target that jinks before the horizon changes the outcome distribution; the identical jink after the horizon does not | measure deviation at arrival; measure it at fire | - | `ShortFlightCommitsAtFire` | *the horizon degrades gracefully* — a velocity-0 weapon commits and resolves in one tick, still publishing both events in order | skip the commit for short flights | - | `OutcomeIsSnapshotNotReread` | R10, the seam the stats campaign named — a weapon whose `Damage` collapses (power cut, heat spike) between fire and arrival still lands the damage it rolled | re-evaluate `Damage` at arrival | - | `AimedHitLandsOnSelectedItem` | R5's payoff — `Precision` 1 and `p` 1: only the aimed item's durability falls | the roll ignores `Aimed` | - | `HardpointHitDamagesItemThenHull` | the damage rule survived the move verbatim — damage above armor plus item durability zeroes the item and the remainder hits the hull | armor not subtracted; hull skipped | - | `PenetrationMarchIsPlanar` | R7 — the march rotates by the target's planar direction and gives the same cells whatever the height difference | use a 3D rotation | - | `ShieldTakesHit` | *the absorb rule has one owner* — an active shield absorbs and the schematic is untouched | remove the shield branch | - | `DeadEntityStopsTakingShots` | *a shot cannot damage a corpse* — a target destroyed before arrival takes nothing and the shot is removed | resolve against a removed entity | - -- negative greps, each checked against legitimate names before publishing: - - `rg -n "Physics\." Assets/Scripts/Gameplay/Weapons/Projectile.cs Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs Assets/Scripts/Gameplay/Weapons/Laser.cs Assets/Scripts/Gameplay/Weapons/Lightning.cs Assets/Scripts/Gameplay/HullCollider.cs` - — empty. (A repo-wide `Physics\.` grep is **not** valid until Cut 4, and never for - `TractorBeam.cs`, which stays.) - - `rg -n "SendHit|SendSplash|DamageSchematic" Assets/Scripts --glob '!**/ServerShared/**'` - — empty. - - `rg -n "TakeHit\(" Assets/Scripts --glob '!**/ServerShared/**'` — empty. Note - `CanTakeHit` shares the substring; the trailing `(` on `TakeHit` distinguishes them, - and the `Can` prefix must be checked by eye in the first run. -- operator play smoke: shots show rolled impacts and misses; a miss reads as a - deliberate near-miss rather than a bug; damage matches the HUD; aiming at a revealed - subsystem visibly concentrates damage there; kill time is in the same order as before. - -**Risks.** `UnaidedAccuracy`, `SchematicCellSize`, `CommitHorizon` and the targeting stat -ranges are first guesses. The headless fixture is the tuning harness, and the operator -smoke is the arbiter. Second: `Combat.cs:84-98` aims `LookDirection` at a first-order -intercept, and R3's deviation is measured against `FireControl`'s own predicted -intercept; if the two predictions differ, an AI will systematically miss. They must be -one function. Hands makes `FireControl` own the prediction and `CombatState` call it. - ---- - -## Cut 4. The unused kinds join the same path - -R9 keeps every weapon type. This cut makes the ones no design currently ships obey the -same owner, so that authoring one later does not resurrect Unity hit detection. - -- **Repo/branch:** continues Cut 3. Depends on Cut 3. -- **First:** **re-measure the claim this cut's safety rests on** — that no live design - uses `ConstantWeaponData`, `HitscanEffect`, `Mine`, airburst or MIRV (§0.4, carried - from a superseded map and not reproduced by this campaign's probe). A behaviour-class - census needs `AetherDb roles-migrate` as a dry run, which the permission classifier - blocked; the operator can unblock it or Hands can add a read-only `behaviors` command. - **If any of these is live, it moves into Cut 3 instead.** - -**Deletes first:** - -- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:109-122` — airburst `OverlapSphere` and - `SendSplash` (14). -- `Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs:74-110` (~37), - `ConstantLightning.cs:54-70` (~17), `HitscanEffect.cs:31-55` (~25), - `Mine.cs:68-75,92-117` (~34), `ConstantParticleWeapon.cs:72` and its `_hull` plumbing - (~8) — every remaining `Physics.*` hit query and `SendHit`/`SendSplash` call in - `Assets/Scripts/Gameplay/Weapons`. - -**Adds:** - -- `ConstantWeapon` rolls once per `GameplaySettings.BeamResolveInterval` for - `Damage * interval`, through the same `FireControl.Fire`/`Step` pair with a flight time - of zero, raising `OnBeamShot(int shotId)`. About ten lines; no design uses it yet, and - it authors no new stat. -- Splash — airburst and mine blast — as `FireControl.Splash`, applying the existing - `DamageSchematic` over the directional half-shape the deleted - `EntityInstance.cs:318-329` computed, against every entity in radius. This is the one - genuinely new rule in the cut, and it exists because R9 keeps the flak cannon and the - mine. -- Nothing else. `Mine`, `TractorBeam` and ship collision stay Unity-physics surfaces for - *placement and contact*, which is not hit detection; only their damage application - moves. - -**Authority map:** unchanged from Cut 3 except that the forbidden-writer list now covers -every file in `Assets/Scripts/Gameplay/Weapons`, and `FireControl` gains splash. - -**Verification:** - -- builds: as Cut 3. -- tests: - - `BeamRollsPerInterval` pins *a beam is a sequence of rolls, not a continuous truth*: - over one second a beam produces `1 / BeamResolveInterval` outcomes totalling - `Damage`. Mutation: roll per tick; roll once. - - `SplashHitsEveryEntityInRadius` pins *splash is one rule, not per-effect*: two - targets in radius both take damage, one outside takes none. Mutation: damage only - the nearest. - - `SplashIsDirectional` pins that the moved half-shape rule survived: cells facing the - blast take it, cells facing away do not. Mutation: damage the whole shape. -- negative: `rg -n "Physics\." Assets/Scripts/Gameplay/Weapons` returns **only** - `Mine.cs`'s placement query, if Q7 keeps it — otherwise empty. `TractorBeam.cs:21` is - outside this directory and stays. - `rg -n "SendHit|SendSplash" Assets/Scripts` empty. - `rg -n "class HullHitEventArgs|class HullSplashEventArgs" Assets/Scripts` empty. -- operator: nothing to check in play, because nothing in the catalog spawns these. The - check is a Studio authoring pass: author one beam product and one flak product onto a - test hull and confirm they damage. - ---- - -## 5. Subtraction ledger - -Estimates, to be compared with reality in the postmortem. - -| Cut | Removed | Added | Deps / targets / formats | -|---|---|---|---| -| 1 | 19 production (Entity 2, EntityInstance 4, Behaviors 6, Combat 3, TurretController 3, Settings 1) | ~60 production (`FireControl.cs` geometry ~45, `HardpointData.FiringArc` 1, `GameplaySettings.FiringArc` 1, call sites ~13), ~90 test | +1 catalog field, authored on one hull. No new project, package or dependency. | -| 2 | 0 | ~140 production (`TargetingSystem.cs` ~55, reveal ~25, `Entity.TargetItem` ~25, AI selection ~10, player input ~15, `LoadoutGenerator` ~12), ~160 test | +1 behaviour union index, +2 designs and their products, +1 setting. | -| 3 | ~285 production (EntityInstance 120, HullCollider 45, Projectile 47, GuidedProjectile 38, Laser 36, Lightning 39) | ~330 production (`Entity.DamageSchematic`/`ApplyHit` ~130 moved, `FireControl` roll and lifecycle ~140, `Zone` pending shots ~35, call sites ~25), ~280 test | +2 settings. **Seven duplicated copies of the shield-absorb rule collapse to one.** | -| 4 | ~135 production | ~70 production, ~70 test | +1 setting. | -| **Total** | **~439** | **~600 production, ~600 test** | | - -Net production about +160. The account: it buys the invariant that the simulation -resolves combat without Unity — `docs/three-gates-scope.md:101-110` calls today's state -a velocity compromise — and it collapses seven copies of the absorb rule and one copy of -the damage rule into single owners. It does **not** buy that with a registry, an adapter, -a mode flag or a compatibility layer: the old path is deleted in the cut that replaces -it, and no cut leaves two writers of damage. - -The line count is the wrong thing to stare at here. The structural delta is: one new -owner (`FireControl`), one new subsystem (`TargetingSystem`), one field on catalog data, -four settings, and **minus one whole category of authority** — Unity physics deciding -gameplay. - ---- - -## 6. Build budget - -- **Packages and targets.** `Aetheria.Shared/Aetheria.Shared.csproj` (netstandard2.1), - `tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj` (net10.0, xunit), - `tools/AetherDb/AetherDb.csproj`. Three projects, no new ones. Debug only; no release - profile, no feature flags, no code generation, no new target platform. -- **Build host and target are the same machine**: a Windows workstation producing - managed assemblies for the same runtime. Nothing here is cross-platform, so no claim - about a Linux artifact is made or needed. -- **Where.** `F:\Projects\Aetheria-stats` (the free worktree; it must first be advanced - from `c4f6029e` to HEAD). Never the main tree — the operator plays there. Never - `F:\Projects\Aetheria-tiers`. CultLib via - `-p:CultLibRoot=\cultlib-45c2f40`, the shared pinned worktree, which is - never removed. `--artifacts-path` into scratch so no `bin`/`obj` lands in a working - tree. -- **Footprint.** Three managed projects plus their test host; on the order of a few - hundred MB of artifacts, comparable to the stats campaign. No expected growth: this - campaign adds no project, package reference or native component. -- **Unity.** Batchmode compile check with the editor closed, per - `docs/item-provenance-cut.md` §6, once per cut that touches `Assets/Scripts/Gameplay` - (Cuts 1, 3, 4). The operator's play smoke is the only Unity *run*. Imagination did not - run Unity and did not build. -- **Mutation harnesses.** One per cut, in the established shape - (`tests/mutation_tests_fire_control_cut1..4.py`): patch a named anchor, run - `dotnet test tests/Aetheria.Shared.Tests --cultlib-root `, assert the named test - goes red, with a no-op control. Note the scar from `8dddda4d`: later cuts move earlier - anchors, so Cut 4 re-anchors Cuts 1-3's harnesses before it lands. - ---- - -## 7. Seams this campaign names and stops at - -- **Presentation of shots.** `Zone.ShotCommitted` and `Zone.ShotResolved` are the whole - contract. What a projectile, panel, whip or near-miss *does* with a commit belongs to - `docs/shield-presentation-contract.md` and the panel and Lariat maps. This campaign - guarantees only that the commit exists, is authoritative, arrives `CommitHorizon` - before impact, and carries what the contract says it carries: what happens, where, - when it arrives, and which capability answers it — not who fired, not damage numbers. - The contract's open question "where the commit window lives" is answered here: - `FireControl`, with the horizon in `GameplaySettings`. -- **Death, removal and loot.** `Entity.Death` is already composed in `ServerShared` - (`Entity.cs:363-366`), but `Zone.Entities.Remove` and the loot roll live in Unity - (`EntityInstance.cs:411-437`) and use `UnityEngine.Random`. Fire control makes a - headless kill *happen*; it does not make the corpse go away. Q3. -- **Pickup.** `ShieldManager.cs:40-51` stores loot on trigger contact. Untouched. -- **Ship collision, `TractorBeam`, mine placement.** Deferred Unity-physics surfaces - (`docs/three-gates-scope.md:98-99`). Untouched. -- **The navigation planner.** R3 makes manoeuvring evasion, which changes what a good - flight path is. The planner is a separate item in the shipping scope and this map does - not anticipate it. -- **Stance and IFF.** Ruled and partly built (`Weapon.StanceAllowsFire`, - `Weapon.cs:101`). Fire control reads the gate and does not redesign it. -- **Faction territory.** The campaign after this. - ---- - -## 8. Operator questions - -Most blocking first. - -**Q1. What makes a turret 360?** R6 says turrets are 360 degrees, but a turret hull's -`Direction` never rotates — `TurretController` writes only `LookDirection` — so under a -120-degree default a turret would only engage within 60 degrees of its spawn facing. -**A:** author `FiringArc: 360` on the `Turret` hull's hardpoints; the override R6 already -grants does the whole job and no code knows what a turret is. **B:** add -`HardpointType.Turret` and give it an implicit 360. **C:** give the arc to the hull -rather than the hardpoint. **Recommended: A**, because it buys the invariant with -authored data instead of a new type, and because a future partially-traversing turret -(a 270-degree sponson) is then already expressible. B makes the arc a property of a -category and loses that. - -**Q2. Is player fire arc-gated?** Today it is not gated at all; the player fires whatever -is equipped, wherever they point. Cut 1 makes the arc a shared rule. **A:** yes, gate the -player exactly as the AI — a side-mounted gun simply does not fire forward, and the HUD -shows which groups bear. **B:** gate the AI only, leaving the player's rear gun able to -shoot forward. **Recommended: A.** Under R1 aiming is gone, so what the player does with -a weapon group *is* the decision, and arcs are what make that decision interesting. B -also splits the shared path the doctrine cares about: manual and programmatic fire would -be two truths. It is a real difficulty change and should be felt in the smoke before the -campaign closes. - -**Q3. Does this campaign move death removal and loot, or leave them in Unity?** A -headless kill currently leaves the corpse in `Zone.Entities`, because `Entities.Remove` -is at `EntityInstance.cs:437`. **A:** move removal and the loot roll into `Zone` in -Cut 3, using `ItemManager.Random` — about 50 lines, and it makes a headless kill a -complete event. **B:** move only the removal; leave loot to the run-structure work. -**C:** leave both; fire control's tests assert on durability, not on removal. -**Recommended: B.** Removal is the half without which the simulation is lying about its -own contents, and a seeded loot roll pulls in drop velocity, bay capacity and pickup — -the run-structure campaign's material. A is more complete but widens this campaign; C -leaves `ServerShared` unable to say who is alive. - -**Q4. Three balance numbers need first values:** `CommitHorizon` (R4's "short fixed -time"), `SchematicCellSize` (metres per schematic cell, which sets how much target size -matters), and `UnaidedAccuracy`. **A:** Hands picks defaults (0.3s, measured off the -LonginusX prefab, 0.35) and the operator tunes them in the smoke. **B:** the operator -states them now. **Recommended: A** for the first two and **B for `UnaidedAccuracy`**, -because it is not a tuning number — it decides whether a targeting system is a -requirement or an upgrade, which is a design ruling. If it is low, Cut 2's required-item -rule is load-bearing; if it is high, the subsystem is a nice-to-have and Cut 2 shrinks. - -**Q5. What seeds the rolls?** `ItemManager.Random` is clock-seeded -(`ItemManager.cs:19`), so `RollsAreSeeded` cannot pass without a seed argument. -**A:** add the seed parameter here, as a two-line change. **B:** wait for the `Run` -lifecycle work that owns it (`docs/headless-playground-cut.md` Cut 1, unlanded). -**Recommended: A.** A reproducible fight is how this campaign is tuned and how a -regression is ever found again; waiting on an unstarted cut for two lines is the -compensator pattern in miniature. - -**Q6. When is the snapshot taken — fire, or commit?** `docs/stats-and-power-cut.md:1020` -says "at commit time". **A:** one snapshot, at fire: the shot carries what left the -barrel, and only deviation is measured up to the commit. **B:** two snapshots — payload -at fire, targeting stats at commit — so a targeting system knocked out mid-flight -degrades the shot already in the air. **Recommended: A.** One rule, one freeze point, and -over a horizon measured in tenths of a second B's extra fidelity is invisible while its -extra state is permanent. B is the more simulationist answer and is worth having if the -operator wants killing the gunnery computer to spoil shots already fired. - -**Q7. Mines.** Cut 4 moves mine *damage* to `FireControl.Splash` but leaves -`Mine.cs:68`'s `OverlapSphere` for proximity triggering, which is contact detection -rather than hit detection. **A:** leave it, alongside `TractorBeam` and ship collision, -as a named deferred physics surface. **B:** move proximity into `Zone` too, so the last -`Physics.*` in the weapons directory goes. **Recommended: A.** No design ships a mine, -and B buys a clean grep rather than an invariant. Revisit when a mine is authored. - ---- - -## Cut 5: the rules that landed in one path and not its twin - -Date: 2026-09-19. Branch `codex/fire-control-5` off `ca57819d`. - -Soul's pass over the whole campaign (2026-09-19) confirmed eleven findings. Most -of them are the same shape: a rule this map declared, implemented on one path, -and left off the path beside it. Splash breaks a shield the discrete roll does -not. `InstantWeapon` gates on arc and `ConstantWeapon` does not. Unity removes a -corpse from the zone and the simulation does not. This cut closes the twins. -Catalog authoring (Soul's findings 1 and 2) is Cut 6; the shared-RNG stream -(finding 6) and airburst (finding 5) are Cut 6 as well; the missing mutation -harnesses (finding 8) are Cut 7. - -### 5.1 Tracking is a floor, not a cliff (Soul finding 3) - -`FireControl.cs:102-106` falls back to `Tracking = 0` with no targeting system, -and `:273-275` turns that zero into `deviation < .01f ? 1f : 0f`. A probe fired -334 shots at a target drifting at 5 u/s with `Accuracy` forced to 1 and landed -none. Q4 asked for unaided fire to be really, really bad; this is a wall. - -- `Settings.cs`, beside `UnaidedAccuracy`: add `public float UnaidedTracking = 10f;` - with the same authoring note. Ten world units of deviation forgiveness is the - first guess and the operator's knob: a target that has strayed five units from - its fire-time projection halves an already-5% chance. -- `FireControl.Tracking` falls back to `settings.UnaidedTracking`, exactly as - `Accuracy` falls back to `UnaidedAccuracy`. One shape for both. -- `Commit` then deletes the zero branch outright: `p *= saturate(1f - deviation / shot.Tracking)`. - Tracking is now always positive, so the branch protects nothing. - -**Authority:** `Settings` owns the unaided figures; `FireControl.Tracking` is the -only reader; `Commit` no longer carries a special case for a value that cannot occur. - -### 5.2 A shot the shield cannot absorb breaks it (Soul finding 4) - -`Commit:299-302` sets `Shielded` only when `CanTakeHit` passes, and `Apply:311-326` -routes the rest to the hull -- neither calls `Break()`. `Splash:365` does. The -deleted Unity path did (`git show b7743789^:Assets/Scripts/Gameplay/Weapons/Projectile.cs`). -So the F4/F5 shield-break mechanic is unreachable for every weapon in the catalog: -an unabsorbable 5000-damage hit leaves `Broken == false` and the shield absorbs the -very next shot, refilling at `RefillDuration` instead of the punitive `RestoreDuration`. - -- `ShotOutcome` gains `public bool ShieldBroken;`. -- `Commit` decides it where it already asks `CanTakeHit`: shield present, active, - and `CanTakeHit` false means `ShieldBroken = true`. The decision is frozen with - the rest of the outcome (R4) -- a shield that recharges during the flight does - not retroactively survive. -- `Apply` performs it: `if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();` - before the hull damage. `CanTakeHit` is still called exactly once per shot. -- Presentation gains the signal for free; nothing is required to read it yet. - -### 5.3 Continuous weapons obey their arc (Soul finding 9) - -`ConstantWeapon.cs:91,99` gate on `StanceAllowsFire` alone. A side-mounted beam -fires forward. Q2 says manual and programmatic must not be two truths; instant and -continuous currently are. - -- Both gates become `StanceAllowsFire && ArcAllowsFire`, matching `InstantWeapon.cs:105`. - -### 5.4 One bearing test, including at zero range (Soul finding 11) - -`Weapon.cs:117` allows a zero-length bearing; `FireControl.InArc:47` normalizes the -zero vector into NaN and `HitProbability` returns 0. The player's trigger says fire -and the probability says impossible, so AI and turrets refuse to fire at a -co-located target. - -- `InArc` takes the rule: a planar bearing shorter than `1e-6` returns true -- - point-blank cannot fail a bearing test. -- `Weapon.ArcAllowsFire` drops its own `lengthsq` special case and simply calls - `InArc`. One owner, one answer. - -### 5.5 A destroyed subsystem stops being the aim point (Soul finding 10) - -A destroyed item is never removed from `Equipment` (`Entity.cs:475` only observes -it), so `IsRevealed` still ranks it and `ResolvedTargetItem` still returns it -- -`Precision` keeps concentrating hits onto a dead item's footprint. - -- `FireControl.IsRevealed` returns false for an item whose - `EquippableItem.Durability < .01f`, and the ranking excludes destroyed items so - the reveal tiers of the survivors close up rather than leaving a gap. -- `Entity.ResolvedTargetItem` therefore drops a destroyed aim point on its next - read. Nothing caches it, so no clearing pass is needed. - -### 5.6 Death removes the ship, in the simulation (Soul finding 7) - -Operator ruling Q3 says death removes the ship. `Zone.Entities.Remove` for death -exists only at `EntityInstance.cs:324`, inside Unity's loot-drop subscription. So -headless, `Step`'s `targetGone` guard never fires, shots land on corpses, `Splash` -keeps hitting them, the AI keeps engaging them, and the tuning harness this map -calls its arbiter cannot end a fight. `DeadEntityStopsTakingShots` passes only -because it calls `Entities.Remove` by hand. - -- `Zone` subscribes each entity's `Death` observable when the entity joins, and - removes it from `Entities` on death. The simulation owns the corpse. -- `EntityInstance.cs:324` deletes its `Entities.Remove` call and keeps the loot - drop and destroy effect, which are its own business. Forbidden writer: no - presentation removes an entity from the zone. - -### 5.7 Delete the two carried-and-unread fields (Soul finding 12) - -`PendingShot.PredictedIntercept` and `PendingShot.FlightTime` are written at -`FireControl.cs:204-205` and read nowhere. The intercept in particular is -carry-weight from Cut 3's named risk: `Combat.cs:109` and `Fire` do share -`FireControl.PredictedIntercept`, but the roll judges deviation against a -straight-line projection from fire position, so the stored copy decides nothing. - -- Both fields are deleted from the struct. The local `flightTime` stays; it still - computes `ArrivalTime` and `CommitTime`. - -### Verification - -Each of 5.1-5.7 gets a test that fails under its own mutation, and the mutation -harness is committed as `tests/mutation_tests_fire_control_cut5.sh` with a no-op -control. In addition this cut pins four of the five mutations that survived 197 -green tests in Soul's pass, because they are rules 5.1-5.6 touch: - -- `FireControl.cs:365` -- delete `shield.Break()` from `Splash`. Must die. -- `Weapon.cs:117` -- `ArcAllowsFire => true`. Must die. -- `InstantWeapon.cs:105` -- delete the `if (!ArcAllowsFire) return;` player arc gate - (Q2, the ruling Cut 1 deferred to Cut 3 and nothing verifies). Must die. -- `FireControl.cs:270` -- `elapsed = shot.FlightTime` in place of `now - shot.FireTime`. - This is the map's own declared mutation for `EvasionCountsUntilCommitAndNotAfter`, - and it survives because that test's fixture gives the target zero velocity, so - `FireTargetVelocity * elapsed` is identically zero and the mutation is a no-op - inside it. Give the fixture a moving target. (After 5.7 deletes `FlightTime` the - mutation's spelling becomes `elapsed = shot.ArrivalTime - shot.FireTime`; the rule - it attacks is the same.) - -The fifth survivor -- freezing `source.TargetItem.Value` instead of -`ResolvedTargetItem` at `FireControl.cs:193`, bypassing the reveal re-check -- is -Cut 7's, alongside the missing Cut 1-3 harnesses. - -Negative checks: no `Entities.Remove` for death outside `Zone`; no `Tracking` read -that can return zero; no `PredictedIntercept` or `FlightTime` field on `PendingShot`. - ---- - -## Cut 6: the dice stop being shared, and the flak cannon works again - -Date: 2026-09-19. Branch `codex/fire-control-6` off Cut 5. - -Cut 6a (catalog authoring, worktree `Aetheria-catalog`, branch -`codex/fire-control-6-catalog`) runs in parallel and is specified by Soul's -findings 1 and 2 plus the Cut 2 authoring spec above. This section is Cut 6b, the -two remaining code findings. - -### 6.1 A shot's dice belong to the shot (Soul finding 6) - -`Commit:266,304` draws from `ItemManager.Random`, a single stream shared with -`ActionGameManager.cs:552,558,567,686,787` (wormhole exit velocity, loadout -generation, travel) and `Combat.cs:144` (`SampleDps`, drawn per AI per -evaluation). Q5's reproducible fight therefore holds only inside a hermetic -fixture: in play, adding an NPC or opening a shop changes every subsequent die. -Soul's determinism probes passed for exactly that reason, which makes them a -demonstration of the fixture rather than of the rule. - -The fix is a deletion, not a second stream. A stream makes the roll depend on -*how many* draws came before it; what this campaign actually promised is that a -shot's outcome is a function of the shot. So make it one. - -- `Zone` exposes `public uint CombatSeed { get; }`, set in the constructor from - the same expression that already seeds `_random`: - `galaxyZone?.Name.StableHash() ?? 1337u`. One zone, one stable identity — a - galaxy seed reproduces it, and nothing else in the zone can perturb it. -- `Commit` stops touching `ItemManager.Random` entirely. It builds its own local - `CultMath.Random` from the zone seed and the shot's own id: - `var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);` - The `| 1u` guards the degenerate zero seed. Every draw the commit makes — the - hit roll, the precision roll, the cell pick — comes from that one local - generator, and it dies with the call. -- The write-back at `:304` (`shot.Source.ItemManager.Random = random;`) is - deleted with it. - -**Authority:** the roll's randomness is a pure function of `(zone identity, shot -id)`. **Forbidden writers:** nothing outside `Commit` may seed or advance a -combat draw, and `Commit` may not read a shared stream. **Consequence worth -stating:** two shots that happen to share a `ShotId` across two zones roll -differently, because the zone seed differs; two runs of the same fight from the -same galaxy seed roll identically no matter what the UI did in between, which is -the promise Q5 actually made. - -### 6.2 Airburst resolves in the simulation (Soul finding 5) - -Cut 4 deleted `Projectile.cs`'s `OverlapSphere`/`SendSplash` and added -`FireControl.Splash` "because R9 keeps the flak cannon and the mine". Only the -mine was wired (`Mine.cs:99`). `Projectile.cs:16-17` still carries -`AirburstDistance`/`AirburstRange`, still written by `ProjectileManager.cs:32`, -read by nothing — so a flak round flies and does nothing. The stale comment at -`Projectile.cs:7-8` calls airburst "Cut 4's — untouched here". - -The wiring does **not** go back into `Projectile`. R8 says presentation does not -decide damage, and a projectile MonoBehaviour calling `Splash` would be exactly -the authority Cut 3 spent itself deleting. Airburst is a property of the weapon, -so the simulation resolves it: - -- `PendingShot` regains a burst point — `public float3 BurstPosition;` — frozen - at fire like every other payload field, from `PredictedIntercept` (which Cut 5 - deletes as unread; this is its live consumer, and it comes back named for what - it is rather than for what computed it). -- `PendingShot` gains `public float BurstRadius;`, frozen from the weapon's - authored airburst range, and zero for a weapon without the `Airburst` flag - (`Enums.cs:101`). -- `FireControl.Fire` sets both when the weapon's `WeaponData` carries `Airburst`. -- `FireControl.Step`, at arrival, resolves an airburst shot by calling - `Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType)` - **instead of** `Apply`. An airburst round does not also roll a discrete hit: - it is an area effect, which is the whole point of the flag, and applying both - would be the double-application Soul was told to hunt for. -- `Projectile.cs` deletes `AirburstDistance` and `AirburstRange` and the stale - comment; `ProjectileManager.cs:32` deletes the line that wrote one. The - projectile's remaining job is to fly and disappear, which is all presentation - should have been doing. - -**Open tuning question, recorded not asked:** the authored airburst radius has no -sibling in the catalog to argue from, because no product has ever used it. Cut 6a -or a later authoring pass sets it; until then the flag is wired and unexercised, -and the report must say so rather than implying a working flak cannon. - -### Verification - -Committed harness `tests/mutation_tests_fire_control_cut6.sh`, no-op control -required, same byte-exact I/O rules as Cut 5. - -- `SameFightRollsSameThroughUnrelatedDraws` pins 6.1: run a fixed fight twice from - one zone seed, drawing an arbitrary number of values from `ItemManager.Random` - between the two runs, and the outcomes must be identical. Mutation: restore the - `ItemManager.Random` draw. Must die — and note that this is the test Soul's own - probe could not be, because a hermetic fixture cannot see the defect. -- `ShotIdDecidesTheDie` pins that the roll is a function of the shot: two shots - with the same frozen payload and different ids may differ; the same id in the - same zone always gives the same result. Mutation: drop `ShotId` from the seed. -- `AirburstSplashesAndDoesNotAlsoRoll` pins 6.2: an airburst shot damages - everything in its radius, and its target takes area damage rather than a - discrete cell hit. Mutation: call `Apply` as well as `Splash`. Must die. -- `NonAirburstNeverSplashes` pins the other side. Mutation: splash unconditionally. -- negative: `ItemManager.Random` appears nowhere in `FireControl.cs`; - `Airburst` appears nowhere in `Assets/Scripts/Gameplay`. - ---- - -## Cut 6c: the formula gets fixed, and the single point of failure - -Date: 2026-09-19. Branch off Cut 6a (`e5f1318a`) once Cut 5 has merged — this -touches `FireControl.cs`, which Cut 5 owns until then. - -Cut 6a landed the catalog data that Cut 2 never shipped: 53 designs, 43 products, -`loadout` green on ten seeds, turret arcs migrated. Reviewing it turned up two -defects it introduced and one it inherited. - -### 6c.1 Resolution becomes reciprocal, so the name stops lying - -`HitProbability` computes `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution(source), info))`. -With the live `TargetDetectionInfoThreshold` of `.1`, a **higher** Resolution -means **more** gathered info is needed before sensor state stops limiting hits. -Resolution is a cost. The field's own comment says so accurately -- "the info -level at which sensor state stops limiting hits" -- and the word "Resolution" -says the opposite, because in every other context a higher resolution is a -better instrument. - -Cut 6a authored the 1-cell `Targeting Computer` at `.3` and the 2-cell -`Fire Control Array` at `.75`. Against a target scanned to info `.3`, that gives -the cheap design `pSensor = 1.0` and the 150000c design `pSensor = 0.31`. The -premium item is strictly worse until the player has scanned hard, which is not a -tradeoff anyone authored -- it is the formula misleading an author who had read -it and said so in the same paragraph. - -Operator ruling, 2026-09-19: "When the formula is unintuitive, we don't make the -handle more opaque for correctness, we fix the formula. Plenty of core gameplay -formulas are reciprocal just for this reason." An earlier draft of this cut -proposed renaming the field to `SensorDemand`; that is superseded. The name -stays `Resolution`, higher stays better, and the formula takes the reciprocal: - -- `HitProbability` derives the ceiling rather than reading it directly: - `var demandCeiling = detection + (1f - detection) / max(Resolution(source), 1e-3f);` - then `pSensor = saturate(unlerp(detection, demandCeiling, info))`. -- Resolution `1` therefore means "needs complete information before sensor data - stops penalising this system" -- which is precisely what the unaided fallback - of `1f` should mean. That fallback stays as it is and is now the floor by - construction instead of by coincidence. The guard against a zero or negative - authored Resolution is the `max`, and no authored value should ever reach it. -- Re-author both designs on the new scale: 1-cell `2` (sensor-limited until info - reaches `.55`), 2-cell `4` (until `.325`). Both comfortably better than unaided, - the premium one better than the cheap one, and the numbers now read the way a - designer reaching for the knob expects. -- `TargetingSystemData.Resolution`'s comment gains one sentence naming the - direction and the reciprocal, so the next author does not have to derive it. - -### 6c.2 One seller is a die roll against ship generation - -Cut 6a sold both targeting designs from NiteLife Energy alone, satisfying the -Cut 2 spec's letter ("at least the manufacturers that sell the capacitor") while -missing what that clause was for. `Galaxy.cs:109` selects -`factions.OrderBy(random).Take(settings.MegaCount)` — a **random subset** of the -twelve factions — plus the quest faction and the authored neutrals. A galaxy that -does not draw NiteLife has no targeting product, `IsAvailable` -(`LoadoutGenerator.cs:154`) filters every candidate out, and -`FillInterior`'s `required: true` throws `InvalidLoadoutException` again. Cut 6a's -ten green seeds do not disprove this; they sample the draw. - -Two changes, because content coverage and structural fragility are different -problems and only one of them is fixed by authoring: - -- **The generator degrades instead of throwing.** A `required: true` interior - item with no available product leaves the entity without one rather than - failing generation outright. This is only safe *because* Cut 5.1 gave unaided - fire a floor: such a ship now fires badly instead of not at all. The generator - logs the gap so it surfaces as content debt rather than silence, and `AetherDb - loadout` reports it per seed. -- **Thematic sellers, which also buy coverage.** NiteLife Energy is the roster's - power-generation faction; targeting belongs to Finch Cybernetics (passive - sensors) and Lucent Media (active sensors), both of which the roster names for - exactly this. Each design gains products from both. That is three sellers per - design, and it puts the item in the hands of the factions whose brand explains - why they make it. - -### 6c.3 Recorded, not fixed - -`Large Drive`'s new product declares no roles because the design declares none. -That is honest, and it is also a design with no role axes in a game where roles -are how products differentiate. It belongs to the content pass, not here. - -### Verification - -- A test pins 6c.1's direction: two entities, identical but for `Resolution`, at - the same info level -- the **higher** Resolution has the higher - `HitProbability`, at every info level between detection and full. Mutation: - drop the reciprocal and read Resolution as the ceiling directly (the shipped - behaviour). Must die. This is the assertion whose absence let a premium item - ship strictly worse than a cheap one. -- A test pins 6c.2: a galaxy containing no seller of any targeting design still - generates every hull, and those entities resolve `UnaidedAccuracy`. Mutation: - restore the throw. Must die. -- `AetherDb loadout` across at least 50 seeds, plus a run with the faction set - forced to exclude all three sellers. Report the gap count, not just "no throw". -- Census unchanged at 53 designs; products rise by 4 (two more sellers per design). - ---- - -## Cut 7: the harnesses that were prose - -Date: 2026-09-19. Branch `codex/fire-control-7` off Cut 6. - -`git log --all -- tests/mutation_tests_fire_control*` returns exactly one commit, -`034e8d3e`, adding `cut4` alone. §6 of this map declares `cut1..4`. So the Cut 3 -mutation table above (seeding, the commit horizon, the frozen snapshot, the -shield branch, the penetration march, the roll itself) is prose: it names the -mutations that should die and nothing has ever run them. - -That is not a paperwork gap. Soul wrote five mutations against rules this map -claims to pin and **all five survived 197 green tests**, including -`InstantWeapon.cs:105`, which is operator ruling Q2 — the one thing Cut 1 -explicitly deferred to Cut 3. Cut 5 pins four of them because it touches their -rules. This cut pins the fifth and builds the harnesses that should have existed -since Cut 1, so that every verdict this campaign has recorded becomes reproducible -rather than remembered. - -### 7.1 The fifth survivor: the reveal re-check - -`FireControl.cs:193` freezes `Aimed = source.ResolvedTargetItem`. Mutating it to -`source.TargetItem.Value` bypasses the reveal re-check — you could aim at a -subsystem you have lost resolution on — and 197 tests stayed green. - -- A test pins that an aim point whose reveal has decayed below its tier is **not** - frozen into the shot: the shot still fires, at a random cell, with no `Aimed`. - Mutation: read the raw `TargetItem.Value`. Must die. -- Note the interaction with Cut 5.5: a *destroyed* aimed item must fall out by the - same path, through `IsRevealed`, not through a second check. If pinning this - needs two tests, the rule has two owners and that is itself the finding. - -### 7.2 Harnesses for Cuts 1, 2 and 3 - -Three committed scripts — `tests/mutation_tests_fire_control_cut1.sh`, `cut2.sh`, -`cut3.sh` — each with a no-op control, byte-exact I/O, anchors matching exactly -once, and a reverse-write restore. Each runs the mutation table its cut already -declares in this document; do not invent a new table, and do not quietly -substitute an easier mutation for one that will not die. - -**Every survivor is a finding to report, never a test to weaken.** The expected -cause, seen twice in this campaign already, is a fixture too weak to distinguish -the mutant: `EvasionCountsUntilCommitAndNotAfter` survived its own declared -mutation because its target had zero velocity, so the sabotage was arithmetically -a no-op. When a mutation survives, first ask whether the fixture differs from the -rule's actual precondition, fix the fixture, and say so in the report. - -Where a declared mutation genuinely cannot be reached by the current suite, write -it up as **not yet reached**, never as unreachable, and name what reaching it -would take. Both times that call was made elsewhere in this project it was wrong, -and a fifteen-line probe killed the mutant. - -### 7.3 What the harnesses are allowed to assume - -Cut 6a authors the first targeting-system designs into the catalog. A harness that -depends on catalog content is a harness that breaks when content changes, so -these suites build their own fixtures through the same synthetic-upsert path -`TargetingSystemTests.cs:368-403` already uses. That file is also a cautionary -example: it passes by upserting a synthetic design into a throwaway cache, which -pins the generator and says nothing about whether the shipped catalog can -generate a ship. Both are worth having; only one of them was, and the map claimed -the other. - -So: **one test in this cut asserts against the live catalog** — that -`AetheriaStores.Open` over `GameData/Aetheria.cc` yields a targeting system the -player's own hull can be generated with. That is the assertion whose absence let -Cut 2 ship code without data. - -### Verification - -- All four harnesses (cut1-3 plus the existing cut4) run clean: every declared - mutation dies, every no-op control leaves the suite green. -- The report carries the full table — cut, mutation, died/survived, and for any - survivor the fixture defect behind it. -- `dotnet test` green, with the count. - ---- - -## Cut 6d: where a hit lands is a dart throw, not a coin flip - -Date: 2026-09-19. Operator direction, 2026-09-19. - -### The defect - -`FireControl.Commit` places a hit like this: - -```csharp -if (aimedCells != null && aimedCells.Length > 0 && random.NextFloat() < shot.Precision) - cell = aimedCells[random.NextInt(aimedCells.Length)]; -else - cell = coords[random.NextInt(coords.Length)]; -``` - -`Precision` is a coin flip between *exactly on the aimed item* and *uniformly -anywhere on the hull*. Aim at a stern thruster with Precision `.35` and the 65% -of hits that miss it are spread evenly over the bow, the cockpit and everything -else — no nearer the thruster than if the player had never aimed at all. There is -no spatial term anywhere in it. - -This is the same shape as 5.1's Tracking cliff: a quantity that should be a -gradient implemented as a binary, so the failure case is maximally wrong rather -than slightly wrong. - -Operator, 2026-09-19: "I would presume that aiming for an item in the stern would -still tend hit in that general area even if it misses the item. Like throwing a -dart at the grid." - -### The rule - -One Gaussian kernel over the target's hull schematic, in cell units: - -- **Aim point.** The centroid of the aimed item's cells, or the hull's - `CenterOfMass` when nothing is aimed at. One path — the uniform-random branch - is deleted, not demoted. -- **Sigma.** Derived from the frozen `shot.Precision`, higher Precision giving a - tighter group. Authored so that the unaided floor (`UnaidedPrecision`, joining - `UnaidedAccuracy` and `UnaidedTracking` in `GameplaySettings`) is a sigma wide - enough to read as spraying at the silhouette. -- **Weights.** `w(cell) = exp(-d² / 2σ²)` for each occupied hull cell, `d` the - distance from the aim point. - -### The tradeoff, and why it cannot be a second miss stage - -Operator, 2026-09-19: "It would also mean that aiming can be a tradeoff; if -you're not precise enough, you're better off aiming center mass than risking -misses on a thin limb." - -That tradeoff only exists if a stray dart can miss the ship. An earlier draft of -this section clamped a stray to the nearest occupied cell, which destroys it: -aiming at a wingtip would cost nothing and merely redistribute damage inward. - -But resolving the stray as a *second* miss stage would break R3 — one roll -decides damage — and make `HitProbability` a lie, since a shot could pass the -roll and then fizzle on placement. So the off-hull mass is priced into the single -roll instead: - -``` -pOnHull = Σ w(cell) / (2πσ²) -``` - -the share of the kernel's total mass that lands on metal. - -- `HitProbability` gains `pOnHull` as a factor. Aiming at a thin limb with a - loose group drops the number **on the HUD, before the trigger** — the tradeoff - is legible at decision time rather than discovered afterwards. -- `Commit` draws the cell as a weighted pick over the same `w(cell)`. A hit - always lands on metal, because the chance of not landing on metal was already - charged at the roll. - -One kernel, computed by one function, used for both the probability and the -placement. Cut 3's named risk was two functions answering "where will this shot -go" and eventually disagreeing; this does not reintroduce it. - -`pOnHull` depends only on the aim point, the hull shape and the frozen -`Precision`, so it folds into `PBase` at fire time and the freeze discipline -(R10, Q6) is unchanged. - -### Consequences, named rather than discovered - -- **Every hit in the game moves**, not only aimed ones: unaimed fire becomes - "aim at the centre of mass with the worst sigma", so big central sections soak - more fire than extremities. That is the intended change, and it is a balance - change. -- **A fat hull is inherently easier to hit than a needle**, for free. -- **The two targeting products' authored `Precision` values (`.05-.2` and - `.35-.55`) become meaningless as written**, because they are probabilities and - this makes Precision a grouping tightness. They are re-authored in the same - pass as Cut 6c's reciprocal `Resolution`. -- **Double-charging risk.** `HitProbability` already carries `pSpread`, which - penalises a small target against a wide weapon spread. `pOnHull` also - penalises small targets. Check whether the two charge the same thing twice - before authoring numbers; if they do, one of them owns it and the other goes. -- **AI willingness shifts.** `Combat.cs` and `TurretController.cs` gate on - `AgentMinHitProbability` against this number, so NPCs will hold fire more than - they used to the moment `pOnHull` lands. That is a tuning pass, not a bug, but - it will show up in the first smoke and should not be mistaken for broken AI. - -### Verification - -- `AimingAtTheSternHitsTheStern`: with a mid-range Precision, hits aimed at a - stern item land in the stern half far more often than the bow half. Mutation: - restore the uniform-random branch. Must die. This is the assertion the shipped - coin flip would fail. -- `ThinLimbCostsHitChance`: `HitProbability` against an aim point on a - single-cell extremity is strictly lower than against the centre of mass, at the - same Precision — and the gap widens as Precision falls. Mutation: drop - `pOnHull` from the probability. Must die. -- `PlacementAndProbabilityShareOneKernel`: the empirical on-hull rate over many - seeded draws matches the `pOnHull` the probability reported, within tolerance. - Mutation: perturb sigma in one of the two call sites only. Must die — this is - the test that would catch the two functions drifting apart. -- `EveryHitLandsOnMetal`: no committed hit ever resolves to an unoccupied cell, - across seeds and aim points including extremities. - -### 7.4 Amendment (2026-09-19): the live-catalog test has a second job - -7.3 above asks for one test that opens the real `GameData/Aetheria.cc` through -`AetheriaStores.Open`, because Cut 2 shipped a `required: true` rule with no data -to satisfy it and every green suite in the campaign missed it. - -Merging Cuts 6b and 6c gave that test a second and larger job. 6b added -`WeaponItemData.AirburstRange` as a non-nullable `float` at a new MessagePack -key. Every weapon record in the shipped catalog was serialized before that key -existed, so the reader was handed `nil` for the absent slot, a `float` cannot -take nil, and `AetheriaStores.Open` threw outright — the game could not read its -own catalog. Fixed at `c896489b` by making the field `float?`. - -Neither cut could have caught it. 6b verified with unit tests over synthetic -fixtures and never opened the shipped catalog; 6c opened the catalog constantly -but on a branch without the field. The defect existed only in the merge, and -only when something read real data. - -So the live-catalog test is not a content check that happens to touch a file. It -is **the campaign's only assertion that the shipped data is readable by the -shipped code at all**, and it is the one test whose absence let a branch that -bricks the catalog pass every gate. Write it so it fails loudly on a -deserialization throw, not only on a missing design: - -- `ShippedCatalogOpensAndGeneratesAnArmedHull`: open the real catalog read-only - through `AetheriaStores.Open`, enumerate every `EquippableItemData` so every - document type is actually deserialized (a lazy read that never touches - `WeaponItemData` would have passed on the broken merge), and generate a - loadout for `LonginusX`. Any exception is a failure. -- Mutation: none needed for the throw path — the shipped catalog either - deserializes or it does not. For the generation half, the Cut 2 mutation - applies: drop the targeting designs' products and the test must go red. - -**The standing rule this cut records**, because it will recur every time a field -is added: a new non-nullable value-type field on a persisted CultCache document -cannot read records written before it existed. Nullable, or a migration that -rewrites every affected record — and the migration cannot run while the read -still throws, so tolerant deserialization comes first either way. - ---- - -## Cut 8: a removed entity is a dead entity, and the anchors the diagnostics moved - -Date: 2026-09-22. Branch `codex/fire-control-8` off `72c0109c`. - -Operator play smoke, 2026-09-20: heat spike on entry with the ship unable to -move, a nearby ship likewise glowing hot and motionless, shots that registered no -hits, and finally a NullReferenceException out of `LockWeapon` reaching -`Entity.EntityInfoGathered` as the other ship vanished. Diagnosis (Codex, relayed -by the operator): the other ship's behaviors updated after the entity had been -cleaned out. Not reproducible afterwards, which makes it a hidden bug rather than -a fixed one -- the diagnostics commit `72c0109c` changed timing and allocation, -not the cause. - -### 8.1 Removal and deactivation are one transition - -`Entity.Deactivate` (`Entity.cs:445`) disposes every subscription, clears -`EntityInfoGathered`, `VisibleEntities`, `VisibleEnemies` and `VisibleFriendlies`, -and sets `_active = false`. `Zone.TryDock` (`Entity.cs:943`) has always paired the -two: `Zone.Entities.Remove(ship); ship.Deactivate();`. Removal from the zone means -the entity stops living there. - -Cut 5.6 added the death path and did only half of it. `Zone.cs:78`: - - Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => Entities.Remove(add.Value))); - -The entity leaves the collection and stays **active**, holding live subscriptions -and stale references, while every surviving entity's `ObserveRemove` handler -(`Entity.cs:200-213`) nulls its `Target` and drops it from `EntityInfoGathered`, -`EntityHostility` and the visibility sets. Half the world has forgotten it and it -does not know it is dead. - -- The death subscription becomes `Entities.Remove(e); e.Deactivate();`, matching - `TryDock`'s existing pairing exactly. One transition, one owner. -- **Ordering risk to verify, not assume:** `EntityInstance` also subscribes to - `Entity.Death` (its loot drop and destroy effect, `EntityInstance.cs:300-325`). - Subscription order decides whether loot drops before or after `Deactivate`. - `Deactivate` does not touch `Equipment` or `CargoBays`, so loot should survive, - but this must be confirmed in play rather than reasoned about -- if loot stops - dropping, that is this cut's fault and the deactivate moves after it. - -### 8.2 A deactivated entity does not update - -`Zone.Update` (`Zone.cs:172`) iterates a snapshot: - - foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); - -so an entity removed part-way through a frame still receives its `Update` in that -same frame, after the rest of the world has dropped its references. `Entity.Update` -(`Entity.cs:1031`) never consults `_active`. Every behaviour then runs against -torn-down state, and `LockWeapon.cs:98` reaches `Entity.EntityInfoGathered[Entity.Target.Value]` -through a raw indexer. - -- `Entity.Update` returns immediately when `!_active`. - -This is the fix, and the raw indexer is deliberately **not** the fix. Guarding -`LockWeapon` with `TryGetValue` would silence this one call site and leave every -other behaviour running on a corpse -- the compensator the doctrine warns about. -Liveness is the entity's own property and the entity is the one place to enforce -it. `IsActive` already exists at `Entity.cs:116`; nothing was asking it. - -### 8.3 The other symptoms are not explained by this, and are not closed - -The heat spike, the motionless ships and the shots that registered no hits are -**unexplained**. One coherent story fits all three -- an item's heat driving the -targeting system offline makes `FireControl.Accuracy` fall back to -`UnaidedAccuracy` (`.05`), which reads exactly like "I can fire but nothing -lands," and an overheated thruster reads as "I cannot move" -- but that is a -hypothesis, not a finding, and nothing here has reproduced it. - -What can be said from the source: `ActionGameManager.cs:1281` passes -`Time.deltaTime` into `Zone.Update` unclamped, and `Zone.cs:160` divides by it -(`orbit.Value.Velocity = (Position - PreviousPosition) / deltaTime`). Unity caps -`Time.deltaTime` at `Time.maximumDeltaTime`, so a large spike is bounded, but a -**zero or near-zero** first frame is not bounded from below and that division is -unguarded. Worth a probe before it is worth a fix. - -This cut does not chase it. It records the symptom, fixes the defect that is -proven, and leaves the rest open with the evidence that exists -- an unreproducible -heat spike is not something to patch speculatively. - -### 8.4 Re-anchor the four harnesses the diagnostics refactor stranded - -`72c0109c` routed `HitProbability` through a new `FireControl.Inspect`, replacing -five early returns with flags and one gate. That is the campaign's own doctrine -applied correctly -- the operator's comment says it outright, that keeping the -calculation here stops the debug HUD growing a second, subtly different fire -control model -- and it rewrote the exact lines four harnesses anchor into. - -Seven anchors now fail: `cut3` (2), `cut5` (1, its **no-op control**), `cut6c` -(2, including its control), `cut6d` (2). Every one reports `tree-clean: PASS`, so -nothing leaked, but a harness whose control cannot run is defending nothing. - -- Re-anchor all seven onto the current spelling in `Inspect`. The rules they - attack are unchanged; only the text moved. -- A control that cannot find its anchor must stay a hard failure. Do not make - anchors fuzzy to survive refactors -- loudly stale is the property that makes - these harnesses trustworthy, and it is what surfaced this within one run. - -### Verification - -- `DeadEntityDoesNotUpdate`: an entity killed mid-frame receives no further - `Update`, and a `LockWeapon` on a ship whose target dies the same frame does not - throw. Mutation: drop the `_active` guard. Must die. -- `DeathDeactivates`: a dead entity reports `IsActive == false` and holds no live - subscriptions. Mutation: remove the `Deactivate()` call. Must die. -- `LootStillDropsOnDeath` if it can be reached headlessly; otherwise an explicit - operator check, named as such rather than assumed. -- All seven harnesses clean with tree-clean PASS, controls included. -- 222 tests plus the new ones; catalog reads; Unity batchmode exit 0. - ---- - -## Cut 9: the die is not a die - -Date: 2026-09-22. Branch `codex/fire-control-9` off `dcb8c448`. - -Soul pass over `ca57819d..dcb8c448` (2026-09-22). Ten findings; this cut takes the -three that block play. The rest are Cut 10. - -### 9.1 The hit roll has a range of 0.125 (Soul C1). **Blocking.** - -`FireControl.cs:356`: - - var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u); - ... - var hit = p > 0f && random.NextFloat() < p; - -`CultMath.Random` is xorshift32 and `NextFloat()` returns the top 24 bits of the -**first** round (`Random.cs:22-42`). One round does not carry a low-bit seed -difference into the high bits. `ShotId` varies only in the low bits, so the top -three bits of the first draw are fixed by the zone seed alone. - -Measured, 2000 shot ids per zone: **every zone's roll spans 0.1249**. Reproduced -independently with a different hash function — the span is structural, not a -property of `StableHash`. The consequence is that a shot is a step function of -`p` whose threshold is fixed when the zone is created: below the band it never -hits, above it always does. An unaided shooter hit 0 of 18,926 shots against the -shipped catalog. Roughly one zone in eight cannot land a fully aided shot at -p = .74. - -This is the operator's "I can target and fire with no hits registering at all," -and it is stable per zone, which is why it came and went. - -It is also this map's own prescription (Cut 6b, §6.1), written by Self, shipped -through three cuts, and never once measured. - -**The rule:** the roll must be uniform on [0,1). Not merely deterministic — -determinism is what a constant already gives, and is exactly why three -determinism tests passed over it. - -- Mix the seed before the first draw, with a standard 32-bit finalizer - (`fmix32`/`splitmix32`: xor-shift, multiply, xor-shift, multiply, xor-shift). - The per-shot seed derivation is unchanged; only its diffusion into the first - output changes. Determinism, the `(zone, shot id)` purity and the freeze - discipline all survive untouched. -- **This is a caller's obligation, not a CultMath defect.** Seeding xorshift with - a structured value and drawing once is the documented way to get a correlated - first output. Record a follow-up to give `CultMath.Random` a mixed-seed entry - point so no other GameCult consumer re-derives this the hard way — that is a - CultLib cut with its own release, not this one. - -**Verification, and the test whose absence caused this:** - -- `TheDieIsUniform`: fire many shots across several zone seeds at an - intermediate `p` (say .3 and .6) and assert the observed hit fraction is near - `p`, and that the observed rolls span substantially more than 0.2. Mutation: - remove the finalizer. Must die. -- No test anywhere in this campaign measures a distribution. Every behavioural - fixture uses `accuracy: 1` or `accuracy: 0`, which is why a die stuck on one - face satisfied all of them. That gap is the finding, not the line of code. - -### 9.2 `pOnHull` collapses to zero above Precision ~4 (Soul C2). **Blocking.** - -`FireControl.cs:497-516`. `Sigma = 1/Precision`, and once sigma falls below about -half a cell every `exp(-d²/2σ²)` underflows against an aim point that is not -exactly on a cell centre — which is **every shipped hull**, since no centre of -mass is integral (LonginusX 2.5/6.697, Zenith 5.5/5.5, Turret 3.5/3.5). Measured -`pOnHull` on LonginusX: `.516` at Precision .3, `1.000` at 1.19, `.112` at 5, -`.000` at 10 and beyond. - -So a better targeting system makes you miss, and past Precision ≈ 5 the weapon -cannot hit anything. Authored content sits at .5-1.3, just under the cliff, with -a margin of about 4x — and the operator is about to tune this number. - -- Clamp sigma at a floor of about half a cell, or normalize by the discrete sum - rather than the continuous 2πσ². Either makes `pOnHull` monotonic in Precision - over the whole domain instead of only below the cliff. -- `WeightedPick` (`:535-545`) carries a comment claiming it "can never fall - through." It can, whenever `totalWeight == 0`, returning a fixed corner cell. - It is unreachable today only because `pOnHull == 0` forces `PBase == 0` first. - Fix the cause and delete the false comment. -- Cut 6d's map section claims to name its consequences; it does not name this. - Add it. - -### 9.3 The LockWeapon crash is still reachable (Soul C3). **High.** - -Cut 8 fixed a real liveness defect and did **not** fix the operator's crash. -`LockWeapon.cs:98` only runs on an *active* entity, so 8.2's `!_active` guard -cannot be what prevents it — `tests/mutation_tests_fire_control_cut8.sh:17-19` -says as much outright, and I merged it anyway. - -The live precondition is `Target.Value` set but absent from `EntityInfoGathered` -on an active entity, and docking still reaches it: `Deactivate` (`Entity.cs:445`) -disposes the `Entities.ObserveRemove` subscription that is the only thing nulling -a stale `Target`, and nulls nothing itself; `Activate` (`Entity.cs:167`) reseeds -`EntityInfoGathered` and likewise leaves `Target` alone. Dock, let the target die -or leave, undock, and the first frame throws. - -- `Deactivate` nulls `Target` (and `TargetItem`), or `Activate` reconciles it - against the reseeded `EntityInfoGathered`. One of the two owns it; say which. -- Still not a `TryGetValue` in `LockWeapon`. The invariant is that an active - entity's `Target` is always a live entity it has info on. -- Pin it with the dock/die/undock sequence, not with a unit test on the guard. - -### Verification - -- The three tests above, each dying under its own mutation, in a committed - `tests/mutation_tests_fire_control_cut9.sh` with a no-op control, byte-exact - I/O, sha256-verified restore and an explicit tree-clean verdict. -- All nine harnesses sequentially, one at a time, all clean. -- 226 tests plus the new ones; catalog reads; `dangling` 0. -- After 9.1 lands, re-measure the fight: Soul's probe, run with a corrected die, - gave 60.8% hit rate and a 4.3 s median time to kill for an aided shooter, 2.6% - unaided. Confirm the shipped build now reproduces that, because those are the - numbers the operator will tune against. - -### Recorded for Cut 10, not fixed here - -Soul C4 (a committed hit on a target that dies mid-flight publishes as a hit), -C5 (`Inspect` is numerically exact but costs 9.87 µs and 448 B per call on paths -that used to bail — the operator's own comment on the hot path looking like a -diagnostic one has an empirical answer now), C6 (`RollsAreSeeded` is obsolete — -Cut 7 retired one such test and missed this one), C7 (`FireControlCut6cTests` -authors `Precision = 0`, making six of seven checks vacuous), C8 (`Precision = -1000` fixtures make several kernel-shape mutants unkillable, and `pOnHull`'s -deletion is caught by exactly one test), C9 (`EveryHitLandsOnMetal` runs one -configuration, not the range it claims), plus the harness-audit items: the -tree-clean verdict restores before it measures, a red result cannot distinguish a -killed mutant from a compile failure, and the no-op control covers only one file -per harness. - ---- - -## Cut 11: mutation testing moves to Stryker, and the survivors it found - -Date: 2026-09-22. Operator, on the hand-written anchor harnesses: "What is the -point of those mutation anchors? Tests should cover how the code behaves, not how -it is shaped." And on Stryker: "sounds good; we don't need to run with every -mutation active, right? > to >= on float thresholds is indeed petty." - -### Why - -The Eureka principle (2026-09-15) is sound and stays: every rule needs a test that -fails when the rule breaks. The mechanism that accreted on it over the following -week -- literal-text anchors, perl substitution, per-cut bash and Python harnesses --- coupled mutation testing to code shape. It stranded anchors on every refactor -(`72c0109c`, Cut 9, Cut 10), and at Cut 10 an anchor kept matching exactly once -while silently re-targeting onto `Inspect`, mutating code the tests no longer ran. -Soul's harness audit also found the tree-clean verdict restoring before it -measured, and a compile failure counting as a kill. - -A Stryker spike against `FireControl.cs` alone: 276 mutants, 7m18s, 208 killed, -67 survived. It killed every mutation the hand-written harnesses defended, never -edits a source file, and has nothing to re-anchor. - -### 11.1 Retire the ServerShared harnesses - -Delete the 23 hand-rolled harnesses (5,816 lines) whose targets are all under -`Assets/Scripts/ServerShared` -- every `mutation_tests_fire_control_*`, every -`mutation_tests_stats_power_*`, `mutation_tests_shield_reserve.py`, -`mutation_tests_condition_ratio_cut8.py` and `mutation_tests.py`. Stryker's scope -covers all of their targets; the behavioural tests they measured stay. - -**Kept, recorded, not yet answered:** `mutation_tests_addressables_cut.py` and the -three `mutation_tests_shield_panel_cut*.py`. They target Unity Editor probes -(`EngineAssetCheck.cs`, `ShieldPanelCut*Verify.cs`) that Unity compiles outside -`Aetheria.Shared`, so Stryker cannot reach them. They carry the same anchor -fragility; the right answer for GPU and editor code is probably that the batchmode -probes are themselves the behavioural tests, but that is a separate decision. - -### 11.2 Scope, pinned - -`tests/Aetheria.Shared.Tests/stryker-config.json` scopes mutation to -`Assets/Scripts/ServerShared/**` and excludes plugins. Per cut, run against the -cut's own diff: - - dotnet tool restore - cd tests/Aetheria.Shared.Tests - CULTLIB_ROOT= CULTMATH_ROOT= dotnet stryker --since: - -Each root must sit at its pinned revision in `Directory.Build.props` (see the Cut 12.0 -status note). - -**Float-threshold boundary flips are known-equivalent.** Stryker files `>`→`>=` -under the same Equality mutator as `==`→`!=`, and the latter catches real bugs, so -the mutator stays on. A survivor that only moves a float comparison across its -boundary is triaged as equivalent and never chased. For the same reason the raw -mutation score is not a gate; the survivor list is the signal. - -### 11.3 Degenerate fixtures - -Most of the 67 survivors trace to three constants shared across the fire-control -fixtures, each of which makes a family of mutants arithmetically identical to the -original -- the same defect class as the zero-velocity target and the durability -coincidence, found here as a pattern instead of one at a time: - -- **Shooter at `float3.zero`.** `target - source` equals `target + source`, so four - separate position mutations survive (`FireControl.cs` HitProbability, Inspect, - Fire, Apply). -- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time - mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does - not defend the rule Cut 5 gave it a moving target to defend. -- **Spread zero everywhere.** `PSpread` returns 1 on every fixture, so it is - effectively untested, and `*` versus `/` against it is indistinguishable. - -Move shooters off the origin, fire at nonzero times, and give some fixtures real -spread. Fix the fixture, never weaken the assertion. - -### 11.4 Behavioural tests the survivors point at - -- **Splash handedness.** Flipping the sign in `var right = float2(forward.y, - -forward.x)` passes every test. The operator's "damage on the wrong side of the - ship" was a legacy Unity bug; this line has read the same since `482c47bf`, so - the rewrite never shipped it, but nothing defended against it either. Self said on 2026-09-19 that Cut 6b would add a test pinning - that a port blast damages port cells; it never landed and nothing checked. Pin it. -- **The shield loses energy when it absorbs.** Deleting `Shield.TakeHit` from the - discrete path survives, because tests assert the hull was untouched and never - that the shield paid for it. -- **Splash's shield-absorb branch** has no coverage at all. -- **Zones roll differently.** `CombatSeed *` → `/` makes the seed zone-independent - and survives; `TheDieIsUniform` checks uniformity within a zone only. Tighten it - enough that the `MixSeed` weakenings that survive it (`>>`→`<<`, `^=`→`|=`) die. -- **A shot whose target left the zone resolves as a miss** (Soul C4). Today only - the uncommitted branch rewrites the outcome; a committed hit on a target that - died mid-flight is published as a hit. Fix the code and pin it. - -### Verification - -Stryker over `FireControl.cs` before and after: every non-equivalent survivor -above killed, the remainder triaged by name. 231 tests plus the new ones; Unity -batchmode clean. - -### Status (2026-09-22) - -Landed on `codex/fire-control-10`: `8ef3b34c` (Stryker tool), `b305debd` (map, -config), `724ec5e7` (11.1), `a6543af7` (11.4 and the C4 fix), `1b187db4` (11.3), -`b0fd3cd0` (second round, below). 244 tests. - -Stryker over `FireControl.cs`, 278 mutants per run: - -| Run | Killed | Survived | Score | -|---|---|---|---| -| Spike, before Cut 11 | 208 | 67 | 74.6% | -| After 11.4 | 219 | 58 | 78.6% | -| After 11.3 fixtures | 225 | 51 | 81.1% | -| After the second round | 225 | 43 | 83.3% | - -**The second round** came from reading the 51. Four were real undefended -behaviour and one was a duplicated formula: - -- `Inspect` recomputed `PBase` as its own product of the factors, so the gates and - the product had two owners, and a `*`→`/` mutant of the HUD's copy survived the - agreement test (every fixture had spread 0, so `PSpread` was 1). `PBase` is now - `HitProbability`'s own answer; the test became - `TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not - fill the silhouette. `Fire`'s redundant `target != null ?` guard around - `HitProbability` went with it. -- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed - the expected damage from its own roll count, a tautology, now deleted. - `ADamageOverrideIsWhatTheShotCarries`. -- `Tracking` could ignore the fitted targeting system: no fixture's target ever - moved off its predicted line. `AFittedTargetingSystemForgivesAJink`. -- A shot orphaned before commit could skip `ShotCommitted`, or publish its miss as - shielded. `ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss`. -- `ArrivalIn` (the HUD countdown) was unchecked; asserted in the C4 test. - -**The 43 that remain, triaged:** - -- *Boundary flips, equivalent (23):* L52, 54, 56, 75, 135 (two), 157 (two), 193 - (two), 223, 273, 410, 418 (two), 465 (two), 503, 518, 524, 586, 605, 608. -- *`MixSeed` shift and xor weakenings, measured equivalent (4):* L382, 384, 386 - (two). 11.4 asked for them to die; measured, the die stays uniform and - zone-distinct under each, so there is no behaviour to pin. They change how the - mixer is built, not whether the die is fair. -- *Unreachable through the live callers (8):* L240 and L241 - (`DeviationProbability`'s null-target branch; the engine guards it, only the HUD - can pass null), L283 (three; airburst has no authored radius anywhere, recorded - since Cut 6b), L320 (empty-list early return, an optimisation), L410 `||` (a - nonzero `PBase` implies a target), L158 and L610 (no coverage: an unlocked lock - weapon reaching this line, and the last-cell fallback of the weighted pick). -- *Recorded gaps, not yet reached (8):* L135 ternary (two; no test that the AI in - `Combat.cs` leads a moving target), L435 (three; the aimed flag on an outcome), - L464 and L465 negate (the `Apply` hit direction through the `FireControl` - path), L585 and L587 (the aimed item's centroid as aim point). These are "not - yet reached," not "unreachable." - -## Cut 12: shots arrive edge-on - -Date: 2026-09-22. Imagination pass, revised the same day after the second round of rulings -below. Anchors are against `origin/codex/item-provenance` `cd846916` (Cuts 10 and 11 merged). -Claims marked **(probe)** were measured in a scratch copy of the test project -(`scratchpad/cut12probe`, the Cut 11 fixture plus probe methods) or by a lane-model script -(`scratchpad/exposure.py`), in both cases against a copy of `GameData/Aetheria.cc`. Claims -marked **(read)** come from source at the cited anchor. - -### The defect - -The impact cell is chosen as if every shot drops in from overhead. -`FireControl.HullKernel` (`FireControl.cs:558-578`) throws a 2D Gaussian at the top-down -schematic, centred on the aim point. `POnHull` (`:233-234`), `WeightedPick` (`:601-611`) and -`ResolveAimPoint` (`:583-589`) all read that kernel, and none of them takes the shooter's -bearing. **(probe)** `Probe3_BearingBlind`: pOnHull is `0.8748` at target facings (0,1), -(1,0) and (.6,.8) alike. - -Bearing is only used after the cell has been chosen: `Entity.ApplyHit` (`Entity.cs:381-409`) -walks penetration from that cell, and `Splash` (`FireControl.cs:515-527`) picks the half of -the hull that faces the blast. So armour facing does nothing. A wedge whose bow carries all -the plate gets hit in its soft stern from dead ahead. The game is planar (R7), so a shot -travels in the plane and should arrive edge-on. - -The damage rule makes it worse. `Entity.DamageSchematic` (`Entity.cs:336-374`) splits the -damage evenly over every cell of the hit shape. **(probe)** `Probe1`: 30 damage with -penetration 3 into column 1 of the 5x3 fixture, with 10 armour on the front cell only, puts -10 on each of the three path cells. The plate absorbs its 10, the item behind it still takes -10, and the stern cell passes 10 to the hull. The item receives exactly what it would have -with no plate in front of it. - -### Rulings (operator, 2026-09-22) - -First round: - -- **Model adopted: throw the dart at the silhouette the shooter sees.** Scatter is 1D and - perpendicular to the bearing. Project the hull's occupied cells onto the lateral axis and - throw a 1D Gaussian at that projection, with the same sigma rule, centred on the aim - point's projection. pOnHull is the Gaussian's mass on that shadow. Depth comes from - geometry: walk the ray in from outside along the bearing, and the first occupied cell is - the impact cell. Penetration continues along the same ray. -- Operator: "No, you're right, this is fine. Armor absorbs first. Shields remain - omnidirectional. Map it." - - **Armor absorbs first.** Damage runs down the ray in order. At each cell the armour - absorbs first, then the item, and only the remainder travels on. The even split over - the hit shape goes, for the penetration path and the DamageSpread expansion alike. - - **Shields remain omnidirectional.** The shield stays one whole-ship reserve with no - facings. -- **Rejected:** any fraction of shots arriving top-down to preserve cockpit sniping. The - operator accepted that cockpit shots become positional: the cockpit must be exposed from - your bearing, or you must flank, or penetration must reach it with what the armour did not - absorb. Cockpit destruction is still instant death (`Entity.cs:494`). - -Second round: - -- **Q12-1 = B: the bearing that places a hit is taken at Commit.** Operator: "Of course we - must use the target's facing at arrival, tanking a hit where you have armor is basic - maneuvering." After Self explained the commit horizon: "at commit." The target's facing - therefore stays live until Commit, as deviation already does. See **Bearing timing**. -- **Q12-2 = A: spread is width.** It becomes 2n+1 parallel lanes, and each lane is absorbed - from its own facing cell. Operator: "Spread is width makes sense for shotguns, but what - about explosive penetrators? Burrowing in before splashing radially would be a satisfying - way to damage vulnerable internals." That remark became sub-cut 12.4. - - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at - angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the - shared armour and mirror-image shots did different damage in ~30% of angled spread hits. - Operator: "I would prefer if the overlapping damage cells were diffused sideways to - thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): - **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` - width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly - that wide and half-open, so no two lanes can strike the same cell; the footprint widens at - angles instead of doubling up, and axis-aligned shots are unchanged. -- **Q12-3 = A (direct hits):** a lane's remainder goes into the hull wherever the lane - ends. -- **Q12-4 = A:** delete `ShotOutcome.Aimed`. -- **Penetration retune is a follow-up.** Operator: "Retuning penetration is a follow up." - See F12-2. Until it lands, interior cells are reached only by blasts. -- **The detonation primitive (12.4): a blast is an area, not a bundle of rays.** The first - draft of this section cast rays; the operator cut that. Operator, 2026-09-22: "sorry, why - are we doing blast rays instead of areas? I'd say distribute the damage over the affected - area, but that only works for areas". So a blast at a schematic point P with radius r - covers the cells within r of P, and **damage is distributed over the disc's area**: a cell - takes the share of the disc that lies inside it. The share of the disc that falls outside - the hull's occupied cells is lost. Each covered cell absorbs independently, in the - existing order (armour, then item, then hull). Nothing travels, nothing is occluded, and - no remainder is routed anywhere. (Superseded for items by the 2026-09-25 one-path - ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) - - **A shot has a direction; a blast does not.** That sentence is the rule behind the split: - direct hits keep the lane model of 12.2 and 12.3, and blasts are areas. Where P sits: - - proximity fuse: the intercept point outside the hull (today's airburst); - - contact fuse: the impact cell; - - delayed fuse or penetrator: the end of the penetration walk. - - All three are weapons that carry a blast radius. A weapon without one is not a blast at - any penetration: it keeps 12.3's lane (see **Where 12.3 and 12.4 meet**). - - 12.4 replaces `FireControl.Splash` and its half-hull footprint, which absorbs F12-1. -- **WeaponModifiers are labels, not behaviour.** Operator: "The AP modifier was merely - there as a label. Like the Incendiary modifier which doesn't actually determine that the - weapon does Thermal damage, but exists to mark weapons that do. Modifiers show up in - schematics as a shorthand for the weapon type." Detonation behaviour comes from weapon - data fields only. -- **Not ruled:** a HUD showing which target items are exposed from the current bearing - (Q12-5). - -### What the probes found - -- **Penetration does nothing in the shipped catalog.** **(probe)** The largest authored - Penetration is `0.25` (Autocannon, SRMM72, LRMM72). `ApplyHit` only walks when - `penetration > .5f` (`Entity.cs:392`), and `WeaponData.Penetration` is inspector-ranged - `0..1` (`Behaviors/Weapon.cs:28`). -- **LonginusX's cockpit cannot be reached edge-on until penetration reaches about two - cells.** **(probe)** `exposure.py` walked lanes 0.01 cells apart at every whole degree of - bearing over the real 6x17 schematic. The ControlModule (cockpit) and the AetherDrive are - never the first cell in any lane. They are still never reached at penetration 0.25 or - 1.0, and are first reached at 2.0. The Turret hull's ControlModule is enclosed too. - Until F12-2 lands, the only way to kill a LonginusX through its cockpit is a blast - delivered inside the hull (12.4). -- **DamageSpread is live content.** **(probe)** GT 3K authors 1-3.25, plight 1-3.3, and - pswarm and scorched void policy 0.9-1.5. `Shape.Expand` is an 8-neighbour ring - (`ItemData.cs:164-174`), so spread 3 produces a 7x7 footprint today. -- **No shipped weapon carries a blast, or the Airburst label.** **(probe)** `Probe4`: - every `WeaponItemData.AirburstRange` is null, and no `WeaponModifiers` includes Airburst. - The labels in use are RapidFire, Cluster and Incendiary. All three Thermal weapons carry - Incendiary, and no non-Thermal weapon does. No weapon carries ArmorPenetrating, - including the three with penetration 0.25. So the only live consequence of demoting the - Airburst label is the code path at `FireControl.cs:282`. -- **The handedness history.** `right = float2(forward.y, -forward.x)` has not changed since - `b7743789`/`482c47bf` (`git log -S`). No sign error ever shipped. What survived was the - *mutant* `-forward.x` → `forward.x`, which is invisible when |fy| > |fx|, and Cut 11 pinned - it. The formula is duplicated at `Entity.cs:394-396` and `FireControl.cs:516-520`, and 12.1 - collapses the two copies. -- **The code has two cell conventions.** The kernel, `Shape.CenterOfMass` - (`ItemData.cs:121-123`) and `ResolveAimPoint` put cell *i* at the integer point *i*. - `ApplyHit` instead starts from `cell + .5` and truncates (`Entity.cs:398-404`), which - treats cell *i* as the square [i, i+1). **(probe)** On bearing (.8,-.6) from (1,2), the - 0.5-step march went (1,2)→(2,1) and skipped (2,2), a cell the ray actually crosses. -- **Nothing reads `ShotOutcome.Aimed`.** `rg "\.Aimed\b"` finds only `FireControl.cs:292` - (PendingShot, which stays), `:432`, `:435` and `:481`, plus `FireControlCut7Tests.cs:190` - (which also reads PendingShot). -- **`Apply` reads live positions at arrival** (`FireControl.cs:463-465`), which is an R4 - leak and Stryker's L464/L465. 12.2 removes it. -- **No simulation code places the schematic in the world.** **(read)** `SchematicCellSize` - (`Settings.cs:255`, default 2) is read only by `PSpread`, and ApplyHit and Splash convert - directions only. A blast needs world↔schematic *points*, so 12.4 names that rule. - `HullCollider` is a physics surface only (`Gameplay/HullCollider.cs:4-8`). -- **Renaming slot 29 is compatible drift, not a break.** **(probe + read)** The catalog - embeds each type's schema descriptor, including `{"slot":29,"name":"AirburstRange", - "type":"System.Nullable"}`, and the wire format is positional - (`Probe5_Wire`: `[…,12.5,50,20]`, with no field names on the wire). On a content-hash - mismatch, `CultCache.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` - (`:672-752`), which compares slot type, reference, cardinality, target schema, name - semantics and index alias, **not the member name**. So renaming slot 29 opens as - `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` - warning. The warnings persist until the catalog is next written with the new descriptor. - Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, - 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs - (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: - no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as - `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. - -### Frame, geometry and the bearing (the rules every sub-cut reads) - -- **Schematic frame, one owner.** `Entity.ToSchematic(float2 worldPlanar) → float2` maps - world to schematic with x = starboard and y = bow: `forward = normalize(Direction)`, - `right = float2(forward.y, -forward.x)`, returning `float2(dot(v, right), dot(v, forward))`. - This is the transform ApplyHit and Splash already use, with the same sign. The Cut 11 - fixture comment and the LonginusX schematic (nose at y 11-16, reactor and radiators at - y 0-3) agree with it. 12.4 adds the point forms `ToSchematicPoint(float2 worldPlanar) = - ToSchematic(worldPlanar - Position.xz) / SchematicCellSize + Shape.CenterOfMass` and its - inverse `ToWorldPoint`, so the entity's position is the schematic's centre of mass. - Operator confirmed 2026-09-22, from the schematic images: "bow is indeed +y on the - schematic images." Still on the operator's check list after 12.4 lands, because it is the - half that only a running blast can show: that blasts land where the model shows them, - which is what confirms the centre-of-mass anchor. -- **A cell is the unit square centred on its integer coordinate.** This matches - `CenterOfMass`, the aim point and the old kernel. The `+ .5` convention dies with ApplyHit. -- **Travel direction.** `FireControl.TravelDirection(Weapon, Entity source, Entity target) → - float2` is the planar world direction of `PredictedIntercept(weapon, source, target) - - source.Position`. That intercept is Cut 3's one function, and for a beam it reduces to the - target position. Below a length of 1e-6 it falls back to world +z, which is today's - `Apply` fallback (`:463`). It belongs to the shot: Fire freezes it into `PendingShot`. -- **Bearing.** `bearing = normalize(target.ToSchematic(travelDirection))`. This is the only - place a world direction meets a target's facing. -- **Lateral axis** `ℓ = float2(-b.y, b.x)`. The sign is fixed once, here. -- **Shadow.** Each occupied cell *c* projects to `[dot(c,ℓ) - h, dot(c,ℓ) + h)`, with - `h = (|ℓ.x| + |ℓ.y|) / 2`. The shadow is the sorted and merged union of those intervals; - merging matters because off-axis intervals overlap. Squares are used rather than points - because a line at lateral offset *s* crosses square *c* exactly when *s* falls in *c*'s - interval. "s lies in the shadow" is therefore the same event as "this lane has metal", so - pOnHull is exactly that event's probability, and placement drawn from the shadow always - lands on metal. -- **`Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float - precision)`** replaces `HullKernel` and is the one function. It returns the merged - intervals, `a = dot(aimPoint, ℓ)`, sigma, `Span = hi_max - lo_min`, and - `POnHull = Σ_k Φ((hi_k - a)/σ) - Φ((lo_k - a)/σ)`, where `Φ(z) = ½(1 + erf(z/√2))`. - `aimPoint` comes from `ResolveAimPoint` unchanged; only its lateral projection matters. - `Sigma` keeps its rule (`FireControl.cs:549-550`). The 9.2 rationale in its comment is - gone, because an exact 1D integral does not undershoot, so rewrite it: the floor is now a - design minimum on group tightness (F12-3). The function allocates only on the ungated - path: one interval array of length N (≤128 on shipped hulls) plus a sort. -- **PSpread** keeps its formula, but takes values instead of a `Weapon`: - `PSpread(float spread, float span, float range, float cellSize)`. The half-extent becomes - `.5f * span * cellSize` instead of `max(Width, Height)` (`:224`). PSpread and pOnHull read - one silhouette, so they cannot disagree about the target's size. -- **Lateral draw.** One `NextFloat` u, taken after the hit roll (the draw `WeightedPick` used - to take). Walk the cumulative interval mass to `u · POnHull` to select interval k, then - invert inside it with `s = a + σ√2 · erfinv(2p - 1)`. Clamp into [lo_k, hi_k) to absorb - rounding. The clamp is a guard, not an invariant. -- **Lane walk** `Lane(HullData hull, float2 b, float s, buffer)` collects the occupied cells - whose interval contains s, each with slab entry and exit parameters `t` along b, where - `t = dot(point, b)`. Cells are ordered by entry, with ties broken by `dot(c, b)` and then - cell index. It is exact slab traversal and replaces the 0.5-step sampling. The result goes - into a caller-supplied pooled buffer (`ArrayPool.Shared`; netstandard2.1 has - `System.Buffers`). A direct hit starts from t → -∞ (outside), so its first element is the - impact cell. It is the walker's only caller: blasts are areas and do not traverse. - - Metal is continuous while `next.entry ≤ current.exit + 1e-4`, and the first gap ends the - walk. That keeps today's rule at `Entity.cs:401`. -- **Penetration along a direct-hit lane.** A cell is reached when - `entry - impact.entry < penetration`, in cells. The impact cell is always reached. The - `> .5f` threshold (`Entity.cs:392`) is deleted. - -**Bearing timing (Q12-1 = B).** A shot is split into what the shooter decided and what the -target is doing: - -- **Frozen at Fire:** everything the shooter decided (R10). `PendingShot.PBase` is renamed - `PFire` because its meaning changes: it is `Accuracy × PSensor` behind the same gates, and - it is 0 when any gate fails. Also frozen: `TravelDirection`, `Precision`, `Aimed`, the - weapon's `Spread`, and `FireRange` (the range at Fire, which PSpread reads). -- **Live until Commit:** everything the target is doing. That was already its position - (deviation); now it is also its facing. `Commit` computes, in this order: - 1. `pDeviation`, as today. - 2. `bearing = normalize(shot.Target.ToSchematic(shot.TravelDirection))`, from the - target's facing at the commit tick. - 3. `sil = Silhouette(target, hull, shot.Aimed, bearing, shot.Precision)`. - 4. `p = PFire × pDeviation × PSpread(shot.Spread, sil.Span, shot.FireRange, cellSize) × - sil.POnHull`. - 5. The one roll against p. On a hit, the lateral draw from `sil`, and the impact cell - from `Lane`. - - Steps 2-5 are one function, `FireControl.CommitProbability(PendingShot, float now, - out Silhouette)`. The debug HUD calls it, which retires the inline estimate at - `ActionGameManager.cs:1317-1318` (`shot.PBase * pDeviation`, which would otherwise be a - second, wrong copy of the model). -- **The outcome freezes the committed geometry** (R4). `ShotOutcome` gains `float2 Bearing` - (schematic frame, as committed) and `float Lateral`, and keeps `Cell`. `Apply` walks - `Lane(hull, outcome.Bearing, outcome.Lateral)`. After Commit, turning the target changes - nothing, and Apply reads no position or facing. -- **HitProbability and Inspect** report `PFire × PSpread × pOnHull` at the *current* - travel direction and facing. That is a forecast, in the same way the HUD already treats - deviation. `Inspect.PBase == HitProbability` still holds, because both are the forecast. - The hot path still gates first (`:150-159`) and builds the silhouette only after the gates. - -What this does to the earlier rules: - -- **R3 (one roll, and a hit always lands on metal) still holds.** The factor the roll is - charged with, pOnHull at the commit bearing, comes from the same `sil` that placement - draws from, in the same call. Placement is exactly consistent with the pOnHull applied, - and it always lands on metal. What changes is that HitProbability's number is no longer - the exact roll price when the target turns during flight. It is the price only if the - target holds its facing, just as it already is only if the target holds its course. -- **Cut 5's deviation factor keeps its ordering and meaning.** It is still measured at the - commit tick against the fire-time projection, and it is still multiplied into p before - the single roll. The two silhouette factors join it in the same product. The Cut 7 guard - (`p > 0f && shot.Target != null`, `:410`) extends to cover them: when `PFire × pDeviation` - is 0, Commit builds no silhouette. Multiplication order has no numerical effect. The guard - exists only to skip work. -- **6d's premise ("pOnHull depends only on the aim point, the hull shape and the frozen - Precision, so it folds into PBase at fire time") is retired.** pOnHull now depends on the - target's facing, which is live target state like position, so it is priced at Commit. - R10 is untouched: every *shooter* stat is still frozen at Fire, and the target's facing - was never a shooter stat. -- **Cut 10 still holds:** gated HitProbability allocates nothing. Commit is not on the - hot path. - -### Sub-cut order - -| Sub-cut | Repo | Nature | Depends on | -|---|---|---|---| -| 12.0 | CultLib | CultMath gains `erf` and `erfinv` (a gap filled in its owner) | none | -| 12.1 | Aetheria | subtraction and frame owner; no behaviour change | none | -| 12.2 | Aetheria | where a hit lands: silhouette at Commit, Apply stops reading positions | 12.0, 12.1 | -| 12.3 | Aetheria | how a direct hit travels: exact lane walk, sequential absorption, spread lanes | 12.2 | -| 12.4 | Aetheria | the detonation primitive (areas), fuse data, the Airburst label demoted; Splash deleted | 12.3 | - -The five are kept apart so that Soul falsifies each question on its own: where a shot -lands, what a direct hit does, and what a blast does. In 12.2, `ApplyHit` survives for one -more cut with a changed signature: it takes the schematic bearing and loses its own -rotation, while its march and its even split are left untouched until 12.3. - ---- - -### Cut 12.0. CultMath gains `erf` and `erfinv` - -- **Repo/branch:** `F:\Projects\CultLib`, off current `main`. -- **Adds:** `math.erf(float)` and `math.erfinv(float)` in - `packages/cultmath/src/CultMath/math.cs`, as standard single-precision approximations (for - example Abramowitz-Stegun 7.1.26 or a minimax fit; Giles 2010 for erfinv), with tests in - `packages/cultmath/tests/CultMath.Tests`. Neither is an HLSL intrinsic, so there is no - shader-parity obligation. -- **Release:** run `packages/cultmath/scripts/build-unity-package.ps1`, bump - `unity/org.gamecult.cultmath/package.json` from 0.2.3 to 0.2.4, and tag - `cultmath-unity-v0.2.4`. Aetheria then bumps `Packages/manifest.json:56` and the headless - `CultLibRoot` pin. -- **Verification:** - - `ErfMatchesReferenceValues`: erf at 0, ±0.5, ±1, ±2 and ±3 matches tabulated values - within 2e-6; erf is odd; the limits are ±1. - - `ErfinvInvertsErf`: over y ∈ [-0.999, 0.999]. - - The CultLib mutation run, with every survivor triaged by name. - -**Status: the code closes, the release does not.** Landed on CultLib -`hands/cultmath-erf`: `87b95ae`, `b688cb2` (erf, erfinv, tests), then the fix batch -`fc9ce97`, `4cdc02b`, `6e8362b`, `cc71f17`. 102 tests. - -Two verification lines above were stale when written and are corrected here. CultLib had -no mutation tooling at all, so there was no "CultLib mutation run"; Stryker.NET was adopted -in its own cut (`hands/adopt-stryker`) and its CultMath baseline is 2243 mutants, 350 -survived, triaged in that cut's report. The headless pin needed more than a bump. `Directory.Build.props:5` pins one CultLib revision -for every package, and that revision (`45c2f40`) is exactly Unity's `caching-unity-v1.4.0` / -`cultlib-unity-v1.0.60`; moving it to the release would have put six CultNet commits of newer -CultCache, including a breaking registration change, under the headless tests while Unity -stayed on 1.4.0. So the headless build now pins per package, mirroring the manifest: -`CultMathRoot`/`CultMathRevision` at the 0.2.4 tag's merge (`6d5e2096`), `CultLibRoot`/ -`CultLibRevision` still at `45c2f40` for CultCache. A package's pin moves only when its -manifest tag moves. (Self first repeated Soul's claim that there was no pin, from a read of -lines 3-4; line 5 is the pin, and the build refused the first attempt to use the release.) The -Unity manifest lives at `packages/cultmath/unity/org.gamecult.cultmath/package.json`, not -at the repo root. - -Soul's first pass found the defect this cut would otherwise have shipped: `erfinv(±1)` -returned the **wrong-signed** infinity, so a sample drawn for the far edge of an interval -was clamped onto the near edge. Reachable in ordinary play — `erf` saturates to exactly 1 -at 5.543σ, so any interval edge past that yields `p = 1`. Fixed with two guards; outside -the domain is NaN, now documented and pinned. Soul also measured the reported accuracy -figures 1.5× optimistic (true worst error: erf 6.621e-7, erfinv 5.066e-7, round trip -6.109e-7, all inside the 2e-6 bar), found `erfinv` had no accuracy test at all (the round -trip was blind by 300-400×, proven with a coefficient mutant), and found -`ErfinvIsMonotonicallyIncreasing` asserting a property neither function has (9662 backward -steps in `erfinv` at float granularity; the fixture's 0.001 step hid every one). The second -pass re-derived all 19 new reference values by a different method and closed the cut. - -**Cost, for 12.2 to answer rather than assume:** `erf` is 16.3 ns per call and -allocation-free, but a silhouette costs one `erf` per interval edge — ~4.2 µs per -evaluation on a 128-interval hull, 1.06 µs on 64, and worse under IL2CPP. The AI evaluates -`HitProbability` constantly. 12.2 must measure the new placement against the old kernel per -gated-in evaluation and report both, not assume 1D beats 2D. - -**Released 2026-09-24:** CultLib `20e6da5` (release) and `8c86bc9` (the changelog meta), merged to -main at `6d5e2096`, tagged `cultmath-unity-v0.2.4`. Release Soul pass: the DLL rebuilt byte- -identical from source (sha256 `2470cd48…`), public surface +2 (`erf`, `erfinv`), shipped -`erfinv(±1)` correctly signed; its one finding, `CHANGELOG.md` without a Unity `.meta`, was fixed -before tagging. Aetheria repinned in `2a68b4c0` (manifest and Unity-written lock, hash -`6d5e2096`); batchmode resolve and compile clean, no missing-meta warning. Follow-up for CultLib: -`build-unity-package.ps1:89-90` checks metas only for code and binary files, so it could not see -the missing `.md` meta. - -**Was owed (kept for the record):** Unity -consumes a *precompiled* `CultMath.dll` pinned at `cultmath-unity-v0.2.3` -(`Packages/manifest.json:56`), so no Unity-side fire-control code can call `erf` until the -package is rebuilt, bumped to 0.2.4, tagged and repinned. The headless tests see the -sources directly, which is why nothing failed. 12.2 must not land in Unity before that. - -**Recorded, not done:** `erf`/`erfinv` are scalar-only while every other scalar in -`math.cs` ships float2/3/4 overloads. The consumer is scalar; vector overloads are a -separate decision. - -### Cut 12.1. One frame owner, and the dead outcome flag - -- **Repo/branch:** Aetheria, off the Cut 11 merge. No behaviour change. -- **Deletes first:** - - The two inline frame copies: - - `FireControl.cs:516-520` becomes `target.ToSchematic(toTarget)`, normalized, keeping - the zero-length fallback. The whole of Splash dies in 12.4, but the frame owner lands - first. - - `Entity.cs:394-396` becomes `ToSchematic(hitDirection)`. - - `ShotOutcome.Aimed` and its plumbing (Q12-4 = A): - - the field, `FireControl.cs:702`; - - the `aimed` parameter of `MakeOutcome` (`:470`, `:481`); - - `Commit`'s `aimed` local and flag (`:420`, `:435`); - - the `AimedCells` half of `ResolveAimPoint`'s tuple (`:583-589`), which now returns - `float2`. -- **Adds:** `Entity.ToSchematic(float2)`. -- **Verification:** - - Builds: headless `Aetheria.Shared`, plus a Unity batchmode compile by Self. - - All 244 tests pass unchanged. `SplashDamagesTheSideTheBlastCameFrom` and - `PenetrationMarchIsPlanar` pin the frame through its new owner. - - Negative greps: - - `rg -n "forward\.y, -forward\.x" Assets/Scripts` returns exactly one hit, in - `Entity.cs`. - - `rg -n "Outcome\.Aimed|outcome\.Aimed|o\.Aimed" Assets/Scripts tests` returns - nothing. - - Both were checked against the current tree: the first finds today's two copies, and the - second finds nothing. - -**Status (2026-09-24): landed** at `b851b0d1` (+21/-18 across `Entity.cs` and -`FireControl.cs`). 244 tests pass, both greps as specified (the one frame copy is -`Entity.cs:383`, inside `ToSchematic`), Unity batchmode compile clean, and nothing outside -`FireControl.cs` read `ShotOutcome.Aimed` (the HUD holds outcomes but never touched it). -Stryker on the changed lines: the frame's sign mutant dies; the only survivors near the diff -are `ResolveAimPoint`'s pre-existing centroid arithmetic (`aimedCells.Length > 0` → `>= 0`, -`/` → `*`), which 12.2's `AimingAtAnItemCentresTheScatterOnItsLane` is specified to close. -`--since` does not work in this repo (Stryker's own diff comes back empty), so runs use -`--mutate` on the touched files and filter the report to changed lines. No separate Soul pass: -a no-behaviour cut this size folds into 12.2's. - -### Cut 12.2. Where a hit lands - -- **Repo/branch:** Aetheria, on top of 12.1, with the 12.0 pin in place. -- **Deletes first:** - - `HullKernel` and its comment block (`FireControl.cs:531-578`). - - `WeightedPick` (`:591-611`). - - `POnHull` (`:229-234`). - - Apply's live direction (`:463-465`). - - The bounding half-extent (`:224`). - - The 9.2 SigmaFloor rationale (`:542-548`). Rewrite it as described above. - - The ActionGameManager inline estimate (`:1317-1318`). -- **Keeps:** `Sigma`, `SigmaFloor`, `ResolveAimPoint`, `CellsOf`, the gate order - (`:150-159`) and `Inspect.PBase = HitProbability(...)` (`:204`). -- **Adds:** `TravelDirection`, `Silhouette`, `CommitProbability`, the lateral draw, `Lane` - (only its first element is used in 12.2), and these fields: - - `PendingShot`: `TravelDirection`, `Spread` and `FireRange`; `PBase` is renamed to - `PFire`. - - `ShotOutcome`: `Bearing` and `Lateral`. -- **Per-file changes:** - - `FireControl.cs:161-167`: after the gates, `PFire(...) × PSpread(...) × - Silhouette(...).POnHull`. The bearing comes from `TravelDirection` and the target's - current facing. Nothing new runs before `:159`. - - `:197-202`: Inspect uses the same functions. It gains nothing new beyond - `diagnostic.PFire` if the HUD wants it (optional; presentation). - - `:263`, `:286-308`: Fire freezes `PFire`, `TravelDirection`, `Spread = weapon.Spread` - and `FireRange`. - - `:409-435`: Commit takes steps 1-5 of **Bearing timing** through `CommitProbability`. - - `:463-467`: Apply passes `shot.Outcome.Bearing` (schematic frame) to `ApplyHit`. For - this one cut, ApplyHit's parameter becomes a schematic bearing and the `ToSchematic` - call 12.1 put inside it is removed. - - `ActionGameManager.cs:1317-1318`: the pending line prints - `FireControl.CommitProbability(shot, now, out _)` instead of `shot.PBase * pDeviation`. - - `Combat.cs:117` and `TurretController.cs:84` need no change. They read HitProbability, - which is now a facing-aware forecast. Expect the AI to prefer broadside shots on long - hulls, and targets that turn to lose fewer hits. That is tuning, not a defect. -- **Authority map:** - - Owner: - - `FireControl.Silhouette` owns scatter. - - `FireControl.CommitProbability` owns the commit-time roll price and placement - source. - - `FireControl.TravelDirection` owns the shot's world direction. - - `Entity.ToSchematic` owns the frame. - - Inputs: - - Frozen: `PFire`, `TravelDirection`, `Precision`, `Aimed`, `Spread`, `FireRange`. - - Live at Commit: target position (deviation) and target facing (bearing). - - Outputs: p for the one roll, then `Bearing`, `Lateral` and `Cell` in the immutable - outcome. - - Derived state: - - HitProbability and Inspect are a forecast of the commit price and decide nothing. - - `ShotOutcome.Cell` is display-only, for the HUD (`ActionGameManager.cs:1323`) and - the tests. - - Forbidden writers: - - Nothing else computes a sigma, a projection or a bearing. - - Nothing after Commit reads a target's facing or anyone's position for placement or - damage direction. - - The HUD computes no probability of its own. - - Shared paths: InstantWeapon (`InstantWeapon.cs:248`), beam rolls - (`ConstantWeapon.cs:181`, where zero flight time makes the fire tick the commit tick), - the AI, turrets and the HUD. - - Deletion line: `HullKernel`, `WeightedPick`, `POnHull` and the HUD estimate are gone - before `Silhouette` has a caller. -- **Verification.** Fixtures follow 11.3: the shooter is off the origin, fire times are - nonzero, facings include ones where |fx| > |fy|, and weapon spread is nonzero wherever - PSpread is asserted. - - `BroadsideIsEasierThanHeadOn`: on a 2x12 hull, pOnHull and PSpread head-on are strictly - below broadside, at facings (0,1) and (1,0). It kills the bearing being ignored and the - old `max(W,H)`. - - `TurningArmourIntoTheShotTakesItOnTheArmour` (the ruling's own case): - - Setup: a slow shot (flight time well above `CommitHorizon`) at the soft stern of a - wedge with bow-only plate. The target turns its bow to face the shot's - `TravelDirection` before commit. - - Pass: every hit's `Cell` is on the bow row. - - Kills: freezing the bearing at Fire; reading the facing at Fire. - - `TurningAfterCommitChangesNothing` (R4, and closes L464/L465): - - Setup: through `Fire`. After commit and before arrival, the target turns 90° and the - shooter moves. - - Pass: the damaged cells equal those implied by the committed `Bearing`/`Lateral`. - - Kills: Apply reading live positions or facing. - - `PlacementAndProbabilityShareOneSilhouette` replaces 6d's - `PlacementAndProbabilityShareOneKernel` (`FireControlCut6dTests.cs:318`). - - Setup: a static target at a diagonal bearing, so the intervals overlap and the - forecast equals the commit price. - - Pass: `CommitProbability` equals a pOnHull that the test computes independently, by - numerically integrating the Gaussian over a union of intervals the test builds itself. - The empirical `Lateral` distribution matches the per-interval shares, and the - empirical hit rate matches p. - - Kills: sigma or bearing perturbed at one site; the union merge skipped; ℓ swapped - with b. - - `TheHudEstimateIsTheCommitPrice`: for an in-flight shot, the value the HUD prints and - the p Commit rolls against are the same number at the commit tick. It kills a second - formula. - - `HitsLandOnTheFacingEdge` supersedes `EveryHitLandsOnMetal` (`:357`): on the concave - fixture, each hit's `Cell` is occupied, and nothing occupied precedes it in its own lane. - The test recomputes the lane from `Bearing` and `Lateral`. - - `AimingAtAnItemCentresTheScatterOnItsLane` closes L585/L587 and replaces - `AimingAtTheSternHitsTheStern` (`:213`), whose fixture fires from the target's stern - (`FireControlCut6dTests.cs:163-164`) and would pass for the wrong reason. - - From broadside, hits aimed at the stern land mostly in the stern half, and hits aimed - at the bow land mostly in the bow half. - - From dead astern, hits aimed at the bow land on the stern edge. - - `TheSigmaFloorHoldsAtHalfACell`: pOnHull at Precision 1000 equals pOnHull at Precision 2 - on a one-cell lane. The exact integral no longer collapses, so this test, not the cliff - test, is now what kills "delete the floor". Keep the cliff test and rewrite its - mutation comment. - - `ShieldIsOmnidirectional`: an active shield absorbs the same shot identically from bow, - stern and beam. - - Kept green: - - `GatedOutHitProbabilityAllocatesNothing`. - - `TheHudShowsTheFactorsTheSimulationMultiplies`, re-read as forecast factors. - - `PSpreadIsTheSilhouettesShareOfTheSpreadCone`, which fires from astern at a 5-wide - hull. There the span is 5, equal to the old `max(5,3)`, so its numbers stand. - - `ThinLimbCostsHitChance`, with its comment rewritten in 1D terms. - - Every test that reads `shot.PBase`, updated to `PFire` with its expectation - unchanged. - - Stryker: `--mutate "**/FireControl.cs" --since:<12.1 head>`. Survivors are triaged by - name, and float boundary flips count as equivalent (11.2). -- **Operator:** - - A smoke against a LonginusX-class AI from the bow and then the beam. The HUD `hull` - factor should read higher from the beam. - - Turn your armoured face into a slow missile and watch where it lands. - -**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: -`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` -(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. - -What the Soul passes found, in order, because two of them are lessons for 12.3: -- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, - and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so - every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause - found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; - `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration - could follow a turn made after commit; multi-cell aimed items were untested. -- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. - The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result - into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root - cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, - `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the - two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an - `IComparer` in a delegate per sort). -- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the - slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all - cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and - float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration - found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact - arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. - The `Commit` invariant throw is unreachable. - -Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX -from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside -fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an -item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per -`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. - -**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at -b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant -survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of -their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in -`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make -`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through -visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` -import. - -### Cut 12.3. How a direct hit travels - -- **Repo/branch:** Aetheria, on top of 12.2. -- **Deletes first:** `Entity.DamageSchematic` (`Entity.cs:333-374`) and `Entity.ApplyHit` - (`Entity.cs:376-409`). Going with them: - - the 0.5-step march; - - the `+ .5` cell convention; - - the `> .5f` threshold; - - the Expand spread footprint. -- **Adds:** - - `Entity.Absorb(int2 cell, float damage) → float`, moved verbatim from - `Entity.cs:348-366`. Armour absorbs up to its value, then the item when `d > .1f`, and - the remainder is returned. It emits `ArmorDamage((cell, incoming))` and - `ItemDamage((item, incoming))` with today's meaning, and only for incoming > 0. - - `Entity.DamageHull(float)`, moved from `Entity.cs:369-373` (`> .1f`, one `HullDamage` - event). -- **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches - unchanged: - 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. - 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's - rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and - **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per - cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two - derivations disagreed at cell edges and struck a cell twice). - 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. - 4. Each lane walks from its own facing cell, clipped to the penetration depth, with - `rem = target.Absorb(cell, rem)` at each cell. - 5. Each lane's final remainder goes to the hull, where the lane ends: penetration - exhausted, a gap, or the far side (Q12-3 = A). The remainders are summed and passed to - `DamageHull` once. - 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead - (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. - - **Where 12.3 and 12.4 meet.** Which model a shot uses is decided by the blast radius, not - by its penetration: - - **No blast radius — a dumb AP round.** Steps 1-5 are the whole model, whatever the - penetration. The shot travels its lane and is absorbed cell by cell in order, with the - remainder carrying on until it is spent or the lane ends (Q12-3). Damage in a line. More - penetration spreads the same damage over more cells; it never concentrates it at depth. - Q12-8 says nothing about this case. - - **With a blast radius — an explosive penetrator.** The shot reaches its fuse depth - without spending damage on the way, and then detonates as an area (12.4). Q12-8 governs - only this case, because the payload is the explosion, not the dart. - - The two roles that follow are the design intent: the AP round is the pinpoint component - sniper, a thin line of damage through whatever it crosses, and the explosive penetrator - is the interior wrecker, a disc across a whole section. - - Splash is untouched in 12.3 and deleted in 12.4. Until then it calls `Absorb` per - footprint cell and passes the summed remainder to `DamageHull`, which is arithmetically - identical to today. -- **Authority map:** - - Owner: `Entity.Absorb` owns what one cell does with incoming damage. - `FireControl.Apply` owns the order and the share per lane. - - Inputs: the committed `Bearing`, `Lateral` and `Cell`, plus the frozen `Penetration`, - `DamageSpread` and `Damage`. - - Outputs: `Armor[]`, item `Durability` and `Hull.Durability`, plus the events - `IncomingHit`, `ArmorDamage`, `ItemDamage` and `HullDamage`. Their consumers: - `ActionGameManager.cs:1070`, `EntityInstance.cs:292`, `InventoryPanel.cs:470-482`, - and `Entity.cs:488-494` (ItemDestroyed, HullArmorDepleted, Death). - - Forbidden writers: no weapon hit writes armour or item durability except through - `Absorb`. `Behaviors.CauseDamage` (`Behaviors/Behaviors.cs:62-73`) is self-damage and - out of scope. - - Deletion line: both Entity methods are deleted before `Absorb` gets a second caller. -- **Verification:** - - `DamageIsAbsorbedInOrderAlongTheRay` replaces `HardpointHitDamagesItemThenHull` and - `PenetrationMarchIsPlanar` (`FireAuthorityTests.cs:481-526`, which call the deleted - `ApplyHit`). - - Setup: through `Fire` at facing (1,0). The lane runs through a 10-armour front cell, - then a marker, then a soft cell, with damage 30 and penetration 3. - - Pass: armour −10, marker −20, soft cell untouched, hull −0. Today it would be - 10/10/10 **(probe)**. - - Kills: an even split; items before armour; no carry-forward. - - `ArmourFacesTheShot`: - - Setup: a wedge with bow-only plate, penetration 0, many shots. Facings (0,1), (.8,.6) - and one where |fx| > |fy|. - - Pass: from ahead only bow cells lose armour, and from astern the reverse. - - `BuriedCockpitNeedsPenetration`: a cockpit enclosed by one ring of cells. - - At penetration 0 it is never damaged from four bearings. - - At a penetration that clears the ring, it takes exactly `damage/lanes` minus what the - ring absorbed. - - Enough damage fires `Death` with `CockpitDestroyed`. - - `SpreadWidensTheImpactAcrossLanes`: spread 1 at penetration 0 puts damage/3 on each of - three facing-edge cells and nothing behind them. - - `ARayThroughACornerCrossesTheCornerCell`: the diagonal case from `Probe1` reaches (2,2). - - `LaneRemainderReachesTheHull`: a ray that exits a thin hull delivers its remainder to - `Hull.Durability` (Q12-3 = A). - - Kept green: every Splash test, and `AirburstSplashes…` (Cut 6). - - Negative: `rg -n "DamageSchematic|ApplyHit\(" Assets/Scripts tests` returns nothing. - Today it matches `Entity.cs`, `FireControl.cs`, `FireAuthorityTests.cs`, and two comment - lines at `Gameplay/EntityInstance.cs:280-281`. Rewrite those comments and - `FireControl.cs:492`. - - Stryker: `--since:<12.2 head>` over `FireControl.cs` and `Entity.cs`. -- **Operator:** a smoke with a GT 3K or pswarm launcher into a LonginusX bow, then from a - flank. The schematic display should pulse the facing edge only. - -**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: -- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. -- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. -- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. -- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. -- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. -- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. - -297 tests. Unity batchmode is clean at `cccf3a2a`. - -Proportional absorption, passes 4 and 5: -- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` - was already ruled out by the operator. Soul found three more defects: - - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. - - The pooled resolve skipped the item's `.1f` threshold. - - The model sweep still passed with proportionality deleted. -- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. - `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is - Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. -- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled - bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. - Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and - stale comments. All are fixed, and each mutant dies to a committed test. - -Recorded, not fixed: -- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, - which only a private seam offers. The operator rule forbids reflection, and no test seam is added - for them. - - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. - - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). - - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). -- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. - -What the three Soul passes found: -- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of - 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an - angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. - It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on - (Q12-2 amendment: no doubling up, the line thickens). -- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits - struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first - structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the - centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit - with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float - drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two - derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, - the `ceil` empty centre); the cure each time was one owner. -- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two - lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by - construction. Residual float effects (a side lane starting one cell deeper, a column falling - between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage - is never lost. - -**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike -exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% -of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing -ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since -it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. - -**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the -first-processed lane drains its durability and the second carries the leftover into its own deeper -cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on -a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs -from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. -Alternative: keep the processing order and pin it. - -**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell -item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane -carries on with a leftover in proportion to what it brought. The result must not depend on the order -lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. - -**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an -exception, this should be one code path." The first implementation (`56757913`) surveyed each shot -for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split -authority, and the second reachability derivation it needed is the same failure as the 12.2 empty -lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of -the lanes that reach it, a pool of one lane included. The survey and the sequential path die. - -**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. - -### Cut 12.4. The detonation primitive - -Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path -rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. -Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see -Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. - -- **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: - - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no - weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has - not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). - - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. - -#### Deletes first - -In (a): - -- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), - `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. - `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. -- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The - initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a - proximity fuse. -- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. - -In (b): - -- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. -- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. - `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). -- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell - separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. - **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` - the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. - 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. -- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended - answer. It is not deleted: it is gated on `shot.Fuse == null`. -- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one - reach function (see **The penetrator**). -- Stale comments that name a deleted owner as live: - - `Entity.cs:391` and `:417`; - - `FireControl.cs:1201`; - - `Gameplay/EntityInstance.cs:280-283`; - - `Gameplay/HullCollider.cs:4-6`; - - `Gameplay/Weapons/Projectile.cs:8-9`; - - `Gameplay/Weapons/Mine.cs:90-95`. - -#### Data - -The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). - -- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name - `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache - opens the catalog as compatible drift (see 0b). -- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, - Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: - null means no blast, and every shipped record reads as null. -- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when - `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing - re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius - without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is - not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on - `BlastRadius` alone (Soul, `2625768f`). -- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That - write is content and changes no data. It belongs to F12-2. -- **Readers to update:** - - `FireControl.cs:386-388`. - - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` - parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, - because Q12-6 ruled that nothing checks labels against behaviour. - -#### Which model a shot uses - -`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: - -- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. -- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which - `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, - `AirburstSplashesEvenOnAGuaranteedMiss`. -- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P - is**). On a miss nothing happens. - -Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse -into Apply's switch, so `Step` has no damage branch left. - -`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and -only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one -consumer is the player's hit marker (`ActionGameManager.cs:1070`). - -This is a default, not a ruling. - -#### Where P is - -The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as -`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). - -**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: - -- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 - from its centre; -- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. - -So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. - -- **Proximity:** `BurstPosition.xz`, a world point. -- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and - `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. -- **Delayed:** the schematic point `point(t_fuse)`, with - `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that - 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, - whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last - reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. -- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already - proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not - read. The disc's radius is its width. This is a default, not a ruling. -- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the - host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like - every other entity in the radius: its cells are found again by `ToSchematicPoint`. - - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for - it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. - - Bystanders are judged against where the host actually is at arrival. That is the same live rule a - proximity burst already applies to everyone. - - There is exactly one input shape, a world point. There is no host-only schematic entry. - -#### The penetrator (Q12-8) - -A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` -the shot's whole `Damage`**. - -That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the -explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. - -The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at -`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → -int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while -`entry - impact.entry < penetration`. - -Two callers read it: - -- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; -- the delayed fuse point, which calls it on the centre lane. - -No second "lane damage with a blast" path exists, because no shot does both: - -- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; -- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. - -A shot takes one branch of Apply's switch or the other, never both. - -The mutant this must kill is a second reach derivation: for example, a delayed P computed as -`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. - -#### Area, per entity: `FireControl.Detonate` - -The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, -public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). - -- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes - `position.ToCultMath().xz`). -- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / - SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the - frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and - `SchematicCellSize` is `Settings.cs:255`. - -For each entity in the zone: - -1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. - Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a - long hull whose end lies inside the blast. -2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For - every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × - overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a - cell being the unit square centred on its integer coordinate. - - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing - computes an overlap twice. - - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. -3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, - under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact - or delayed blast, under Q12-9's recommended answer. - - If the shield absorbs, no cell of that entity takes anything. - - If it cannot, it breaks, and the cells take their shares. -4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects - its own cell's share and nothing else. -5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in - hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has - deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join - the hull total. -6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). - -Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the -other (F12-8). - -**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, -and no operator question is needed: - -- "Armor absorbs first", from the first round of rulings; -- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; -- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where - incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after - `ArmorAbsorb`; -- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." - -The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: -an item's cells deposit into one pool, and they do not absorb independently. - -**One owner, and why there is no walk.** - -- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on - the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. -- `ArmorAbsorb` is the one owner of what armour does. -- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, - `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the - item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no - cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the - reachability test and the cycle rule have nothing to decide. - -The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull -with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell -with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 -discs. Its hull total differed only by float summation order, at most 3.8e-6. - -`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a -starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. - -**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same -per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only -order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. - -**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the -disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` -between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it -conserves exactly up to float rounding. - -- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at - radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double - precision is not needed. -- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. -- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc - smaller than a cell, or for one straddling a cell boundary. - -**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than -128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not -the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. - -**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an -area is occluded. A bulkhead protects its own cell's share and no other. Falloff toward the rim is F12-7, not -this cut. - -**HitProbability and Inspect are unchanged.** A contact or delayed blast still needs the shot to hit, so the -forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. -That is pre-existing, and it is F12-5. - -#### Authority map - -**Owners:** - -- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. -- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the - per-entity armour→pool→hull pass. -- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with - 12.3's walk. -- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. -- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. -- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. - -**Inputs:** - -- P, the radius, the damage and the type; -- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; -- the fuse and the radius, frozen at Fire; -- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. - -**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, -`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. - -**Derived state:** - -- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, - `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. -- `BurstPosition` is read only for a proximity fuse. -- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. - Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` - finds only `FireControl.cs`). - -**Forbidden writers:** - -- No label decides behaviour. -- Nothing but `Detonate` applies area damage. -- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. -- Nothing but `Reach` decides how deep a lane reaches. -- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). -- `Step` has no damage branch. -- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they - decide no damage themselves. - -**Shared paths:** - -- the proximity burst: Apply at arrival, with no roll; -- the contact and delayed blasts: Apply on a committed hit; -- `Mine.Explode`; -- the tests, which call `Detonate` directly (see **Test seam**). - -**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a -caller. - -#### Test seam - -`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen -intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production -interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from -`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is -committable with no seam and no reflection. - -It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at -an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes -a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and -exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: - -- angled direct-hit correctness is covered by the model sweep and - `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; -- P1 and P2 stay uncommitted. - -#### Verification - -These fixture rules apply to every blast test that touches the frame: - -- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a - deleted `/ SchematicCellSize` survives. -- The target is off the origin. -- The facings include one with |fx| > |fy|. -- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. - -Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 -absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. - -**Geometry.** - -- `TheDiscIsConservedOverTheGrid`. - - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius - under one cell, one centred on a cell corner, and one off the grid. - - Pass: the total equals `damage`. - - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box - that clips the rim cells; a dropped chord breakpoint. -- `EachCellTakesItsShareOfTheDisc`. - - Setup: a solid hull with no armour and no items. - - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. - - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the - cell's centre. -- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). - - Setup: a blast straddling the hull's edge. - - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The - shares sum to the blast's damage minus what falls off the hull. - - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. -- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. - - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. - - Pass: the near end takes damage. - - Kills: Splash's centre-distance cull. - -**Absorption order.** - -- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` - (`FireControlCut123Tests.cs:750`). - - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage - levels of the old test: below the armour, between the armour and armour plus durability, and above both. - - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. - - Kills: the item absorbing before the armour, and armour being skipped. -- `ArmourProtectsOnlyItsOwnCellsShare`. - - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. - - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. - - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat - cell B's share. -- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. - - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but - more than `.1` on the two together (the `ProbeC2` geometry). - - Pass: the item absorbs the sum. - - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or - a pool that resolves before every covered cell has deposited. - - This test is also what pins that the result does not depend on which cell is considered first. As shown - above, the threshold is the only order-sensitive rule a blast has left. -- `ItemDamageFiresOncePerCoveredCell`. - - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive - post-armour deposit, and each event carries that cell's deposit. - - Kills: one event per item, and events for zero-deposit cells. - -**Symmetry and frame.** - -- `MirroredBlastsDoMirroredDamage`. - - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of - world points mirrored across the target's bow axis, with the mirror computed by the test from the facing - and not through `ToSchematic`. - - Pass: per-cell armour, item durability and hull damage mirror. - - Kills: - - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); - - a missing or wrong `CenterOfMass` anchor; - - any scheduling rule that depends on enumeration order. - - It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test - does. -- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` - (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). - - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's - width. - - Pass: port cells are damaged, and starboard cells are untouched. - - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. - -**Shields** (under Q12-9 = A). - -- `ShieldPaysForWhatReachesIt`. - - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. - - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` - (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the - covered share, never loosened. - - Kills: charging the full damage. -- `ABlastShotsShieldIsDecidedAtDetonation`. - - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at - arrival. - - Pass: the shield is charged the covered share. - - Second setup: a shield that could absorb the whole shot at commit. - - Pass: the shot still detonates, and the shield pays its covered share. - - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the - host. - -**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). - -- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit - behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. - - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour - of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. - - A contact fuse with the same stats leaves the cockpit untouched. - - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in - order, and the cockpit takes only what the ring left (12.3). Damage in a line. - - Kills: - - absorbing along the burrow; - - a contact fuse reading penetration; - - routing every penetrating shot through the fuse path; - - a fuse without a radius detonating. -- `TheFusePointStaysOnMetal`. - - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap - behind the first run. - - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the - gap's near edge). - - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. -- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. - - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. - - Pass: the host's per-cell damage equals that of the same shot with no turn. - - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. -- `LabelsDoNotDecideBehaviour`. - - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with - `Fuse = Proximity` and no label detonates. -- `AFuseWithoutARadiusIsADirectHit`. - - Pass: `Fuse = Contact` with a null or zero radius does lane damage. - - Kills: Fire freezing `Fuse` without the radius check. -- Re-fixtured to `Fuse`, with their behaviour unchanged: - - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes - `ProximityDetonatesEvenOnAGuaranteedMiss`; - - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); - - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; - - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes - `ABlastHitsEveryEntityItCovers`. -- `AContactBlastReportsTheHit`. - - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. - - Kills: `IncomingHit` moved inside the direct-hit branch. - -**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: - -- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes - `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with - a remainder of 0. -- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool - of `{ .1f }`. - -**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the -clip rule are otherwise untouched. - -**Negative greps:** - -- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` - returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and - `FireControlCut4/5/6/11/123Tests.cs`. - - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are - unrelated. - - Exclude `StrykerOutput`, `bin` and `obj`. -- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the - stale-comment list above. - -**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. -`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). - -#### Operator - -- Author one proximity weapon and one delayed-fuse weapon in a scratch catalog. -- Watch an airburst beside a LonginusX and a penetrator into its nose, and check that the damage lands where - the model shows the blast. That verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of - the frame that only a running blast can show. - -#### History (superseded 2026-09-25, kept for the record) - -The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: - -- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** - Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour - stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. -- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with - `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. -- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** - Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then - `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit - had judged unshielded: two authorities over one shield. -- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and - `Step` has no damage branch. -- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one - `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. -- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was - `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. - -### 0b. Identity, lifecycle, authority - -Two persisted changes, both on `aetheria.weaponitemdata` v1 and both in 12.4: - -| Field | Slot | Change | Lifecycle | Reader risk | -|---|---|---|---|---| -| `BlastRadius` (was `AirburstRange`) | 29 | rename, same `float?` type | authored catalog | compatible drift: `CompareSchemaShapes` ignores member names (`CultCache.cs:672-752`, CultLib `45c2f40`); drift warnings until the next catalog write | -| `Fuse` | 32 | new, `WeaponFuse?` | authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule | - -Runtime-only and never serialised (0b table, `FireControl.cs:626-629`, `:686-689`): - -- `PendingShot`: `PFire`, `TravelDirection`, `Spread`, `FireRange`, `Fuse`, `BlastRadius`. -- `ShotOutcome`: `Bearing`, `Lateral`. - -Nothing in saves changes shape. - -### Operator questions - -Ruled 2026-09-22 (the words are recorded under **Rulings**): - -- **Q12-1: B**, the bearing is taken at Commit. -- **Q12-2: A**, spread is width. -- **Q12-3: A**, the lane remainder goes into the hull where the lane ends (direct hits). -- **Q12-4: A**, `ShotOutcome.Aimed` is deleted. -- **Q12-6: no.** Operator: "12-6, nope". No catalog test ties a label to behaviour. - `WeaponModifiers` stays authored metadata that nothing checks and nothing reads in the - simulation, and a weapon whose label disagrees with its data is authoring's business. -- **Q12-7: dissolved, not answered.** It asked where a blast ray's remainder goes. With - blasts as areas there are no rays and no remainder to route, so the question has no - subject. The ray model's shadowing goes with it (see **Accepted loss** in 12.4). -- **Q12-8: A, and it is scoped to weapons that carry a blast.** Nothing is absorbed along - an *explosive penetrator's* burrow: it reaches its fuse depth without spending damage, - and the explosion is the payload. Penetration is a reach decision there — how deep the - fuse point can sit — not a damage budget. - - The first wording ("the burrow is travel, not damage") read as if it covered every - penetrating shot. It does not. Operator, 2026-09-22: "Q12-8 is odd. Like, penetration is - still a thing even if it's not a warhead. For a dumb AP round, you'd expect it to do - damage in a line, not dump all its damage at max depth, that would make AP rounds even - better at component sniping than AP explosives". Agreed, and the map never meant - otherwise: a weapon with no blast radius takes 12.3's lane, absorbed cell by cell in order - with the remainder carrying on, and Q12-8 does not reach it. See **Where 12.3 and 12.4 - meet**. - - So "armour absorbs first" holds everywhere it was ruled: down the lane for a direct hit, - and per covered cell for a blast. Against an explosive penetrator, armour protects by the - reach gate and by absorbing its own cell's share of the disc. The consequence to watch - when F12-2 tunes penetration is that an explosive penetrator with reach pays nothing for - the plate it crosses, while an AP round pays for every plate it passes. - -Open: - -**Q12-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. - -**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a -blast that needs a hit?** - -The current map lets two places decide it: - -- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. -- `Detonate` then decides every shield in the radius live, and that includes the host. - -**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so -the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can -absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split -this campaign has cut three times. - -- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield - for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the - radius, live at detonation. - - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no - direction", applied to shields. - - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that - reaches the hull. For a contact detonation on the hull's face, that is roughly half. - - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads - them. -- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole - and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" - parameter: a compensator that keeps two owners alive, one per entity. - -What depends on it: - -- the gate on Commit's shield block; -- whether `Detonate` takes a host parameter; -- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. - -Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half -of (b) can proceed while this waits. - -**Defaults taken, not rulings.** The operator may overrule any of these: - -- A blast shot ignores `DamageSpread`, and P sits on the centre lane. -- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. -- The delayed fuse point is clamped to the last reached cell's exit. -- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. - -- **Q12-5 (not ruled). Should the HUD show which target items are exposed from the current - bearing?** - - **Recommended:** a follow-up after 12.3. It would be a presentation read of `Silhouette` - and `Lane`, the way Inspect is: for each revealed item, is it first in some lane of the - current shadow. It belongs on the target-item cycling UI. - -### Follow-ups recorded, not in this cut - -- **F12-2. Penetration retune** (operator: "Retuning penetration is a follow up."). - - Shipped values are ≤ 0.25 against an inspector range of 0..1, and enclosed cockpits - need about 2 cells. - - The same pass decides which weapons get fuses and blast radii (none have one today), - and writes the catalog once to clear the slot-29/30 drift warnings. - - Until it lands, interior cells are reached only by blasts. -- **F12-3. SigmaFloor's purpose changed.** It was a numerical guard and is now a design - minimum on group tightness. Revisit it when Precision is next tuned. -- **F12-4. Double charging.** 6d's note that pSpread and pOnHull both price target size - still stands. Both now read one Span and shadow, so a later merge is mechanical. -- **F12-5. AI and proximity blasts.** `Combat.cs:117` gates a proximity weapon on a - direct-hit forecast that does not decide its damage. This is pre-existing. -- **F12-6. Mine's blast inputs.** The prefab `BlastRange` and the arming OverlapSphere - (`Mine.cs:69-78`, `:99`) are presentation-side (Cut 4 Q7). Mine has no catalog weapon - record carrying `Fuse`/`BlastRadius`. It can move when it ships (R9). -- **F12-7. Blast falloff toward the rim.** Every cell takes its area share flat, so a cell - at the rim is hurt as hard per unit of area as the one under the fuse. A radial falloff is - a tuning decision for the same pass that authors radii, and it changes only the weight - inside the integral, not the owner. -- **F12-8. Blasts do not shadow.** Nothing inside an area is occluded, so a bulkhead - protects only its own cell, and two ships caught by one blast do not shade each other. - This is the accepted cost of areas over rays. Reopen it only if play shows interiors are - too soft. -- **Behaviors.CauseDamage** (`Behaviors/Behaviors.cs:62-73`) writes durability and - `HullDamage` without `Absorb`. It is self-damage, not a hit, and is left alone. -- **Orphaned `Airburst*` YAML** in nine weapon prefabs. It is harmless, and Unity drops it - on the next save of each prefab. - -### Subtraction ledger (estimates, with actuals) - -| Sub-cut | Removed | Added | Notes | -|---|---|---|---| -| 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | -| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | -| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | -| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | -| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | -| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | - -Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: - -- 12.1: `b851b0d1^..b851b0d1`; -- 12.2: `b851b0d1..c7b6e80c`; -- 12.3: `c7b6e80c..d402283c`. - -The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: - -- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; -- 12.3: sequential, proportional, order-free absorption; -- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, - `Absorb` and `Step`'s branch. - -12.4's own delta is ~+60, and that is the estimate to hold it to. - -**Section history:** Cut 6d's "The rule" (the 2D kernel) and its premise that pOnHull folds -into PBase at Fire, 9.2's SigmaFloor rationale, and Cut 4's Splash rule (`FireControl.cs:946-959`) -describe models this cut replaces. Self should mark them as history in the status header -as 12.2 and 12.4 land. +# Fire Control: Cut Map + +Date: 2026-09-19 + +Status: Imagination pass, cut map. Nothing here has landed. Ends are owned by +`docs/three-gates-scope.md` ("Fire control") and +`docs/shield-presentation-contract.md` ("The commit window"); this document owns +the means. + +Anchors are against `codex/item-provenance` HEAD `8a00fc92`, read-only. Nothing was +built in the main tree and Unity was not run. Claims marked **(probe)** were measured +by running `tools/AetherDb` (`census`, `hardpoint-fit`) against the live catalog +`GameData/Aetheria.cc` from the free worktree `F:\Projects\Aetheria-stats`, CultLib +pinned at the shared `45c2f40` worktree. Claims marked **(read)** come from reading +source at the cited anchor. + +**This map supersedes Cut 2 of `docs/headless-playground-cut.md`** +(`:490-810`, anchored to `562cdcf2`). That draft is now history for three reasons: +its anchors predate the stats-and-power campaign, which moved most of the code it +cites; it predates the commit-window ruling of 2026-09-18, which changes the shape of +a shot from an instant to a lifecycle; and it predates the per-hardpoint arc override +and 360-degree turret rulings. Its substrate probes are still good and are carried +forward below with citations. Where the two disagree, this document wins; nobody +should be reading two live designs. + +**Scope: fire control only.** What a presentation does with a committed outcome is the +capability/presentation seam's business (`docs/shield-presentation-contract.md`). +Faction territory is the campaign after this. §6 names every seam and stops there. + +--- + +## Rulings this map is built on (do not re-litigate) + +From `docs/three-gates-scope.md:47-99` and `docs/shield-presentation-contract.md:34-57`: + +- **R1. Hit detection goes.** Operator, 2026-09-17: "We wanted to get rid of hit + detection anyway... Just roll the dice based on weapon stats, targeting system stats + (new subsystem) and sensor state." +- **R2. Targeting systems are a new subsystem**, interior `Tool` gear. +- **R3. A shot resolves on arrival**, hit chance reduced by how far the target deviated + from the predicted intercept. Manoeuvring is evasion. +- **R4. The outcome commits a short fixed time before impact.** Operator, 2026-09-18: + "That hybrid is the way." Deviation feeds the roll up to that horizon; after it the + result stands and the rest of the flight is choreography. The horizon is one authored + fire-control setting. A commit is authoritative; presentations perform it and never + predict. +- **R5. Subsystems reveal between `TargetArmorInfoThreshold` and + `TargetGearInfoThreshold`**, and both the player and the AI may aim at a revealed + subsystem. +- **R6. Per-hardpoint firing arcs.** Mount direction from the item's `ItemRotation`, as + reaction thrusters do; default 120 degrees from `GameplaySettings`; an optional + per-hardpoint override; turrets 360. +- **R7. The simulation is 2D.** The 3D is set dressing. +- **R8. Unity weapon effects and `HullCollider` become presentation only.** Their + `Physics.*` queries stop deciding damage. +- **R9. Every weapon type in the catalog's history stays** + (`docs/headless-playground-cut.md` fork E). Nothing is deleted for being unused. + +From `docs/stats-and-power-cut.md:996-1030`, the seam the stats campaign left: + +- **R10. Fire control must snapshot what it rolled over.** The resolver hands out a + current value; it does not promise that value is still current a second later. +- **R11. A starved weapon does not fire** (Cut 4 of that campaign: no fraction of a + shot). A degraded shot instead is a ruling against that map, not a thing to work + around here. See Q6. + +--- + +## 0. Substrate + +### 0.1 What decides a hit today, end to end + +Nothing in `ServerShared` does. The whole chain is Unity **(read)**: + +1. `InstantWeapon.Execute` (`Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs:216-237`) + walks the burst and raises `OnFire` (`:228`), a parameterless `event Action` declared + at `:73`. It carries no outcome and nothing in `ServerShared` subscribes. +2. `Assets/Scripts/Gameplay/EntityInstance.cs:203-204` subscribes and calls + `InstantWeaponEffectManager.Fire(weapon, item, source, target)` + (`Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs:7`). The constant + family goes the same way at `:232-235`. +3. The manager spawns an effect and copies the weapon's resolved numbers onto it. + `ProjectileManager.cs:18-25` jitters the barrel forward vector by + `Random.Range(±Spread/2)` on all three Euler axes — that is the entire aiming model. +4. The effect raycasts. `Projectile.cs:58`, `GuidedProjectile.cs:153`, `Laser.cs:40`, + `Lightning.cs:34`, `ConstantLaser.cs:74`, `ConstantLightning.cs:54`, + `HitscanEffect.cs:31`, `Mine.cs:68,92`, plus `Projectile.cs:114` for airburst. +5. On a hull collider it calls `HullCollider.SendHit` + (`Assets/Scripts/Gameplay/HullCollider.cs:18-30`), or `SendSplash` (`:32-41`). +6. `EntityInstance.cs:316-396` subscribes to those subjects, converts a UV texture + coordinate into schematic cells, expands by `DamageSpread`, marches a penetration ray, + and calls the local function `DamageSchematic` (`:277-314`), which is the only writer + of `Armor`, item `Durability` and `Hull.Durability` from combat. +7. Death: `EntityInstance.cs:405-439` subscribes to `HullDamage`, rolls loot with + `UnityEngine.Random`, and calls `entity.Zone.Entities.Remove(entity)` (`:437`). + +**Authority versus presentation in that chain.** Everything from step 3 down is +authority wearing a presentation costume. The shield branch repeated in all seven +effects (`Projectile.cs:60-80` and its copies) is the absorb rule, duplicated seven +times. `DamageSchematic` is the damage rule. `EntityInstance.cs:437` is the death rule. +Genuine presentation is narrow: trail rendering, line renderers, the lightning compute +animation, hit effect prefabs, `ShieldManager.ShowHit`, and the destroy effect. + +**The 2D/3D violation.** `EntityInstance.cs:323` and `:380` call +`transform.InverseTransformDirection` to get a hit direction into the ship's frame. That +is a 3D rotation standing in for the planar rotation R7 says the simulation is made of. + +### 0.2 What already exists of targeting + +- `Entity.Target` — `Assets/Scripts/ServerShared/Entity.cs:46`, a + `ReactiveProperty`. Cleared when the target leaves `VisibleEnemies` (`:196`, + `:222`). `TargetRange` is recomputed each tick at `:920`. +- `Entity.EntityInfoGathered` — `Entity.cs:57`, a `ReactiveDictionary` in + [0,1], accumulated with decay by `Sensor.Execute` + (`Assets/Scripts/ServerShared/Behaviors/Sensor.cs:152-184`). +- `TargetDetectionInfoThreshold` gates `VisibleEnemies` at `Entity.cs:232-246`. +- **`TargetArmorInfoThreshold` and `TargetGearInfoThreshold` are authored and read by no + code.** They are declared at `Assets/Scripts/ServerShared/Settings.cs:212-213` and + appear nowhere else in `Assets/Scripts` **(read)**. R5's reveal tiers are unwired + settings. +- `LockWeapon` — `Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs`. Integrates a + 0..1 `_lock` scaled by `pow(EntityInfoGathered[target], SensorImpact)` (`:92`), gated by + a `LockAngle` cone off `Entity.LookDirection` (`:88-89`). `CanFire` (`:56`) requires + `_lock > .99f` and range within `[MinRange, Range]`. This is the closest thing in the + codebase to a to-hit probability, and it is per-weapon rather than per-ship. +- `TurretController` — `Assets/Scripts/ServerShared/Behaviors/TurretController.cs`. + `TurretControllerData` has no fields (`:15-25`). It writes `Entity.LookDirection` to a + first-order intercept (`:67-72`) and fires each weapon when + `dot(x.Direction, Entity.LookDirection) > .99f` (`:81-83`). +- `Sensor` — sensitivity, ping boost/energy/visibility/range/cooldown, all + `PerformanceStat` (`Sensor.cs:16-34`). +- Player target selection: `Assets/Scripts/Gameplay/ActionGameManager.cs:368-403` + (reticle, nearest, next, previous). Player fire is not arc-gated at all. +- **No targeting-system item, behavior or stat exists anywhere in code or catalog.** A + repo-wide grep for `Targeting|FireControl|Accuracy|Aim` finds one `#region Targeting` + label at `ActionGameManager.cs:368` and nothing else; the same grep over the catalog + returns zero **(probe)**. + +### 0.3 Where hardpoint rotation and arcs live + +- `HardpointData` — `Assets/Scripts/ServerShared/ItemData.cs:538-545`: `Type`, + `Position`, `Shape`, `Transform` (a prefab node name), `Rotation` (`ItemRotation`), + `Armor`. **There is no arc field on `HardpointData`, `WeaponData`, or any effect.** + A per-hardpoint override attaches here, as catalog data already authored per hull. +- `ItemRotation` is four-way (`Assets/Scripts/ServerShared/Enums.cs:7`); + `Extensions.cs:135-166` gives `None` = forward, `Reversed` = aft, `Clockwise` = right, + `CounterClockwise` = left. `Agent.cs:72` reads `Clockwise` as right and `Thruster` uses + the same convention for mount direction, which is the precedent R6 names. +- `Behavior.Direction` (`Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:24-44`) has + two paths. The first (`:30-34`) reads `Entity.HardpointTransforms`; the second + (`:37-38`) is the item-rotation fallback R6 wants. +- **`Entity.HardpointTransforms` (`Entity.cs:58-59`) is written only by Unity**, at + `EntityInstance.cs:514-517`, from the first `WeaponHardpoint.FiringPoint` barrel's + world forward. Headless it is empty. That single dictionary is why + `Combat.cs:100-102` throws `KeyNotFoundException` on the first combat tick outside + Unity, and why the simulation's own notion of where a gun points is a readback from a + renderer. +- Barrel swing is `ArticulationPoint`, yawing and pitching toward `LookAtPoint` + (`EntityInstance.cs:400-403`, `:519-520`). Authored limits on the Longinus energy + barrels are yaw [-1, 1] — effectively fixed forward — while the Turret prefab's yaw + pivot is [-720, 720] (`docs/headless-playground-cut.md:533-545`, prefab probe, not + re-measured here). That is presentation and stays. +- `AgentFiringMinDot` (`Settings.cs:218`) is **declared and read by nothing** **(read)**. + +### 0.4 What the catalog ships in weapons + +**(probe)**, `tools/AetherDb census` and `hardpoint-fit` over `GameData/Aetheria.cc`: +51 designs, 37 products, of which 18 are weapons. + +| Hardpoint kind | Designs | Sellers | +|---|---|---| +| Ballistic | 6 | Zhestokost 2, Lightsail 1, AU 1, DME 1, unsold 1 | +| Energy | 7 | NiteLife 3, Lucent 1, Adrasteia 1, Alakrita 1, unsold 1 | +| Launcher | 5 | DME 1, R&D 1, unsold 3 | + +Unsold, therefore unspawnable: `Autocannon`, `SRMM72`, `LRMM72`, `plight`, `pswarm`. +The earlier behaviour-class breakdown (8 `ProjectileManager`, 3 `LaserManager` at +velocity 0, 5 `GuidedProjectileManager`, 1 `LightningGunManager`, and **no design using +`ConstantWeaponData`, `HitscanEffect`, `Mine`, airburst or MIRV**) is carried from +`docs/headless-playground-cut.md:500-506` and was not re-measured; the census agent +could not reproduce it because `census` buckets by `HardpointType` and the one command +that prints behaviour fields was blocked. **Cut 4's safety argument depends on that +claim, so Hands re-measures it before Cut 4** (§4, First). + +**Stats a roll can read today** (`Weapon.cs:22-68`, all `PerformanceStat` unless noted): +`Damage`, `Penetration`, `DamageSpread`, `MinRange`, `Range`, `Energy`, `Heat`, +`Visibility`, `Spread` (an angular cone in degrees), `Velocity`; plus `DamageCurve` +(a `BezierCurve`, damage against normalized range), `MagazineSize`, `ReloadTime`, +`AmmoType`. `InstantWeaponData` adds `Count`, `BurstTime`, `Cooldown` +(`InstantWeapon.cs:13-22`). `LockWeaponData` adds `LockSpeed`, `SensorImpact`, +`LockAngle`, `DirectionImpact`, `Decay` (`LockWeapon.cs:13-25`). The item carries +`WeaponRange`, `WeaponCaliber`, `WeaponType`, `WeaponFireTypes`, `WeaponModifiers` +(`ItemData.cs:476-492`). + +Note against a misreading: **`DamageSpread` is not damage variance.** It is the +footprint, in schematic cells: `EntityInstance.cs:372-375` expands the hit shape +`round(hit.Spread)` times, where `hit.Spread` is the weapon's `DamageSpread`. + +**What is missing from the authored data for a roll to be meaningful:** + +- **No accuracy stat on anything.** No weapon, hull, cockpit or sensor contributes a + to-hit number. This is what R2's targeting system exists to supply, and it is the + whole reason the subsystem is required content rather than a nice-to-have. +- **No target-size term.** A roll that ignores how big the target is will feel wrong at + every range. The hull `Shape` and a cell size give an angular radius; `SchematicCellSize` + does not exist and must be authored (Q4). +- **No tracking or angular-velocity stat**, so nothing yet reads R3's deviation. The + targeting system supplies it. +- **Beams have nothing beam-specific.** `ConstantWeaponData` adds only `AmmoInterval` + (`ConstantWeapon.cs:15`); `Spread` is inherited and unread by any beam code, and + `Velocity` is meaningless for one. There is no dwell or wander stat. Unused today, so + Cut 4 gives beams the plainest possible rule and authors nothing new. +- **`ChargedWeaponData.ChargeFiringSpreadMultiplier` is a plain `float`** + (`ChargedWeapon.cs:34`), not a `PerformanceStat`, so unlike every other combat number + it cannot vary with quality or wear. Noted, not fixed here. +- **Lock is binary at 0.99** (`LockWeapon.cs:56`). There is no stat turning partial lock + into hit chance, and `GuidedWeaponData` (`Launcher.cs:37-55`) has no lock stats at all. + +### 0.5 Can the headless sim step a full engagement today + +No, and two specific things stop it **(read)**: + +1. `Combat.cs:100-102` indexes `HardpointTransforms`, which only Unity writes (§0.3). + `Minion` enters `CombatState` as soon as `VisibleEnemies` is non-empty, so any NPC + that sees an enemy throws on the tick it sees them. +2. There is no owner between `InstantWeapon.OnFire` and `Entity.HullDamage`. The only + `ServerShared` writers of durability are the repair consumable + (`Behaviors.cs:67,72-73`). + +Everything else is already there and already exercised. +`tests/Aetheria.Shared.Tests/IffAndCombatTests.cs:52` (`BuildWorld`) opens the real +catalog, builds an `ItemManager` and a `Zone`, and `:73` (`NewShip`) equips a gun on a +hardpoint and activates the ship — two ships, positions, look directions, sensor info, +targets and IFF, headless. `:241` and `:255` assert on `weapon.Progress` after +`Activate()`, because a cooldown starting is the only observable consequence of firing +that exists. That fixture is this campaign's harness; it does not need to be built. + +`Zone.Update` (`Zone.cs:126-151`) steps orbits, belts, agents, then entities. +`ItemManager.Random` is still clock-seeded (`ItemManager.cs:19`), so a seeded-roll test +needs a seed argument — see Q5. + +The xunit project is `tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj` +(net10.0, xunit), referencing `Aetheria.Shared/Aetheria.Shared.csproj` +(netstandard2.1, `noEngineReferences` mirrored by +`Assets/Scripts/ServerShared/Aetheria.Shared.Unity.asmdef`). The mutation harnesses are +`tests/mutation_tests_*.py`, 17 of them, each patching a named source anchor and +asserting a named test goes red with a no-op control. + +--- + +## Rulings (operator and Self, 2026-09-19) + +- **Q4, unaided accuracy: really, really bad.** Operator: "Targeting with the mark one eyeball + should be really, really bad." A ship with no targeting system can hit something close, + slow and unaware, and little else. That makes the targeting subsystem **required equipment** + rather than an upgrade: every hull needs somewhere to put one, the catalog needs them at + several price points, and a stripped hull is a genuine handicap. Hands picks the other two + balance numbers; this one is authored to make unaided fire a last resort, and the operator + tunes it in play. +- **Q1, turrets: author `FiringArc: 360`.** No new type for what a number already says. +- **Q2, player fire is arc-gated** like everyone else's. Manual and programmatic must not be + two truths. +- **Q3, death removes the ship**, and loot stays as it is until the pickup cut. +- **Q5, seed the rolls in this campaign.** Two lines here beats waiting on an unstarted cut. +- **Q6, snapshot at fire, not at commit** (Self, accepting Imagination's counter-proposal to + the stats map's §8 seam). A shot carries the gun that fired it: its parameters freeze when + the trigger is pulled, and nothing about the shooter's heat, power or wear changes them + mid-flight. Evasion still decides the outcome, because deviation is measured at arrival + against the predicted intercept. `docs/stats-and-power-cut.md` §8 said "snapshot at commit"; + that is superseded, and the resolver's promise (a current value, not a stable one) is + honoured either way. +- **Q7, mine proximity physics: leave it.** + +## 0b. Identity, lifecycle, authority + +One row per kind this campaign introduces. No cut below is mapped with a cell empty. + +| Kind | What names it | What happens to it over time | Who decides | +|---|---|---|---| +| **A shot in flight** | `PendingShot`, a struct in `Zone.PendingShots` (a `List`), identified by a monotonic `int ShotId` from `Zone`. Nothing outside `Zone` holds a reference. | Born in `FireControl.Fire` when a weapon's burst step fires, carrying a payload snapshot frozen at that instant. Ages by `dt` in `FireControl.Step`, called from `Zone.Update` after entities. Reaches its commit horizon, then its arrival time, then is removed the same tick it resolves. A shot whose source or target entity leaves the zone resolves as a miss and is removed. Never serialised: a zone saved mid-flight loses its shots, which is correct — a save is a scene boundary. | `Zone` owns the collection and the identity. `FireControl` owns the transitions. Nothing else may add, remove or mutate one. | +| **A roll** | Nothing. It is not a thing; it is one draw from `ItemManager.Random` inside `FireControl.Commit`, and only its result is named. | Exists for the duration of one call. Its inputs are the payload snapshot plus deviation measured at that instant; its output is written into the shot's `Outcome` and never recomputed. | `FireControl` alone. No behaviour, agent, presentation or UI may draw for a hit. | +| **A commit** | `ShotOutcome { int ShotId; Entity Source; Entity Target; EquippedItem Weapon; bool Hit; bool Shielded; EquippedItem Aimed; int2 Cell; float ArrivalIn; DamageType DamageType; }`, published on `Zone.ShotCommitted` (a `Subject`). | Created once, at `ArrivalTime - CommitHorizon`, or at fire time when the flight is shorter than the horizon. Immutable from then on. Published once. Read by presentations and by nothing else. Dies with the shot. | `FireControl` creates it and is the only writer. Presentations are the only readers. **Nothing outside presentation may read a commit to change state** (`docs/shield-presentation-contract.md:57`). | +| **A targeting solution** | Not a stored object. `FireControl.HitProbability(weapon, source, target)` is a pure function, recomputed on demand. | Has no lifetime. AI reads it to decide whether to fire; the HUD reads it to draw a number; `FireControl.Fire` reads it once per shot and freezes the result into the payload. Two reads a tick apart may differ; nothing caches one. | `FireControl`. The targeting-system behaviour owns only its own resolved stats; it does not compute probability. | +| **Target data / knowledge of a subsystem** | `Entity.EntityInfoGathered[other]` (existing, `Entity.cs:57`) is the knowledge. `FireControl.IsRevealed(observer, item)` is the derived predicate over it. The current aim point is `Entity.TargetItem`, a `ReactiveProperty`. | Info accumulates and decays continuously under `Sensor` (`Sensor.cs:152-184`); ownership of that does not change. Reveal is **derived on every read**, never stored and never cached, so it falls back the moment info decays. `TargetItem` is written only by `Entity.TrySelectTargetItem`, nulled when `Target` changes, and **read through a derived accessor that returns null if the item is no longer revealed or no longer belongs to the target** — no loop clears it. | `Sensor` owns info. `FireControl` owns the reveal rule and the thresholds it reads. `Entity` owns the selection slot; `TrySelectTargetItem` is its only writer. Player input, `CombatState` and the test harness are its three callers. | +| **A hardpoint arc** | `HardpointData.FiringArc`, a `float` on catalog data (`ItemData.cs:538-545`), zero meaning "use the default". Resolved through `FireControl.ArcFor(item)`. | Authored once, in the catalog. Immutable at runtime. Read per shot and per AI fire decision; never stored on the entity. Mount direction is likewise derived per read from `Behavior.Direction`'s item-rotation path. | The catalog authors it; `FireControl` resolves and applies it; `GameplaySettings.FiringArc` supplies the default. **No prefab, transform, articulation limit or renderer may influence it.** | + +--- + +## Cut order + +1. **Mount direction and arc become simulation data.** Deletes `HardpointTransforms` + and the Unity readback; adds the arc gate; unbreaks headless combat. +2. **Targeting system and reveal.** The new subsystem, the reveal rule, the selection + slot, and the two dead settings wired up. +3. **Hit authority moves.** The Physics path in the four effects the catalog actually + uses is deleted and `FireControl` plus the shot lifecycle replaces it, in one cut, so + damage is never applied twice. +4. **The unused kinds join the same path.** Beams, hitscan, mines, splash and airburst + stop querying Physics. They keep existing (R9). + +Cuts 3 and 4 cannot be swapped, and 3 cannot be split into "roll" and "delete the old +path": a cut boundary between them is a cut boundary with two damage owners. + +--- + +## Cut 1. Mount direction and arc become simulation data + +- **Repo/branch:** `Aetheria`, branch `codex/fire-control` from `codex/item-provenance` + `8a00fc92`. Depends on nothing. +- **First:** update `F:\Projects\Aetheria-stats` to HEAD (it sits 11 commits behind at + `c4f6029e`); capture `AetherDb census`, `loadout 1`, `dangling` as the before side. + +**Deletes first:** + +- `Assets/Scripts/ServerShared/Entity.cs:58-59` — `HardpointTransforms` (2). +- `Assets/Scripts/Gameplay/EntityInstance.cs:514-517` — its only writer (4). +- `Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:30-35` — the readback branch in + `Behavior.Direction` (6). The item-rotation path at `:37-38` becomes the whole body. +- `Assets/Scripts/ServerShared/Agents/States/Combat.cs:100-102` — the + `HardpointTransforms` dot test (3). +- `Assets/Scripts/ServerShared/Behaviors/TurretController.cs:81-83` — the `.99f` dot + test (3). +- `Assets/Scripts/ServerShared/Settings.cs:218` — `AgentFiringMinDot`, read by nothing + (1). + +**Adds:** + +- `Assets/Scripts/ServerShared/FireControl.cs` — a new static class, this cut holding + only geometry: `MountDirection(EquippedItem)` (the planar unit vector from + `Behavior.Direction`), `ArcFor(EquippedItem)` and + `bool InArc(EquippedItem weapon, float3 toTarget)`, which tests + `dot(normalize(weapon.Direction.xz), normalize(toTarget.xz)) >= cos(radians(arc/2))`. + The test is planar (R7); target height never enters it. An arc of 360 or more passes + unconditionally. +- `HardpointData.FiringArc` (`ItemData.cs:545`, next `Key`) — full width in degrees, + `0` meaning "use `GameplaySettings.FiringArc`". +- `GameplaySettings.FiringArc = 120` (`Settings.cs`, after `:214`). +- `GameplaySettings.AgentMinHitProbability` is **not** added here; Cut 1's AI gate is + `InArc` alone, which is strictly closer to today's behaviour than a probability the + roll does not yet produce. + +**Per-file changes (against `8a00fc92`):** + +- `Combat.cs:100-102`: `shouldFire = FireControl.InArc(testWeapon.Item, toTarget)`. The + intercept prediction at `:84-98` stays — it still aims `LookDirection`, and under R3 it + becomes the predicted intercept a deviation is measured against. +- `TurretController.cs:81-83`: the same call. Under the default arc a turret hull, whose + `Direction` never rotates (`TurretController` writes only `LookDirection`), would + engage only within 60 degrees of its spawn facing. R6 says turrets are 360; the turret + hull's hardpoints author `FiringArc: 360`. See Q1. +- Catalog: author `FiringArc` on the `Turret` hull's two Ballistic hardpoints. Follows + the Cut C precedent — a Hands scratch console upserts, Unity and Studio closed. + +**Authority map:** + +- **Owner:** `FireControl` owns whether a weapon bears on a point. `HardpointData` + authors the arc; `ItemRotation` authors the mount direction. +- **Inputs:** the equipped item's hardpoint rotation, `HardpointData.FiringArc`, + `GameplaySettings.FiringArc`, the planar vector to the target. +- **Outputs:** one bool, consumed by `CombatState`, `TurretController`, and from Cut 3 + by `FireControl.Fire`. +- **Derived state:** `Behavior.Direction` becomes purely derived from item rotation. +- **Forbidden writers:** no Unity transform, `ArticulationPoint`, `WeaponHardpoint` or + prefab node may influence aim. `Barrels`, `GetBarrel` and `ArticulationPoint` survive + as presentation and must never be read by `ServerShared`. +- **Shared paths:** player fire, AI fire and turret fire all go through `InArc`. Player + fire is arc-gated for the first time; see Q2. +- **Deletion line:** `HardpointTransforms` and its writer and readers are gone before + `FireControl.cs` is written. + +**Verification:** + +- builds: `Aetheria.Shared/Aetheria.Shared.csproj`, `tests/Aetheria.Shared.Tests`, + `tools/AetherDb`. +- tests, each named with the rule it pins: + - `ArcFollowsMountRotation` pins *mount direction is the item's rotation, not the + hull's facing*: a fixture hull with one weapon on a `None` hardpoint and one on a + `Clockwise` hardpoint; a target 90 degrees to starboard passes only for the + `Clockwise` weapon, a target dead ahead only for the `None` weapon. Mutation: make + `InArc` use `Entity.Direction`; swap `Rotate`'s `Clockwise`/`CounterClockwise` cases. + - `ArcBoundaryIsHalfWidth` pins *`FiringArc` is full width*: at 120, a target 59 + degrees off passes and one at 61 does not. Mutation: compare against + `cos(radians(arc))`. + - `ArcIsPlanar` pins R7: a target inside the arc horizontally but far above the firer + still passes. Mutation: use the 3D vector. + - `HardpointOverrideBeatsDefault` pins *the per-hardpoint override wins*: the same + weapon on a hardpoint with `FiringArc: 360` passes at 170 degrees, and fails there on + a hardpoint with `0`. Mutation: ignore the field; treat `0` as an arc of zero. + - `CombatStateStepsHeadless` pins *the simulation decides aim without Unity*: a + `Minion` with a target in range steps through `Zone.Update` without throwing. + Mutation: restore the `HardpointTransforms` read. +- negative: `rg -n "HardpointTransforms" Assets/Scripts` empty; + `rg -n "ArticulationPoint|FiringPoint|WeaponHardpoint" Assets/Scripts/ServerShared` + empty; `rg -n "AgentFiringMinDot" Assets/Scripts` empty. (`\.99f` is **not** a safe + negative grep — it occurs legitimately in `LockWeapon.cs:56`. Grep the two files by + name instead.) +- operator: in Unity, side-mounted weapons still fire at targets abeam; a turret still + tracks all the way around; nothing fires through the hull. + +**Risk:** this cut changes what the AI fires at before the roll exists, so combat feel +shifts once and shifts again in Cut 3. That is the price of deleting the obsolete +authority first, and it is the right price. + +--- + +## Cut 2. Targeting system and reveal + +- **Repo/branch:** continues Cut 1. Depends on Cut 1 (uses `FireControl.cs`), and on + provenance Cut C if that has not landed, because Cut C re-upserts every `ItemData`. +- **First:** `AetherDb census` and `loadout 1` over several seeds, as the before side. + +**Deletes first:** nothing. This cut is entirely additive, which needs its account +(CODE IS A LIABILITY): the invariant it buys is R5, which cannot be purchased by moving +authority, because no authority for it exists — two authored settings are read by +nothing and there is no subsystem to carry accuracy. Cut 3 is where the subtraction is. + +**Adds:** + +- `Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs` — + `TargetingSystemData : BehaviorData` (next free `Union` index on `BehaviorData`; Hands + reads the current list in `Behaviors.cs` rather than trusting the number 39 quoted in + the superseded draft) plus a `TargetingSystem : Behavior` that resolves and exposes: + - `Accuracy` (0..1) — the ceiling on hit probability. + - `Resolution` (detection threshold..1) — the info level at which sensor state stops + limiting hits. + - `Precision` (0..1) — the chance a hit lands on the aimed item rather than a random + cell. + - `Tracking` — how much deviation it forgives (consumed in Cut 3). + + All four are `PerformanceStat`, so lot quality, wear, heat and `PowerSupply` reach them + through `EquippedItem.Evaluate` — that is the brownout ruling applied to accuracy, and + it is why a starved targeting system rolls worse instead of switching off. Energy and + heat come from the existing `EnergyDraw` and `Heat` behaviours on the item; the + subsystem adds no power machinery of its own. +- `GameplaySettings.UnaidedAccuracy` — what an entity with no working targeting system + fires with. Such an entity uses `Resolution = 1` and `Precision = 0`: it can still + select a revealed item, and its hits scatter. +- `FireControl.IsRevealed(Entity observer, EquippedItem item)` — derived, never cached: + 1. Rank the target's non-hull equipment: hardpoint-mounted first, then size + descending, then equipment index. + 2. Item `i` of `N` is revealed when + `info >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1))`. + + This is the first and only reader of `Settings.cs:212-213`. +- `Entity.TargetItem` (`ReactiveProperty`, beside `Target` at + `Entity.cs:46`) and `bool Entity.TrySelectTargetItem(EquippedItem)`, the only writer. + It accepts null, or an item of `Target.Value` that `IsRevealed`. A `Target` change + nulls it. The read accessor returns null when the item is no longer revealed, so decay + drops the aim point without a loop. +- Player input: a select/cycle command beside `ActionGameManager.cs:368-403`, calling + `TrySelectTargetItem`. The target schematic + (`Assets/Scripts/UI/HUD/SchematicDisplay.cs:109-119`, which today lists only weapon + items) shows revealed items and marks the selection. That is presentation; it decides + nothing. +- AI: `CombatState` selects the revealed target `Weapon` item with the highest + `RangeDamagePerSecond(range)`, or null. +- Catalog: two targeting-system designs, 1-cell and 2-cell, each with products from at + least the manufacturers that sell the capacitor, so `IsAvailable` finds one in every + galaxy. `LoadoutGenerator.FillInterior` picks one with `required: true` for every + entity with `Weapons` — the starting LonginusX, NPC ships and turrets. Zenith, with no + weapon hardpoints, gets none. + +**Authority map:** + +- **Owner:** `FireControl` owns the reveal rule. `Entity` owns the selection slot. + `TargetingSystem` owns nothing but its own resolved stats. +- **Inputs:** `EntityInfoGathered`, the two threshold settings, the target's equipment + list and item sizes. +- **Outputs:** a bool per (observer, item); a nullable `EquippedItem` per entity. +- **Derived state:** reveal is derived on read. `TargetItem`'s *validity* is derived; + only the slot is stored. +- **Forbidden writers:** the HUD may not write `TargetItem` except through + `TrySelectTargetItem`; no code may store a reveal result; no code may read the two + thresholds except `IsRevealed`. +- **Shared paths:** player selection, AI selection and the test harness use one writer. +- **Deletion line:** none, and that is stated rather than hidden. + +**Verification:** + +- builds: as Cut 1. +- tests: + - `SelectionNeedsReveal` pins *you cannot aim at what you have not resolved*: below + the item's tier `TrySelectTargetItem` returns false; above it, true; a target change + nulls it; info decaying below the tier makes the read null. Mutation: `IsRevealed` + returns true; the `Target` change skips the null; the accessor returns the stored + value without re-checking. + - `RevealOrder` pins *the big obvious things resolve first*: hardpoint items reveal + before interior items, larger before smaller. Mutation: drop the ordering. + - `RevealSpansBothThresholds` pins *the tiers are the authored ones*: with N items, + the first reveals at `TargetArmorInfoThreshold` and the last at + `TargetGearInfoThreshold`. Mutation: swap them; use the detection threshold. + - `UnaidedFiresWorse` pins *the subsystem is what supplies accuracy*: an entity with + no targeting system resolves `UnaidedAccuracy` and `Precision` 0; one with a + destroyed or offline system resolves the same. Mutation: fall back to the system's + stats when offline. + - `StarvedTargetingRollsWorse` pins the brownout ruling on accuracy: with an + `Accuracy` stat carrying a `PowerSupply` term, a half-grant halves it rather than + switching the item off. Mutation: gate on `PowerSupply <= 1e-4`. + - `EveryArmedLoadoutGetsATargetingSystem` pins the required-item rule across seeds. + Mutation: drop `required: true`. +- negative: `rg -n "TargetArmorInfoThreshold|TargetGearInfoThreshold" Assets/Scripts` + returns `Settings.cs` and `FireControl.cs` **only**. +- operator: `AetherDb census` shows 2 more designs; `loadout 1` shows one more interior + item per armed entity; `dangling` is 0; reopen reports 0 schema issues; CultCache + Studio renders the new behaviour union (an operator check, because Studio's handling of + a new union index is not something this map can promise). + +**Risk:** `LoadoutGenerator.EquipHardpoints` can already fit the Tractor Beam into +LonginusX's only Sensors slot, leaving an NPC that gathers no info and therefore reveals +nothing and hits nothing (`docs/headless-playground-cut.md:520-523`). Check `loadout` +over several seeds. The fix is data or `EquipHardpoints`, not fire control. + +--- + +## Cut 3. Hit authority moves + +The rebuild. The old path is deleted and the new owner added in the same cut, because +any boundary between them is a boundary with two damage owners. + +- **Repo/branch:** continues Cut 2. Depends on Cuts 1 and 2. +- **First:** capture a Unity play smoke of current combat feel (kill time against one + NPC, roughly) as the before side, since this cut changes feel more than anything in + the campaign. + +**Deletes first** (paths and line counts against `8a00fc92`): + +- `Assets/Scripts/Gameplay/EntityInstance.cs:277-396` — `DamageSchematic` and both + `HullCollider` subscriptions (120). +- `Assets/Scripts/Gameplay/HullCollider.cs:13-14,18-41,48-65` — `Hit`, `Splash`, + `SendHit`, `SendSplash` and both argument classes (~45 of 65). `OnCollisionEnter` + (`:43-46`) stays; ship collision is a deferred Unity-physics surface. +- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:57-103` — the raycast, shield branch and + `SendHit` (47). `:109-122` (airburst) is Cut 4's. +- `Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs:152-189` — the same (38). +- `Assets/Scripts/Gameplay/Weapons/Laser.cs:40-75` — the same; the `LineRenderer` + endpoint writes at `:59,70,77` are presentation and are rebuilt from the outcome (36). +- `Assets/Scripts/Gameplay/Weapons/Lightning.cs:34-72` — the same; the endpoint capture + at `:67-71` stays as a visual (39). +- `Assets/Scripts/Gameplay/EntityInstance.cs:411-431` — the `UnityEngine.Random` loot + roll, **only if Q3 is answered (a)**; otherwise this cut leaves it and the seam is + named in §6. + +**Adds:** + +- `Entity.DamageSchematic(float damage, Shape hitShape)` — the rule moved verbatim from + `EntityInstance.cs:277-314`, now a method on `Entity` in `ServerShared`, writing + `Armor`, item `Durability`, `Hull.Durability` and raising the existing `ArmorDamage`, + `ItemDamage` and `HullDamage` subjects. Not a redesign: the same arithmetic, the same + order, the same thresholds. +- `Entity.ApplyHit(...)` — the shape construction moved from + `EntityInstance.cs:334-394`, with two changes forced by R7: the hit cell comes from the + rolled `Cell` rather than a UV texture coordinate, and the penetration march rotates + the firer-to-target vector into the target's frame with a planar rotation by + `-target.Direction` instead of `transform.InverseTransformDirection`. +- `FireControl.HitProbability(weapon, source, target)` — pure, no draw: + - Zero, with no draw consumed, when there is no target; the target is not in + `VisibleEntities`; range is outside `[MinRange, Range]`; a `LockWeapon` is not + locked; or `InArc` fails. + - Otherwise `p = Accuracy * pSensor * pSpread`, where + `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info))` and + `pSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1`, with + `angularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range))`. + - `SchematicCellSize` is the one new tuning constant (Q4). +- `FireControl.Fire(weapon, item, source)` — called once per burst step from + `InstantWeapon.Execute` (replacing the bare `OnFire?.Invoke()` at + `InstantWeapon.cs:228`). It computes the predicted intercept, the flight time + (`range / Velocity`, or zero when `Velocity` is at or near zero), **freezes the payload + snapshot** (R10) and appends a `PendingShot` to `Zone.PendingShots`. The snapshot holds + `Damage`, `Penetration`, `DamageSpread`, `DamageType`, `Aimed`, the resolved targeting + stats, and the intercept point. Nothing downstream re-evaluates a stat. +- `FireControl.Step(Zone, dt)` — called from `Zone.Update` after the entity loop + (`Zone.cs:150`). For each pending shot: age it; at `ArrivalTime - CommitHorizon` + compute deviation, roll, write `Outcome`, publish `Zone.ShotCommitted`; at + `ArrivalTime` apply the outcome and remove the shot. A shot whose flight is shorter + than the horizon commits at fire time, in the same call as `Fire` — that is every + velocity-0 weapon, which is three of the catalog's eighteen. +- `GameplaySettings.CommitHorizon` — R4's authored horizon, one number, in seconds. +- The deviation term: `pDeviation` folds in how far the target is from the predicted + intercept at commit time, forgiven by the targeting system's `Tracking`. This is R3's + evasion, and the commit horizon is what bounds how late it counts. +- The roll: one draw against `p`. On a hit, a second draw against `Precision` decides + where — on `Aimed`'s footprint cells on success, otherwise on one cell drawn uniformly + over the hull `Shape`. An active shield that `CanTakeHit` takes the hit instead, and + the outcome is marked `Shielded`; that rule now exists once, in `FireControl`, rather + than seven times in the effects. +- `Zone.ShotCommitted` and `Zone.ShotResolved` (`Subject`), the presentation + feed. +- `InstantWeapon.OnFire` becomes `event Action` carrying the `ShotId`, so the + Unity manager can bind its effect to the shot it belongs to. + +**Per-file changes:** + +- `InstantWeapon.cs:228`: `OnFire?.Invoke(FireControl.Fire(this, Item, Entity))`. + Charge multipliers already fold into `Damage` and `Spread` before this point. +- `Combat.cs:100-102`: + `shouldFire = FireControl.HitProbability(...) >= Settings.AgentMinHitProbability`, + replacing Cut 1's bare `InArc` (which the probability already subsumes, returning zero + out of arc). `AgentMinHitProbability` is added here, not in Cut 1. +- `TurretController.cs:81-83`: the same gate and threshold. +- `EntityInstance.cs:203-204,232-235`: the manager call takes the `ShotId` and looks the + outcome up, or subscribes to `ShotCommitted`. Effects fly to the rolled cell's world + point or to a miss offset and play the impact on arrival. **They apply nothing.** +- `Zone.cs:150`: `FireControl.Step(this, deltaTime)` after the entity loop. + +**Authority map:** + +- **Owner:** `FireControl` owns the engage gate, hit probability, the roll, the cell, the + shield branch, and when each of those happens. `Entity` owns the application of damage + to its own schematic. `Zone` owns the pending-shot collection. +- **Inputs:** weapon stats through `EquippedItem.Evaluate`; targeting-system stats; + `EntityInfoGathered`; `Target` and `TargetItem`; both entities' positions, velocities + and planar directions; mount direction and arc; `ItemManager.Random`. +- **Outputs:** `ShotOutcome` on two subjects; `Entity.DamageSchematic`; shield + `TakeHit`; `Entity.IncomingHit`. +- **Derived state:** `Behavior.Direction`, reveal, hit probability. Everything a + presentation shows about a shot is derived from the outcome. +- **Forbidden writers:** after this cut no `Physics.*` query in + `Assets/Scripts/Gameplay/Weapons` (except the unused four Cut 4 handles) may reach + durability; `HullCollider` may not raise a hit; `EntityInstance` may not damage a + schematic; no effect may call `TakeHit`; nothing outside `FireControl` may draw for a + hit; nothing outside presentation may read a commit to change state. +- **Shared paths:** player fire, AI fire, turret fire, instant, charged, guided and + locked weapons all reach damage through `FireControl.Fire` and `FireControl.Step`. + There is exactly one commit primitive. +- **Deletion line:** the six deletions above land before `FireControl.Fire` exists. + +**Verification:** + +- builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`; and Unity + batchmode with the editor closed (`docs/item-provenance-cut.md` §6) reporting no + `error CS`. +- tests, each with the mutation that must kill it: + + | Test | Pins | Mutation | + |---|---|---| + | `RollsAreSeeded` | *a fight is reproducible* — two zones, same seed, 50 shots, identical outcome sequences | `FireControl` draws from `new Random()` | + | `ProbabilityFollowsInputs` | *the roll reads what the ruling says it reads* — zero below detection and outside range; rising with info to `Resolution`; falling with `Spread`; capped at `UnaidedAccuracy` with no system | drop `pSensor`; drop the range gate; drop the cap | + | `OutOfArcConsumesNoDraw` | *the gate is a gate, not a penalty* — the RNG sequence is unchanged by an out-of-arc trigger | roll first and multiply by zero | + | `ShotResolvesOnArrival` | R3 — no durability change before `range / Velocity`; a target removed mid-flight takes none | apply at fire time | + | `OutcomeCommitsBeforeImpact` | R4 — `ShotCommitted` fires exactly `CommitHorizon` before `ShotResolved`, once per shot | publish at arrival; publish twice | + | `EvasionCountsUntilCommitAndNotAfter` | R4's second half — a target that jinks before the horizon changes the outcome distribution; the identical jink after the horizon does not | measure deviation at arrival; measure it at fire | + | `ShortFlightCommitsAtFire` | *the horizon degrades gracefully* — a velocity-0 weapon commits and resolves in one tick, still publishing both events in order | skip the commit for short flights | + | `OutcomeIsSnapshotNotReread` | R10, the seam the stats campaign named — a weapon whose `Damage` collapses (power cut, heat spike) between fire and arrival still lands the damage it rolled | re-evaluate `Damage` at arrival | + | `AimedHitLandsOnSelectedItem` | R5's payoff — `Precision` 1 and `p` 1: only the aimed item's durability falls | the roll ignores `Aimed` | + | `HardpointHitDamagesItemThenHull` | the damage rule survived the move verbatim — damage above armor plus item durability zeroes the item and the remainder hits the hull | armor not subtracted; hull skipped | + | `PenetrationMarchIsPlanar` | R7 — the march rotates by the target's planar direction and gives the same cells whatever the height difference | use a 3D rotation | + | `ShieldTakesHit` | *the absorb rule has one owner* — an active shield absorbs and the schematic is untouched | remove the shield branch | + | `DeadEntityStopsTakingShots` | *a shot cannot damage a corpse* — a target destroyed before arrival takes nothing and the shot is removed | resolve against a removed entity | + +- negative greps, each checked against legitimate names before publishing: + - `rg -n "Physics\." Assets/Scripts/Gameplay/Weapons/Projectile.cs Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs Assets/Scripts/Gameplay/Weapons/Laser.cs Assets/Scripts/Gameplay/Weapons/Lightning.cs Assets/Scripts/Gameplay/HullCollider.cs` + — empty. (A repo-wide `Physics\.` grep is **not** valid until Cut 4, and never for + `TractorBeam.cs`, which stays.) + - `rg -n "SendHit|SendSplash|DamageSchematic" Assets/Scripts --glob '!**/ServerShared/**'` + — empty. + - `rg -n "TakeHit\(" Assets/Scripts --glob '!**/ServerShared/**'` — empty. Note + `CanTakeHit` shares the substring; the trailing `(` on `TakeHit` distinguishes them, + and the `Can` prefix must be checked by eye in the first run. +- operator play smoke: shots show rolled impacts and misses; a miss reads as a + deliberate near-miss rather than a bug; damage matches the HUD; aiming at a revealed + subsystem visibly concentrates damage there; kill time is in the same order as before. + +**Risks.** `UnaidedAccuracy`, `SchematicCellSize`, `CommitHorizon` and the targeting stat +ranges are first guesses. The headless fixture is the tuning harness, and the operator +smoke is the arbiter. Second: `Combat.cs:84-98` aims `LookDirection` at a first-order +intercept, and R3's deviation is measured against `FireControl`'s own predicted +intercept; if the two predictions differ, an AI will systematically miss. They must be +one function. Hands makes `FireControl` own the prediction and `CombatState` call it. + +--- + +## Cut 4. The unused kinds join the same path + +R9 keeps every weapon type. This cut makes the ones no design currently ships obey the +same owner, so that authoring one later does not resurrect Unity hit detection. + +- **Repo/branch:** continues Cut 3. Depends on Cut 3. +- **First:** **re-measure the claim this cut's safety rests on** — that no live design + uses `ConstantWeaponData`, `HitscanEffect`, `Mine`, airburst or MIRV (§0.4, carried + from a superseded map and not reproduced by this campaign's probe). A behaviour-class + census needs `AetherDb roles-migrate` as a dry run, which the permission classifier + blocked; the operator can unblock it or Hands can add a read-only `behaviors` command. + **If any of these is live, it moves into Cut 3 instead.** + +**Deletes first:** + +- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:109-122` — airburst `OverlapSphere` and + `SendSplash` (14). +- `Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs:74-110` (~37), + `ConstantLightning.cs:54-70` (~17), `HitscanEffect.cs:31-55` (~25), + `Mine.cs:68-75,92-117` (~34), `ConstantParticleWeapon.cs:72` and its `_hull` plumbing + (~8) — every remaining `Physics.*` hit query and `SendHit`/`SendSplash` call in + `Assets/Scripts/Gameplay/Weapons`. + +**Adds:** + +- `ConstantWeapon` rolls once per `GameplaySettings.BeamResolveInterval` for + `Damage * interval`, through the same `FireControl.Fire`/`Step` pair with a flight time + of zero, raising `OnBeamShot(int shotId)`. About ten lines; no design uses it yet, and + it authors no new stat. +- Splash — airburst and mine blast — as `FireControl.Splash`, applying the existing + `DamageSchematic` over the directional half-shape the deleted + `EntityInstance.cs:318-329` computed, against every entity in radius. This is the one + genuinely new rule in the cut, and it exists because R9 keeps the flak cannon and the + mine. +- Nothing else. `Mine`, `TractorBeam` and ship collision stay Unity-physics surfaces for + *placement and contact*, which is not hit detection; only their damage application + moves. + +**Authority map:** unchanged from Cut 3 except that the forbidden-writer list now covers +every file in `Assets/Scripts/Gameplay/Weapons`, and `FireControl` gains splash. + +**Verification:** + +- builds: as Cut 3. +- tests: + - `BeamRollsPerInterval` pins *a beam is a sequence of rolls, not a continuous truth*: + over one second a beam produces `1 / BeamResolveInterval` outcomes totalling + `Damage`. Mutation: roll per tick; roll once. + - `SplashHitsEveryEntityInRadius` pins *splash is one rule, not per-effect*: two + targets in radius both take damage, one outside takes none. Mutation: damage only + the nearest. + - `SplashIsDirectional` pins that the moved half-shape rule survived: cells facing the + blast take it, cells facing away do not. Mutation: damage the whole shape. +- negative: `rg -n "Physics\." Assets/Scripts/Gameplay/Weapons` returns **only** + `Mine.cs`'s placement query, if Q7 keeps it — otherwise empty. `TractorBeam.cs:21` is + outside this directory and stays. + `rg -n "SendHit|SendSplash" Assets/Scripts` empty. + `rg -n "class HullHitEventArgs|class HullSplashEventArgs" Assets/Scripts` empty. +- operator: nothing to check in play, because nothing in the catalog spawns these. The + check is a Studio authoring pass: author one beam product and one flak product onto a + test hull and confirm they damage. + +--- + +## 5. Subtraction ledger + +Estimates, to be compared with reality in the postmortem. + +| Cut | Removed | Added | Deps / targets / formats | +|---|---|---|---| +| 1 | 19 production (Entity 2, EntityInstance 4, Behaviors 6, Combat 3, TurretController 3, Settings 1) | ~60 production (`FireControl.cs` geometry ~45, `HardpointData.FiringArc` 1, `GameplaySettings.FiringArc` 1, call sites ~13), ~90 test | +1 catalog field, authored on one hull. No new project, package or dependency. | +| 2 | 0 | ~140 production (`TargetingSystem.cs` ~55, reveal ~25, `Entity.TargetItem` ~25, AI selection ~10, player input ~15, `LoadoutGenerator` ~12), ~160 test | +1 behaviour union index, +2 designs and their products, +1 setting. | +| 3 | ~285 production (EntityInstance 120, HullCollider 45, Projectile 47, GuidedProjectile 38, Laser 36, Lightning 39) | ~330 production (`Entity.DamageSchematic`/`ApplyHit` ~130 moved, `FireControl` roll and lifecycle ~140, `Zone` pending shots ~35, call sites ~25), ~280 test | +2 settings. **Seven duplicated copies of the shield-absorb rule collapse to one.** | +| 4 | ~135 production | ~70 production, ~70 test | +1 setting. | +| **Total** | **~439** | **~600 production, ~600 test** | | + +Net production about +160. The account: it buys the invariant that the simulation +resolves combat without Unity — `docs/three-gates-scope.md:101-110` calls today's state +a velocity compromise — and it collapses seven copies of the absorb rule and one copy of +the damage rule into single owners. It does **not** buy that with a registry, an adapter, +a mode flag or a compatibility layer: the old path is deleted in the cut that replaces +it, and no cut leaves two writers of damage. + +The line count is the wrong thing to stare at here. The structural delta is: one new +owner (`FireControl`), one new subsystem (`TargetingSystem`), one field on catalog data, +four settings, and **minus one whole category of authority** — Unity physics deciding +gameplay. + +--- + +## 6. Build budget + +- **Packages and targets.** `Aetheria.Shared/Aetheria.Shared.csproj` (netstandard2.1), + `tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj` (net10.0, xunit), + `tools/AetherDb/AetherDb.csproj`. Three projects, no new ones. Debug only; no release + profile, no feature flags, no code generation, no new target platform. +- **Build host and target are the same machine**: a Windows workstation producing + managed assemblies for the same runtime. Nothing here is cross-platform, so no claim + about a Linux artifact is made or needed. +- **Where.** `F:\Projects\Aetheria-stats` (the free worktree; it must first be advanced + from `c4f6029e` to HEAD). Never the main tree — the operator plays there. Never + `F:\Projects\Aetheria-tiers`. CultLib via + `-p:CultLibRoot=\cultlib-45c2f40`, the shared pinned worktree, which is + never removed. `--artifacts-path` into scratch so no `bin`/`obj` lands in a working + tree. +- **Footprint.** Three managed projects plus their test host; on the order of a few + hundred MB of artifacts, comparable to the stats campaign. No expected growth: this + campaign adds no project, package reference or native component. +- **Unity.** Batchmode compile check with the editor closed, per + `docs/item-provenance-cut.md` §6, once per cut that touches `Assets/Scripts/Gameplay` + (Cuts 1, 3, 4). The operator's play smoke is the only Unity *run*. Imagination did not + run Unity and did not build. +- **Mutation harnesses.** One per cut, in the established shape + (`tests/mutation_tests_fire_control_cut1..4.py`): patch a named anchor, run + `dotnet test tests/Aetheria.Shared.Tests --cultlib-root `, assert the named test + goes red, with a no-op control. Note the scar from `8dddda4d`: later cuts move earlier + anchors, so Cut 4 re-anchors Cuts 1-3's harnesses before it lands. + +--- + +## 7. Seams this campaign names and stops at + +- **Presentation of shots.** `Zone.ShotCommitted` and `Zone.ShotResolved` are the whole + contract. What a projectile, panel, whip or near-miss *does* with a commit belongs to + `docs/shield-presentation-contract.md` and the panel and Lariat maps. This campaign + guarantees only that the commit exists, is authoritative, arrives `CommitHorizon` + before impact, and carries what the contract says it carries: what happens, where, + when it arrives, and which capability answers it — not who fired, not damage numbers. + The contract's open question "where the commit window lives" is answered here: + `FireControl`, with the horizon in `GameplaySettings`. +- **Death, removal and loot.** `Entity.Death` is already composed in `ServerShared` + (`Entity.cs:363-366`), but `Zone.Entities.Remove` and the loot roll live in Unity + (`EntityInstance.cs:411-437`) and use `UnityEngine.Random`. Fire control makes a + headless kill *happen*; it does not make the corpse go away. Q3. +- **Pickup.** `ShieldManager.cs:40-51` stores loot on trigger contact. Untouched. +- **Ship collision, `TractorBeam`, mine placement.** Deferred Unity-physics surfaces + (`docs/three-gates-scope.md:98-99`). Untouched. +- **The navigation planner.** R3 makes manoeuvring evasion, which changes what a good + flight path is. The planner is a separate item in the shipping scope and this map does + not anticipate it. +- **Stance and IFF.** Ruled and partly built (`Weapon.StanceAllowsFire`, + `Weapon.cs:101`). Fire control reads the gate and does not redesign it. +- **Faction territory.** The campaign after this. + +--- + +## 8. Operator questions + +Most blocking first. + +**Q1. What makes a turret 360?** R6 says turrets are 360 degrees, but a turret hull's +`Direction` never rotates — `TurretController` writes only `LookDirection` — so under a +120-degree default a turret would only engage within 60 degrees of its spawn facing. +**A:** author `FiringArc: 360` on the `Turret` hull's hardpoints; the override R6 already +grants does the whole job and no code knows what a turret is. **B:** add +`HardpointType.Turret` and give it an implicit 360. **C:** give the arc to the hull +rather than the hardpoint. **Recommended: A**, because it buys the invariant with +authored data instead of a new type, and because a future partially-traversing turret +(a 270-degree sponson) is then already expressible. B makes the arc a property of a +category and loses that. + +**Q2. Is player fire arc-gated?** Today it is not gated at all; the player fires whatever +is equipped, wherever they point. Cut 1 makes the arc a shared rule. **A:** yes, gate the +player exactly as the AI — a side-mounted gun simply does not fire forward, and the HUD +shows which groups bear. **B:** gate the AI only, leaving the player's rear gun able to +shoot forward. **Recommended: A.** Under R1 aiming is gone, so what the player does with +a weapon group *is* the decision, and arcs are what make that decision interesting. B +also splits the shared path the doctrine cares about: manual and programmatic fire would +be two truths. It is a real difficulty change and should be felt in the smoke before the +campaign closes. + +**Q3. Does this campaign move death removal and loot, or leave them in Unity?** A +headless kill currently leaves the corpse in `Zone.Entities`, because `Entities.Remove` +is at `EntityInstance.cs:437`. **A:** move removal and the loot roll into `Zone` in +Cut 3, using `ItemManager.Random` — about 50 lines, and it makes a headless kill a +complete event. **B:** move only the removal; leave loot to the run-structure work. +**C:** leave both; fire control's tests assert on durability, not on removal. +**Recommended: B.** Removal is the half without which the simulation is lying about its +own contents, and a seeded loot roll pulls in drop velocity, bay capacity and pickup — +the run-structure campaign's material. A is more complete but widens this campaign; C +leaves `ServerShared` unable to say who is alive. + +**Q4. Three balance numbers need first values:** `CommitHorizon` (R4's "short fixed +time"), `SchematicCellSize` (metres per schematic cell, which sets how much target size +matters), and `UnaidedAccuracy`. **A:** Hands picks defaults (0.3s, measured off the +LonginusX prefab, 0.35) and the operator tunes them in the smoke. **B:** the operator +states them now. **Recommended: A** for the first two and **B for `UnaidedAccuracy`**, +because it is not a tuning number — it decides whether a targeting system is a +requirement or an upgrade, which is a design ruling. If it is low, Cut 2's required-item +rule is load-bearing; if it is high, the subsystem is a nice-to-have and Cut 2 shrinks. + +**Q5. What seeds the rolls?** `ItemManager.Random` is clock-seeded +(`ItemManager.cs:19`), so `RollsAreSeeded` cannot pass without a seed argument. +**A:** add the seed parameter here, as a two-line change. **B:** wait for the `Run` +lifecycle work that owns it (`docs/headless-playground-cut.md` Cut 1, unlanded). +**Recommended: A.** A reproducible fight is how this campaign is tuned and how a +regression is ever found again; waiting on an unstarted cut for two lines is the +compensator pattern in miniature. + +**Q6. When is the snapshot taken — fire, or commit?** `docs/stats-and-power-cut.md:1020` +says "at commit time". **A:** one snapshot, at fire: the shot carries what left the +barrel, and only deviation is measured up to the commit. **B:** two snapshots — payload +at fire, targeting stats at commit — so a targeting system knocked out mid-flight +degrades the shot already in the air. **Recommended: A.** One rule, one freeze point, and +over a horizon measured in tenths of a second B's extra fidelity is invisible while its +extra state is permanent. B is the more simulationist answer and is worth having if the +operator wants killing the gunnery computer to spoil shots already fired. + +**Q7. Mines.** Cut 4 moves mine *damage* to `FireControl.Splash` but leaves +`Mine.cs:68`'s `OverlapSphere` for proximity triggering, which is contact detection +rather than hit detection. **A:** leave it, alongside `TractorBeam` and ship collision, +as a named deferred physics surface. **B:** move proximity into `Zone` too, so the last +`Physics.*` in the weapons directory goes. **Recommended: A.** No design ships a mine, +and B buys a clean grep rather than an invariant. Revisit when a mine is authored. + +--- + +## Cut 5: the rules that landed in one path and not its twin + +Date: 2026-09-19. Branch `codex/fire-control-5` off `ca57819d`. + +Soul's pass over the whole campaign (2026-09-19) confirmed eleven findings. Most +of them are the same shape: a rule this map declared, implemented on one path, +and left off the path beside it. Splash breaks a shield the discrete roll does +not. `InstantWeapon` gates on arc and `ConstantWeapon` does not. Unity removes a +corpse from the zone and the simulation does not. This cut closes the twins. +Catalog authoring (Soul's findings 1 and 2) is Cut 6; the shared-RNG stream +(finding 6) and airburst (finding 5) are Cut 6 as well; the missing mutation +harnesses (finding 8) are Cut 7. + +### 5.1 Tracking is a floor, not a cliff (Soul finding 3) + +`FireControl.cs:102-106` falls back to `Tracking = 0` with no targeting system, +and `:273-275` turns that zero into `deviation < .01f ? 1f : 0f`. A probe fired +334 shots at a target drifting at 5 u/s with `Accuracy` forced to 1 and landed +none. Q4 asked for unaided fire to be really, really bad; this is a wall. + +- `Settings.cs`, beside `UnaidedAccuracy`: add `public float UnaidedTracking = 10f;` + with the same authoring note. Ten world units of deviation forgiveness is the + first guess and the operator's knob: a target that has strayed five units from + its fire-time projection halves an already-5% chance. +- `FireControl.Tracking` falls back to `settings.UnaidedTracking`, exactly as + `Accuracy` falls back to `UnaidedAccuracy`. One shape for both. +- `Commit` then deletes the zero branch outright: `p *= saturate(1f - deviation / shot.Tracking)`. + Tracking is now always positive, so the branch protects nothing. + +**Authority:** `Settings` owns the unaided figures; `FireControl.Tracking` is the +only reader; `Commit` no longer carries a special case for a value that cannot occur. + +### 5.2 A shot the shield cannot absorb breaks it (Soul finding 4) + +`Commit:299-302` sets `Shielded` only when `CanTakeHit` passes, and `Apply:311-326` +routes the rest to the hull -- neither calls `Break()`. `Splash:365` does. The +deleted Unity path did (`git show b7743789^:Assets/Scripts/Gameplay/Weapons/Projectile.cs`). +So the F4/F5 shield-break mechanic is unreachable for every weapon in the catalog: +an unabsorbable 5000-damage hit leaves `Broken == false` and the shield absorbs the +very next shot, refilling at `RefillDuration` instead of the punitive `RestoreDuration`. + +- `ShotOutcome` gains `public bool ShieldBroken;`. +- `Commit` decides it where it already asks `CanTakeHit`: shield present, active, + and `CanTakeHit` false means `ShieldBroken = true`. The decision is frozen with + the rest of the outcome (R4) -- a shield that recharges during the flight does + not retroactively survive. +- `Apply` performs it: `if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();` + before the hull damage. `CanTakeHit` is still called exactly once per shot. +- Presentation gains the signal for free; nothing is required to read it yet. + +### 5.3 Continuous weapons obey their arc (Soul finding 9) + +`ConstantWeapon.cs:91,99` gate on `StanceAllowsFire` alone. A side-mounted beam +fires forward. Q2 says manual and programmatic must not be two truths; instant and +continuous currently are. + +- Both gates become `StanceAllowsFire && ArcAllowsFire`, matching `InstantWeapon.cs:105`. + +### 5.4 One bearing test, including at zero range (Soul finding 11) + +`Weapon.cs:117` allows a zero-length bearing; `FireControl.InArc:47` normalizes the +zero vector into NaN and `HitProbability` returns 0. The player's trigger says fire +and the probability says impossible, so AI and turrets refuse to fire at a +co-located target. + +- `InArc` takes the rule: a planar bearing shorter than `1e-6` returns true -- + point-blank cannot fail a bearing test. +- `Weapon.ArcAllowsFire` drops its own `lengthsq` special case and simply calls + `InArc`. One owner, one answer. + +### 5.5 A destroyed subsystem stops being the aim point (Soul finding 10) + +A destroyed item is never removed from `Equipment` (`Entity.cs:475` only observes +it), so `IsRevealed` still ranks it and `ResolvedTargetItem` still returns it -- +`Precision` keeps concentrating hits onto a dead item's footprint. + +- `FireControl.IsRevealed` returns false for an item whose + `EquippableItem.Durability < .01f`, and the ranking excludes destroyed items so + the reveal tiers of the survivors close up rather than leaving a gap. +- `Entity.ResolvedTargetItem` therefore drops a destroyed aim point on its next + read. Nothing caches it, so no clearing pass is needed. + +### 5.6 Death removes the ship, in the simulation (Soul finding 7) + +Operator ruling Q3 says death removes the ship. `Zone.Entities.Remove` for death +exists only at `EntityInstance.cs:324`, inside Unity's loot-drop subscription. So +headless, `Step`'s `targetGone` guard never fires, shots land on corpses, `Splash` +keeps hitting them, the AI keeps engaging them, and the tuning harness this map +calls its arbiter cannot end a fight. `DeadEntityStopsTakingShots` passes only +because it calls `Entities.Remove` by hand. + +- `Zone` subscribes each entity's `Death` observable when the entity joins, and + removes it from `Entities` on death. The simulation owns the corpse. +- `EntityInstance.cs:324` deletes its `Entities.Remove` call and keeps the loot + drop and destroy effect, which are its own business. Forbidden writer: no + presentation removes an entity from the zone. + +### 5.7 Delete the two carried-and-unread fields (Soul finding 12) + +`PendingShot.PredictedIntercept` and `PendingShot.FlightTime` are written at +`FireControl.cs:204-205` and read nowhere. The intercept in particular is +carry-weight from Cut 3's named risk: `Combat.cs:109` and `Fire` do share +`FireControl.PredictedIntercept`, but the roll judges deviation against a +straight-line projection from fire position, so the stored copy decides nothing. + +- Both fields are deleted from the struct. The local `flightTime` stays; it still + computes `ArrivalTime` and `CommitTime`. + +### Verification + +Each of 5.1-5.7 gets a test that fails under its own mutation, and the mutation +harness is committed as `tests/mutation_tests_fire_control_cut5.sh` with a no-op +control. In addition this cut pins four of the five mutations that survived 197 +green tests in Soul's pass, because they are rules 5.1-5.6 touch: + +- `FireControl.cs:365` -- delete `shield.Break()` from `Splash`. Must die. +- `Weapon.cs:117` -- `ArcAllowsFire => true`. Must die. +- `InstantWeapon.cs:105` -- delete the `if (!ArcAllowsFire) return;` player arc gate + (Q2, the ruling Cut 1 deferred to Cut 3 and nothing verifies). Must die. +- `FireControl.cs:270` -- `elapsed = shot.FlightTime` in place of `now - shot.FireTime`. + This is the map's own declared mutation for `EvasionCountsUntilCommitAndNotAfter`, + and it survives because that test's fixture gives the target zero velocity, so + `FireTargetVelocity * elapsed` is identically zero and the mutation is a no-op + inside it. Give the fixture a moving target. (After 5.7 deletes `FlightTime` the + mutation's spelling becomes `elapsed = shot.ArrivalTime - shot.FireTime`; the rule + it attacks is the same.) + +The fifth survivor -- freezing `source.TargetItem.Value` instead of +`ResolvedTargetItem` at `FireControl.cs:193`, bypassing the reveal re-check -- is +Cut 7's, alongside the missing Cut 1-3 harnesses. + +Negative checks: no `Entities.Remove` for death outside `Zone`; no `Tracking` read +that can return zero; no `PredictedIntercept` or `FlightTime` field on `PendingShot`. + +--- + +## Cut 6: the dice stop being shared, and the flak cannon works again + +Date: 2026-09-19. Branch `codex/fire-control-6` off Cut 5. + +Cut 6a (catalog authoring, worktree `Aetheria-catalog`, branch +`codex/fire-control-6-catalog`) runs in parallel and is specified by Soul's +findings 1 and 2 plus the Cut 2 authoring spec above. This section is Cut 6b, the +two remaining code findings. + +### 6.1 A shot's dice belong to the shot (Soul finding 6) + +`Commit:266,304` draws from `ItemManager.Random`, a single stream shared with +`ActionGameManager.cs:552,558,567,686,787` (wormhole exit velocity, loadout +generation, travel) and `Combat.cs:144` (`SampleDps`, drawn per AI per +evaluation). Q5's reproducible fight therefore holds only inside a hermetic +fixture: in play, adding an NPC or opening a shop changes every subsequent die. +Soul's determinism probes passed for exactly that reason, which makes them a +demonstration of the fixture rather than of the rule. + +The fix is a deletion, not a second stream. A stream makes the roll depend on +*how many* draws came before it; what this campaign actually promised is that a +shot's outcome is a function of the shot. So make it one. + +- `Zone` exposes `public uint CombatSeed { get; }`, set in the constructor from + the same expression that already seeds `_random`: + `galaxyZone?.Name.StableHash() ?? 1337u`. One zone, one stable identity — a + galaxy seed reproduces it, and nothing else in the zone can perturb it. +- `Commit` stops touching `ItemManager.Random` entirely. It builds its own local + `CultMath.Random` from the zone seed and the shot's own id: + `var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);` + The `| 1u` guards the degenerate zero seed. Every draw the commit makes — the + hit roll, the precision roll, the cell pick — comes from that one local + generator, and it dies with the call. +- The write-back at `:304` (`shot.Source.ItemManager.Random = random;`) is + deleted with it. + +**Authority:** the roll's randomness is a pure function of `(zone identity, shot +id)`. **Forbidden writers:** nothing outside `Commit` may seed or advance a +combat draw, and `Commit` may not read a shared stream. **Consequence worth +stating:** two shots that happen to share a `ShotId` across two zones roll +differently, because the zone seed differs; two runs of the same fight from the +same galaxy seed roll identically no matter what the UI did in between, which is +the promise Q5 actually made. + +### 6.2 Airburst resolves in the simulation (Soul finding 5) + +Cut 4 deleted `Projectile.cs`'s `OverlapSphere`/`SendSplash` and added +`FireControl.Splash` "because R9 keeps the flak cannon and the mine". Only the +mine was wired (`Mine.cs:99`). `Projectile.cs:16-17` still carries +`AirburstDistance`/`AirburstRange`, still written by `ProjectileManager.cs:32`, +read by nothing — so a flak round flies and does nothing. The stale comment at +`Projectile.cs:7-8` calls airburst "Cut 4's — untouched here". + +The wiring does **not** go back into `Projectile`. R8 says presentation does not +decide damage, and a projectile MonoBehaviour calling `Splash` would be exactly +the authority Cut 3 spent itself deleting. Airburst is a property of the weapon, +so the simulation resolves it: + +- `PendingShot` regains a burst point — `public float3 BurstPosition;` — frozen + at fire like every other payload field, from `PredictedIntercept` (which Cut 5 + deletes as unread; this is its live consumer, and it comes back named for what + it is rather than for what computed it). +- `PendingShot` gains `public float BurstRadius;`, frozen from the weapon's + authored airburst range, and zero for a weapon without the `Airburst` flag + (`Enums.cs:101`). +- `FireControl.Fire` sets both when the weapon's `WeaponData` carries `Airburst`. +- `FireControl.Step`, at arrival, resolves an airburst shot by calling + `Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType)` + **instead of** `Apply`. An airburst round does not also roll a discrete hit: + it is an area effect, which is the whole point of the flag, and applying both + would be the double-application Soul was told to hunt for. +- `Projectile.cs` deletes `AirburstDistance` and `AirburstRange` and the stale + comment; `ProjectileManager.cs:32` deletes the line that wrote one. The + projectile's remaining job is to fly and disappear, which is all presentation + should have been doing. + +**Open tuning question, recorded not asked:** the authored airburst radius has no +sibling in the catalog to argue from, because no product has ever used it. Cut 6a +or a later authoring pass sets it; until then the flag is wired and unexercised, +and the report must say so rather than implying a working flak cannon. + +### Verification + +Committed harness `tests/mutation_tests_fire_control_cut6.sh`, no-op control +required, same byte-exact I/O rules as Cut 5. + +- `SameFightRollsSameThroughUnrelatedDraws` pins 6.1: run a fixed fight twice from + one zone seed, drawing an arbitrary number of values from `ItemManager.Random` + between the two runs, and the outcomes must be identical. Mutation: restore the + `ItemManager.Random` draw. Must die — and note that this is the test Soul's own + probe could not be, because a hermetic fixture cannot see the defect. +- `ShotIdDecidesTheDie` pins that the roll is a function of the shot: two shots + with the same frozen payload and different ids may differ; the same id in the + same zone always gives the same result. Mutation: drop `ShotId` from the seed. +- `AirburstSplashesAndDoesNotAlsoRoll` pins 6.2: an airburst shot damages + everything in its radius, and its target takes area damage rather than a + discrete cell hit. Mutation: call `Apply` as well as `Splash`. Must die. +- `NonAirburstNeverSplashes` pins the other side. Mutation: splash unconditionally. +- negative: `ItemManager.Random` appears nowhere in `FireControl.cs`; + `Airburst` appears nowhere in `Assets/Scripts/Gameplay`. + +--- + +## Cut 6c: the formula gets fixed, and the single point of failure + +Date: 2026-09-19. Branch off Cut 6a (`e5f1318a`) once Cut 5 has merged — this +touches `FireControl.cs`, which Cut 5 owns until then. + +Cut 6a landed the catalog data that Cut 2 never shipped: 53 designs, 43 products, +`loadout` green on ten seeds, turret arcs migrated. Reviewing it turned up two +defects it introduced and one it inherited. + +### 6c.1 Resolution becomes reciprocal, so the name stops lying + +`HitProbability` computes `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution(source), info))`. +With the live `TargetDetectionInfoThreshold` of `.1`, a **higher** Resolution +means **more** gathered info is needed before sensor state stops limiting hits. +Resolution is a cost. The field's own comment says so accurately -- "the info +level at which sensor state stops limiting hits" -- and the word "Resolution" +says the opposite, because in every other context a higher resolution is a +better instrument. + +Cut 6a authored the 1-cell `Targeting Computer` at `.3` and the 2-cell +`Fire Control Array` at `.75`. Against a target scanned to info `.3`, that gives +the cheap design `pSensor = 1.0` and the 150000c design `pSensor = 0.31`. The +premium item is strictly worse until the player has scanned hard, which is not a +tradeoff anyone authored -- it is the formula misleading an author who had read +it and said so in the same paragraph. + +Operator ruling, 2026-09-19: "When the formula is unintuitive, we don't make the +handle more opaque for correctness, we fix the formula. Plenty of core gameplay +formulas are reciprocal just for this reason." An earlier draft of this cut +proposed renaming the field to `SensorDemand`; that is superseded. The name +stays `Resolution`, higher stays better, and the formula takes the reciprocal: + +- `HitProbability` derives the ceiling rather than reading it directly: + `var demandCeiling = detection + (1f - detection) / max(Resolution(source), 1e-3f);` + then `pSensor = saturate(unlerp(detection, demandCeiling, info))`. +- Resolution `1` therefore means "needs complete information before sensor data + stops penalising this system" -- which is precisely what the unaided fallback + of `1f` should mean. That fallback stays as it is and is now the floor by + construction instead of by coincidence. The guard against a zero or negative + authored Resolution is the `max`, and no authored value should ever reach it. +- Re-author both designs on the new scale: 1-cell `2` (sensor-limited until info + reaches `.55`), 2-cell `4` (until `.325`). Both comfortably better than unaided, + the premium one better than the cheap one, and the numbers now read the way a + designer reaching for the knob expects. +- `TargetingSystemData.Resolution`'s comment gains one sentence naming the + direction and the reciprocal, so the next author does not have to derive it. + +### 6c.2 One seller is a die roll against ship generation + +Cut 6a sold both targeting designs from NiteLife Energy alone, satisfying the +Cut 2 spec's letter ("at least the manufacturers that sell the capacitor") while +missing what that clause was for. `Galaxy.cs:109` selects +`factions.OrderBy(random).Take(settings.MegaCount)` — a **random subset** of the +twelve factions — plus the quest faction and the authored neutrals. A galaxy that +does not draw NiteLife has no targeting product, `IsAvailable` +(`LoadoutGenerator.cs:154`) filters every candidate out, and +`FillInterior`'s `required: true` throws `InvalidLoadoutException` again. Cut 6a's +ten green seeds do not disprove this; they sample the draw. + +Two changes, because content coverage and structural fragility are different +problems and only one of them is fixed by authoring: + +- **The generator degrades instead of throwing.** A `required: true` interior + item with no available product leaves the entity without one rather than + failing generation outright. This is only safe *because* Cut 5.1 gave unaided + fire a floor: such a ship now fires badly instead of not at all. The generator + logs the gap so it surfaces as content debt rather than silence, and `AetherDb + loadout` reports it per seed. +- **Thematic sellers, which also buy coverage.** NiteLife Energy is the roster's + power-generation faction; targeting belongs to Finch Cybernetics (passive + sensors) and Lucent Media (active sensors), both of which the roster names for + exactly this. Each design gains products from both. That is three sellers per + design, and it puts the item in the hands of the factions whose brand explains + why they make it. + +### 6c.3 Recorded, not fixed + +`Large Drive`'s new product declares no roles because the design declares none. +That is honest, and it is also a design with no role axes in a game where roles +are how products differentiate. It belongs to the content pass, not here. + +### Verification + +- A test pins 6c.1's direction: two entities, identical but for `Resolution`, at + the same info level -- the **higher** Resolution has the higher + `HitProbability`, at every info level between detection and full. Mutation: + drop the reciprocal and read Resolution as the ceiling directly (the shipped + behaviour). Must die. This is the assertion whose absence let a premium item + ship strictly worse than a cheap one. +- A test pins 6c.2: a galaxy containing no seller of any targeting design still + generates every hull, and those entities resolve `UnaidedAccuracy`. Mutation: + restore the throw. Must die. +- `AetherDb loadout` across at least 50 seeds, plus a run with the faction set + forced to exclude all three sellers. Report the gap count, not just "no throw". +- Census unchanged at 53 designs; products rise by 4 (two more sellers per design). + +--- + +## Cut 7: the harnesses that were prose + +Date: 2026-09-19. Branch `codex/fire-control-7` off Cut 6. + +`git log --all -- tests/mutation_tests_fire_control*` returns exactly one commit, +`034e8d3e`, adding `cut4` alone. §6 of this map declares `cut1..4`. So the Cut 3 +mutation table above (seeding, the commit horizon, the frozen snapshot, the +shield branch, the penetration march, the roll itself) is prose: it names the +mutations that should die and nothing has ever run them. + +That is not a paperwork gap. Soul wrote five mutations against rules this map +claims to pin and **all five survived 197 green tests**, including +`InstantWeapon.cs:105`, which is operator ruling Q2 — the one thing Cut 1 +explicitly deferred to Cut 3. Cut 5 pins four of them because it touches their +rules. This cut pins the fifth and builds the harnesses that should have existed +since Cut 1, so that every verdict this campaign has recorded becomes reproducible +rather than remembered. + +### 7.1 The fifth survivor: the reveal re-check + +`FireControl.cs:193` freezes `Aimed = source.ResolvedTargetItem`. Mutating it to +`source.TargetItem.Value` bypasses the reveal re-check — you could aim at a +subsystem you have lost resolution on — and 197 tests stayed green. + +- A test pins that an aim point whose reveal has decayed below its tier is **not** + frozen into the shot: the shot still fires, at a random cell, with no `Aimed`. + Mutation: read the raw `TargetItem.Value`. Must die. +- Note the interaction with Cut 5.5: a *destroyed* aimed item must fall out by the + same path, through `IsRevealed`, not through a second check. If pinning this + needs two tests, the rule has two owners and that is itself the finding. + +### 7.2 Harnesses for Cuts 1, 2 and 3 + +Three committed scripts — `tests/mutation_tests_fire_control_cut1.sh`, `cut2.sh`, +`cut3.sh` — each with a no-op control, byte-exact I/O, anchors matching exactly +once, and a reverse-write restore. Each runs the mutation table its cut already +declares in this document; do not invent a new table, and do not quietly +substitute an easier mutation for one that will not die. + +**Every survivor is a finding to report, never a test to weaken.** The expected +cause, seen twice in this campaign already, is a fixture too weak to distinguish +the mutant: `EvasionCountsUntilCommitAndNotAfter` survived its own declared +mutation because its target had zero velocity, so the sabotage was arithmetically +a no-op. When a mutation survives, first ask whether the fixture differs from the +rule's actual precondition, fix the fixture, and say so in the report. + +Where a declared mutation genuinely cannot be reached by the current suite, write +it up as **not yet reached**, never as unreachable, and name what reaching it +would take. Both times that call was made elsewhere in this project it was wrong, +and a fifteen-line probe killed the mutant. + +### 7.3 What the harnesses are allowed to assume + +Cut 6a authors the first targeting-system designs into the catalog. A harness that +depends on catalog content is a harness that breaks when content changes, so +these suites build their own fixtures through the same synthetic-upsert path +`TargetingSystemTests.cs:368-403` already uses. That file is also a cautionary +example: it passes by upserting a synthetic design into a throwaway cache, which +pins the generator and says nothing about whether the shipped catalog can +generate a ship. Both are worth having; only one of them was, and the map claimed +the other. + +So: **one test in this cut asserts against the live catalog** — that +`AetheriaStores.Open` over `GameData/Aetheria.cc` yields a targeting system the +player's own hull can be generated with. That is the assertion whose absence let +Cut 2 ship code without data. + +### Verification + +- All four harnesses (cut1-3 plus the existing cut4) run clean: every declared + mutation dies, every no-op control leaves the suite green. +- The report carries the full table — cut, mutation, died/survived, and for any + survivor the fixture defect behind it. +- `dotnet test` green, with the count. + +--- + +## Cut 6d: where a hit lands is a dart throw, not a coin flip + +Date: 2026-09-19. Operator direction, 2026-09-19. + +### The defect + +`FireControl.Commit` places a hit like this: + +```csharp +if (aimedCells != null && aimedCells.Length > 0 && random.NextFloat() < shot.Precision) + cell = aimedCells[random.NextInt(aimedCells.Length)]; +else + cell = coords[random.NextInt(coords.Length)]; +``` + +`Precision` is a coin flip between *exactly on the aimed item* and *uniformly +anywhere on the hull*. Aim at a stern thruster with Precision `.35` and the 65% +of hits that miss it are spread evenly over the bow, the cockpit and everything +else — no nearer the thruster than if the player had never aimed at all. There is +no spatial term anywhere in it. + +This is the same shape as 5.1's Tracking cliff: a quantity that should be a +gradient implemented as a binary, so the failure case is maximally wrong rather +than slightly wrong. + +Operator, 2026-09-19: "I would presume that aiming for an item in the stern would +still tend hit in that general area even if it misses the item. Like throwing a +dart at the grid." + +### The rule + +One Gaussian kernel over the target's hull schematic, in cell units: + +- **Aim point.** The centroid of the aimed item's cells, or the hull's + `CenterOfMass` when nothing is aimed at. One path — the uniform-random branch + is deleted, not demoted. +- **Sigma.** Derived from the frozen `shot.Precision`, higher Precision giving a + tighter group. Authored so that the unaided floor (`UnaidedPrecision`, joining + `UnaidedAccuracy` and `UnaidedTracking` in `GameplaySettings`) is a sigma wide + enough to read as spraying at the silhouette. +- **Weights.** `w(cell) = exp(-d² / 2σ²)` for each occupied hull cell, `d` the + distance from the aim point. + +### The tradeoff, and why it cannot be a second miss stage + +Operator, 2026-09-19: "It would also mean that aiming can be a tradeoff; if +you're not precise enough, you're better off aiming center mass than risking +misses on a thin limb." + +That tradeoff only exists if a stray dart can miss the ship. An earlier draft of +this section clamped a stray to the nearest occupied cell, which destroys it: +aiming at a wingtip would cost nothing and merely redistribute damage inward. + +But resolving the stray as a *second* miss stage would break R3 — one roll +decides damage — and make `HitProbability` a lie, since a shot could pass the +roll and then fizzle on placement. So the off-hull mass is priced into the single +roll instead: + +``` +pOnHull = Σ w(cell) / (2πσ²) +``` + +the share of the kernel's total mass that lands on metal. + +- `HitProbability` gains `pOnHull` as a factor. Aiming at a thin limb with a + loose group drops the number **on the HUD, before the trigger** — the tradeoff + is legible at decision time rather than discovered afterwards. +- `Commit` draws the cell as a weighted pick over the same `w(cell)`. A hit + always lands on metal, because the chance of not landing on metal was already + charged at the roll. + +One kernel, computed by one function, used for both the probability and the +placement. Cut 3's named risk was two functions answering "where will this shot +go" and eventually disagreeing; this does not reintroduce it. + +`pOnHull` depends only on the aim point, the hull shape and the frozen +`Precision`, so it folds into `PBase` at fire time and the freeze discipline +(R10, Q6) is unchanged. + +### Consequences, named rather than discovered + +- **Every hit in the game moves**, not only aimed ones: unaimed fire becomes + "aim at the centre of mass with the worst sigma", so big central sections soak + more fire than extremities. That is the intended change, and it is a balance + change. +- **A fat hull is inherently easier to hit than a needle**, for free. +- **The two targeting products' authored `Precision` values (`.05-.2` and + `.35-.55`) become meaningless as written**, because they are probabilities and + this makes Precision a grouping tightness. They are re-authored in the same + pass as Cut 6c's reciprocal `Resolution`. +- **Double-charging risk.** `HitProbability` already carries `pSpread`, which + penalises a small target against a wide weapon spread. `pOnHull` also + penalises small targets. Check whether the two charge the same thing twice + before authoring numbers; if they do, one of them owns it and the other goes. +- **AI willingness shifts.** `Combat.cs` and `TurretController.cs` gate on + `AgentMinHitProbability` against this number, so NPCs will hold fire more than + they used to the moment `pOnHull` lands. That is a tuning pass, not a bug, but + it will show up in the first smoke and should not be mistaken for broken AI. + +### Verification + +- `AimingAtTheSternHitsTheStern`: with a mid-range Precision, hits aimed at a + stern item land in the stern half far more often than the bow half. Mutation: + restore the uniform-random branch. Must die. This is the assertion the shipped + coin flip would fail. +- `ThinLimbCostsHitChance`: `HitProbability` against an aim point on a + single-cell extremity is strictly lower than against the centre of mass, at the + same Precision — and the gap widens as Precision falls. Mutation: drop + `pOnHull` from the probability. Must die. +- `PlacementAndProbabilityShareOneKernel`: the empirical on-hull rate over many + seeded draws matches the `pOnHull` the probability reported, within tolerance. + Mutation: perturb sigma in one of the two call sites only. Must die — this is + the test that would catch the two functions drifting apart. +- `EveryHitLandsOnMetal`: no committed hit ever resolves to an unoccupied cell, + across seeds and aim points including extremities. + +### 7.4 Amendment (2026-09-19): the live-catalog test has a second job + +7.3 above asks for one test that opens the real `GameData/Aetheria.cc` through +`AetheriaStores.Open`, because Cut 2 shipped a `required: true` rule with no data +to satisfy it and every green suite in the campaign missed it. + +Merging Cuts 6b and 6c gave that test a second and larger job. 6b added +`WeaponItemData.AirburstRange` as a non-nullable `float` at a new MessagePack +key. Every weapon record in the shipped catalog was serialized before that key +existed, so the reader was handed `nil` for the absent slot, a `float` cannot +take nil, and `AetheriaStores.Open` threw outright — the game could not read its +own catalog. Fixed at `c896489b` by making the field `float?`. + +Neither cut could have caught it. 6b verified with unit tests over synthetic +fixtures and never opened the shipped catalog; 6c opened the catalog constantly +but on a branch without the field. The defect existed only in the merge, and +only when something read real data. + +So the live-catalog test is not a content check that happens to touch a file. It +is **the campaign's only assertion that the shipped data is readable by the +shipped code at all**, and it is the one test whose absence let a branch that +bricks the catalog pass every gate. Write it so it fails loudly on a +deserialization throw, not only on a missing design: + +- `ShippedCatalogOpensAndGeneratesAnArmedHull`: open the real catalog read-only + through `AetheriaStores.Open`, enumerate every `EquippableItemData` so every + document type is actually deserialized (a lazy read that never touches + `WeaponItemData` would have passed on the broken merge), and generate a + loadout for `LonginusX`. Any exception is a failure. +- Mutation: none needed for the throw path — the shipped catalog either + deserializes or it does not. For the generation half, the Cut 2 mutation + applies: drop the targeting designs' products and the test must go red. + +**The standing rule this cut records**, because it will recur every time a field +is added: a new non-nullable value-type field on a persisted CultCache document +cannot read records written before it existed. Nullable, or a migration that +rewrites every affected record — and the migration cannot run while the read +still throws, so tolerant deserialization comes first either way. + +--- + +## Cut 8: a removed entity is a dead entity, and the anchors the diagnostics moved + +Date: 2026-09-22. Branch `codex/fire-control-8` off `72c0109c`. + +Operator play smoke, 2026-09-20: heat spike on entry with the ship unable to +move, a nearby ship likewise glowing hot and motionless, shots that registered no +hits, and finally a NullReferenceException out of `LockWeapon` reaching +`Entity.EntityInfoGathered` as the other ship vanished. Diagnosis (Codex, relayed +by the operator): the other ship's behaviors updated after the entity had been +cleaned out. Not reproducible afterwards, which makes it a hidden bug rather than +a fixed one -- the diagnostics commit `72c0109c` changed timing and allocation, +not the cause. + +### 8.1 Removal and deactivation are one transition + +`Entity.Deactivate` (`Entity.cs:445`) disposes every subscription, clears +`EntityInfoGathered`, `VisibleEntities`, `VisibleEnemies` and `VisibleFriendlies`, +and sets `_active = false`. `Zone.TryDock` (`Entity.cs:943`) has always paired the +two: `Zone.Entities.Remove(ship); ship.Deactivate();`. Removal from the zone means +the entity stops living there. + +Cut 5.6 added the death path and did only half of it. `Zone.cs:78`: + + Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => Entities.Remove(add.Value))); + +The entity leaves the collection and stays **active**, holding live subscriptions +and stale references, while every surviving entity's `ObserveRemove` handler +(`Entity.cs:200-213`) nulls its `Target` and drops it from `EntityInfoGathered`, +`EntityHostility` and the visibility sets. Half the world has forgotten it and it +does not know it is dead. + +- The death subscription becomes `Entities.Remove(e); e.Deactivate();`, matching + `TryDock`'s existing pairing exactly. One transition, one owner. +- **Ordering risk to verify, not assume:** `EntityInstance` also subscribes to + `Entity.Death` (its loot drop and destroy effect, `EntityInstance.cs:300-325`). + Subscription order decides whether loot drops before or after `Deactivate`. + `Deactivate` does not touch `Equipment` or `CargoBays`, so loot should survive, + but this must be confirmed in play rather than reasoned about -- if loot stops + dropping, that is this cut's fault and the deactivate moves after it. + +### 8.2 A deactivated entity does not update + +`Zone.Update` (`Zone.cs:172`) iterates a snapshot: + + foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); + +so an entity removed part-way through a frame still receives its `Update` in that +same frame, after the rest of the world has dropped its references. `Entity.Update` +(`Entity.cs:1031`) never consults `_active`. Every behaviour then runs against +torn-down state, and `LockWeapon.cs:98` reaches `Entity.EntityInfoGathered[Entity.Target.Value]` +through a raw indexer. + +- `Entity.Update` returns immediately when `!_active`. + +This is the fix, and the raw indexer is deliberately **not** the fix. Guarding +`LockWeapon` with `TryGetValue` would silence this one call site and leave every +other behaviour running on a corpse -- the compensator the doctrine warns about. +Liveness is the entity's own property and the entity is the one place to enforce +it. `IsActive` already exists at `Entity.cs:116`; nothing was asking it. + +### 8.3 The other symptoms are not explained by this, and are not closed + +The heat spike, the motionless ships and the shots that registered no hits are +**unexplained**. One coherent story fits all three -- an item's heat driving the +targeting system offline makes `FireControl.Accuracy` fall back to +`UnaidedAccuracy` (`.05`), which reads exactly like "I can fire but nothing +lands," and an overheated thruster reads as "I cannot move" -- but that is a +hypothesis, not a finding, and nothing here has reproduced it. + +What can be said from the source: `ActionGameManager.cs:1281` passes +`Time.deltaTime` into `Zone.Update` unclamped, and `Zone.cs:160` divides by it +(`orbit.Value.Velocity = (Position - PreviousPosition) / deltaTime`). Unity caps +`Time.deltaTime` at `Time.maximumDeltaTime`, so a large spike is bounded, but a +**zero or near-zero** first frame is not bounded from below and that division is +unguarded. Worth a probe before it is worth a fix. + +This cut does not chase it. It records the symptom, fixes the defect that is +proven, and leaves the rest open with the evidence that exists -- an unreproducible +heat spike is not something to patch speculatively. + +### 8.4 Re-anchor the four harnesses the diagnostics refactor stranded + +`72c0109c` routed `HitProbability` through a new `FireControl.Inspect`, replacing +five early returns with flags and one gate. That is the campaign's own doctrine +applied correctly -- the operator's comment says it outright, that keeping the +calculation here stops the debug HUD growing a second, subtly different fire +control model -- and it rewrote the exact lines four harnesses anchor into. + +Seven anchors now fail: `cut3` (2), `cut5` (1, its **no-op control**), `cut6c` +(2, including its control), `cut6d` (2). Every one reports `tree-clean: PASS`, so +nothing leaked, but a harness whose control cannot run is defending nothing. + +- Re-anchor all seven onto the current spelling in `Inspect`. The rules they + attack are unchanged; only the text moved. +- A control that cannot find its anchor must stay a hard failure. Do not make + anchors fuzzy to survive refactors -- loudly stale is the property that makes + these harnesses trustworthy, and it is what surfaced this within one run. + +### Verification + +- `DeadEntityDoesNotUpdate`: an entity killed mid-frame receives no further + `Update`, and a `LockWeapon` on a ship whose target dies the same frame does not + throw. Mutation: drop the `_active` guard. Must die. +- `DeathDeactivates`: a dead entity reports `IsActive == false` and holds no live + subscriptions. Mutation: remove the `Deactivate()` call. Must die. +- `LootStillDropsOnDeath` if it can be reached headlessly; otherwise an explicit + operator check, named as such rather than assumed. +- All seven harnesses clean with tree-clean PASS, controls included. +- 222 tests plus the new ones; catalog reads; Unity batchmode exit 0. + +--- + +## Cut 9: the die is not a die + +Date: 2026-09-22. Branch `codex/fire-control-9` off `dcb8c448`. + +Soul pass over `ca57819d..dcb8c448` (2026-09-22). Ten findings; this cut takes the +three that block play. The rest are Cut 10. + +### 9.1 The hit roll has a range of 0.125 (Soul C1). **Blocking.** + +`FireControl.cs:356`: + + var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u); + ... + var hit = p > 0f && random.NextFloat() < p; + +`CultMath.Random` is xorshift32 and `NextFloat()` returns the top 24 bits of the +**first** round (`Random.cs:22-42`). One round does not carry a low-bit seed +difference into the high bits. `ShotId` varies only in the low bits, so the top +three bits of the first draw are fixed by the zone seed alone. + +Measured, 2000 shot ids per zone: **every zone's roll spans 0.1249**. Reproduced +independently with a different hash function — the span is structural, not a +property of `StableHash`. The consequence is that a shot is a step function of +`p` whose threshold is fixed when the zone is created: below the band it never +hits, above it always does. An unaided shooter hit 0 of 18,926 shots against the +shipped catalog. Roughly one zone in eight cannot land a fully aided shot at +p = .74. + +This is the operator's "I can target and fire with no hits registering at all," +and it is stable per zone, which is why it came and went. + +It is also this map's own prescription (Cut 6b, §6.1), written by Self, shipped +through three cuts, and never once measured. + +**The rule:** the roll must be uniform on [0,1). Not merely deterministic — +determinism is what a constant already gives, and is exactly why three +determinism tests passed over it. + +- Mix the seed before the first draw, with a standard 32-bit finalizer + (`fmix32`/`splitmix32`: xor-shift, multiply, xor-shift, multiply, xor-shift). + The per-shot seed derivation is unchanged; only its diffusion into the first + output changes. Determinism, the `(zone, shot id)` purity and the freeze + discipline all survive untouched. +- **This is a caller's obligation, not a CultMath defect.** Seeding xorshift with + a structured value and drawing once is the documented way to get a correlated + first output. Record a follow-up to give `CultMath.Random` a mixed-seed entry + point so no other GameCult consumer re-derives this the hard way — that is a + CultLib cut with its own release, not this one. + +**Verification, and the test whose absence caused this:** + +- `TheDieIsUniform`: fire many shots across several zone seeds at an + intermediate `p` (say .3 and .6) and assert the observed hit fraction is near + `p`, and that the observed rolls span substantially more than 0.2. Mutation: + remove the finalizer. Must die. +- No test anywhere in this campaign measures a distribution. Every behavioural + fixture uses `accuracy: 1` or `accuracy: 0`, which is why a die stuck on one + face satisfied all of them. That gap is the finding, not the line of code. + +### 9.2 `pOnHull` collapses to zero above Precision ~4 (Soul C2). **Blocking.** + +`FireControl.cs:497-516`. `Sigma = 1/Precision`, and once sigma falls below about +half a cell every `exp(-d²/2σ²)` underflows against an aim point that is not +exactly on a cell centre — which is **every shipped hull**, since no centre of +mass is integral (LonginusX 2.5/6.697, Zenith 5.5/5.5, Turret 3.5/3.5). Measured +`pOnHull` on LonginusX: `.516` at Precision .3, `1.000` at 1.19, `.112` at 5, +`.000` at 10 and beyond. + +So a better targeting system makes you miss, and past Precision ≈ 5 the weapon +cannot hit anything. Authored content sits at .5-1.3, just under the cliff, with +a margin of about 4x — and the operator is about to tune this number. + +- Clamp sigma at a floor of about half a cell, or normalize by the discrete sum + rather than the continuous 2πσ². Either makes `pOnHull` monotonic in Precision + over the whole domain instead of only below the cliff. +- `WeightedPick` (`:535-545`) carries a comment claiming it "can never fall + through." It can, whenever `totalWeight == 0`, returning a fixed corner cell. + It is unreachable today only because `pOnHull == 0` forces `PBase == 0` first. + Fix the cause and delete the false comment. +- Cut 6d's map section claims to name its consequences; it does not name this. + Add it. + +### 9.3 The LockWeapon crash is still reachable (Soul C3). **High.** + +Cut 8 fixed a real liveness defect and did **not** fix the operator's crash. +`LockWeapon.cs:98` only runs on an *active* entity, so 8.2's `!_active` guard +cannot be what prevents it — `tests/mutation_tests_fire_control_cut8.sh:17-19` +says as much outright, and I merged it anyway. + +The live precondition is `Target.Value` set but absent from `EntityInfoGathered` +on an active entity, and docking still reaches it: `Deactivate` (`Entity.cs:445`) +disposes the `Entities.ObserveRemove` subscription that is the only thing nulling +a stale `Target`, and nulls nothing itself; `Activate` (`Entity.cs:167`) reseeds +`EntityInfoGathered` and likewise leaves `Target` alone. Dock, let the target die +or leave, undock, and the first frame throws. + +- `Deactivate` nulls `Target` (and `TargetItem`), or `Activate` reconciles it + against the reseeded `EntityInfoGathered`. One of the two owns it; say which. +- Still not a `TryGetValue` in `LockWeapon`. The invariant is that an active + entity's `Target` is always a live entity it has info on. +- Pin it with the dock/die/undock sequence, not with a unit test on the guard. + +### Verification + +- The three tests above, each dying under its own mutation, in a committed + `tests/mutation_tests_fire_control_cut9.sh` with a no-op control, byte-exact + I/O, sha256-verified restore and an explicit tree-clean verdict. +- All nine harnesses sequentially, one at a time, all clean. +- 226 tests plus the new ones; catalog reads; `dangling` 0. +- After 9.1 lands, re-measure the fight: Soul's probe, run with a corrected die, + gave 60.8% hit rate and a 4.3 s median time to kill for an aided shooter, 2.6% + unaided. Confirm the shipped build now reproduces that, because those are the + numbers the operator will tune against. + +### Recorded for Cut 10, not fixed here + +Soul C4 (a committed hit on a target that dies mid-flight publishes as a hit), +C5 (`Inspect` is numerically exact but costs 9.87 µs and 448 B per call on paths +that used to bail — the operator's own comment on the hot path looking like a +diagnostic one has an empirical answer now), C6 (`RollsAreSeeded` is obsolete — +Cut 7 retired one such test and missed this one), C7 (`FireControlCut6cTests` +authors `Precision = 0`, making six of seven checks vacuous), C8 (`Precision = +1000` fixtures make several kernel-shape mutants unkillable, and `pOnHull`'s +deletion is caught by exactly one test), C9 (`EveryHitLandsOnMetal` runs one +configuration, not the range it claims), plus the harness-audit items: the +tree-clean verdict restores before it measures, a red result cannot distinguish a +killed mutant from a compile failure, and the no-op control covers only one file +per harness. + +--- + +## Cut 11: mutation testing moves to Stryker, and the survivors it found + +Date: 2026-09-22. Operator, on the hand-written anchor harnesses: "What is the +point of those mutation anchors? Tests should cover how the code behaves, not how +it is shaped." And on Stryker: "sounds good; we don't need to run with every +mutation active, right? > to >= on float thresholds is indeed petty." + +### Why + +The Eureka principle (2026-09-15) is sound and stays: every rule needs a test that +fails when the rule breaks. The mechanism that accreted on it over the following +week -- literal-text anchors, perl substitution, per-cut bash and Python harnesses +-- coupled mutation testing to code shape. It stranded anchors on every refactor +(`72c0109c`, Cut 9, Cut 10), and at Cut 10 an anchor kept matching exactly once +while silently re-targeting onto `Inspect`, mutating code the tests no longer ran. +Soul's harness audit also found the tree-clean verdict restoring before it +measured, and a compile failure counting as a kill. + +A Stryker spike against `FireControl.cs` alone: 276 mutants, 7m18s, 208 killed, +67 survived. It killed every mutation the hand-written harnesses defended, never +edits a source file, and has nothing to re-anchor. + +### 11.1 Retire the ServerShared harnesses + +Delete the 23 hand-rolled harnesses (5,816 lines) whose targets are all under +`Assets/Scripts/ServerShared` -- every `mutation_tests_fire_control_*`, every +`mutation_tests_stats_power_*`, `mutation_tests_shield_reserve.py`, +`mutation_tests_condition_ratio_cut8.py` and `mutation_tests.py`. Stryker's scope +covers all of their targets; the behavioural tests they measured stay. + +**Kept, recorded, not yet answered:** `mutation_tests_addressables_cut.py` and the +three `mutation_tests_shield_panel_cut*.py`. They target Unity Editor probes +(`EngineAssetCheck.cs`, `ShieldPanelCut*Verify.cs`) that Unity compiles outside +`Aetheria.Shared`, so Stryker cannot reach them. They carry the same anchor +fragility; the right answer for GPU and editor code is probably that the batchmode +probes are themselves the behavioural tests, but that is a separate decision. + +### 11.2 Scope, pinned + +`tests/Aetheria.Shared.Tests/stryker-config.json` scopes mutation to +`Assets/Scripts/ServerShared/**` and excludes plugins. Per cut, run against the +cut's own diff: + + dotnet tool restore + cd tests/Aetheria.Shared.Tests + CULTLIB_ROOT= CULTMATH_ROOT= dotnet stryker --since: + +Each root must sit at its pinned revision in `Directory.Build.props` (see the Cut 12.0 +status note). + +**Float-threshold boundary flips are known-equivalent.** Stryker files `>`→`>=` +under the same Equality mutator as `==`→`!=`, and the latter catches real bugs, so +the mutator stays on. A survivor that only moves a float comparison across its +boundary is triaged as equivalent and never chased. For the same reason the raw +mutation score is not a gate; the survivor list is the signal. + +### 11.3 Degenerate fixtures + +Most of the 67 survivors trace to three constants shared across the fire-control +fixtures, each of which makes a family of mutants arithmetically identical to the +original -- the same defect class as the zero-velocity target and the durability +coincidence, found here as a pattern instead of one at a time: + +- **Shooter at `float3.zero`.** `target - source` equals `target + source`, so four + separate position mutations survive (`FireControl.cs` HitProbability, Inspect, + Fire, Apply). +- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time + mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does + not defend the rule Cut 5 gave it a moving target to defend. +- **Spread zero everywhere.** `PSpread` returns 1 on every fixture, so it is + effectively untested, and `*` versus `/` against it is indistinguishable. + +Move shooters off the origin, fire at nonzero times, and give some fixtures real +spread. Fix the fixture, never weaken the assertion. + +### 11.4 Behavioural tests the survivors point at + +- **Splash handedness.** Flipping the sign in `var right = float2(forward.y, + -forward.x)` passes every test. The operator's "damage on the wrong side of the + ship" was a legacy Unity bug; this line has read the same since `482c47bf`, so + the rewrite never shipped it, but nothing defended against it either. Self said on 2026-09-19 that Cut 6b would add a test pinning + that a port blast damages port cells; it never landed and nothing checked. Pin it. +- **The shield loses energy when it absorbs.** Deleting `Shield.TakeHit` from the + discrete path survives, because tests assert the hull was untouched and never + that the shield paid for it. +- **Splash's shield-absorb branch** has no coverage at all. +- **Zones roll differently.** `CombatSeed *` → `/` makes the seed zone-independent + and survives; `TheDieIsUniform` checks uniformity within a zone only. Tighten it + enough that the `MixSeed` weakenings that survive it (`>>`→`<<`, `^=`→`|=`) die. +- **A shot whose target left the zone resolves as a miss** (Soul C4). Today only + the uncommitted branch rewrites the outcome; a committed hit on a target that + died mid-flight is published as a hit. Fix the code and pin it. + +### Verification + +Stryker over `FireControl.cs` before and after: every non-equivalent survivor +above killed, the remainder triaged by name. 231 tests plus the new ones; Unity +batchmode clean. + +### Status (2026-09-22) + +Landed on `codex/fire-control-10`: `8ef3b34c` (Stryker tool), `b305debd` (map, +config), `724ec5e7` (11.1), `a6543af7` (11.4 and the C4 fix), `1b187db4` (11.3), +`b0fd3cd0` (second round, below). 244 tests. + +Stryker over `FireControl.cs`, 278 mutants per run: + +| Run | Killed | Survived | Score | +|---|---|---|---| +| Spike, before Cut 11 | 208 | 67 | 74.6% | +| After 11.4 | 219 | 58 | 78.6% | +| After 11.3 fixtures | 225 | 51 | 81.1% | +| After the second round | 225 | 43 | 83.3% | + +**The second round** came from reading the 51. Four were real undefended +behaviour and one was a duplicated formula: + +- `Inspect` recomputed `PBase` as its own product of the factors, so the gates and + the product had two owners, and a `*`→`/` mutant of the HUD's copy survived the + agreement test (every fixture had spread 0, so `PSpread` was 1). `PBase` is now + `HitProbability`'s own answer; the test became + `TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not + fill the silhouette. `Fire`'s redundant `target != null ?` guard around + `HitProbability` went with it. +- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed + the expected damage from its own roll count, a tautology, now deleted. + `ADamageOverrideIsWhatTheShotCarries`. +- `Tracking` could ignore the fitted targeting system: no fixture's target ever + moved off its predicted line. `AFittedTargetingSystemForgivesAJink`. +- A shot orphaned before commit could skip `ShotCommitted`, or publish its miss as + shielded. `ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss`. +- `ArrivalIn` (the HUD countdown) was unchecked; asserted in the C4 test. + +**The 43 that remain, triaged:** + +- *Boundary flips, equivalent (23):* L52, 54, 56, 75, 135 (two), 157 (two), 193 + (two), 223, 273, 410, 418 (two), 465 (two), 503, 518, 524, 586, 605, 608. +- *`MixSeed` shift and xor weakenings, measured equivalent (4):* L382, 384, 386 + (two). 11.4 asked for them to die; measured, the die stays uniform and + zone-distinct under each, so there is no behaviour to pin. They change how the + mixer is built, not whether the die is fair. +- *Unreachable through the live callers (8):* L240 and L241 + (`DeviationProbability`'s null-target branch; the engine guards it, only the HUD + can pass null), L283 (three; airburst has no authored radius anywhere, recorded + since Cut 6b), L320 (empty-list early return, an optimisation), L410 `||` (a + nonzero `PBase` implies a target), L158 and L610 (no coverage: an unlocked lock + weapon reaching this line, and the last-cell fallback of the weighted pick). +- *Recorded gaps, not yet reached (8):* L135 ternary (two; no test that the AI in + `Combat.cs` leads a moving target), L435 (three; the aimed flag on an outcome), + L464 and L465 negate (the `Apply` hit direction through the `FireControl` + path), L585 and L587 (the aimed item's centroid as aim point). These are "not + yet reached," not "unreachable." + +## Cut 12: shots arrive edge-on + +Date: 2026-09-22. Imagination pass, revised the same day after the second round of rulings +below. Anchors are against `origin/codex/item-provenance` `cd846916` (Cuts 10 and 11 merged). +Claims marked **(probe)** were measured in a scratch copy of the test project +(`scratchpad/cut12probe`, the Cut 11 fixture plus probe methods) or by a lane-model script +(`scratchpad/exposure.py`), in both cases against a copy of `GameData/Aetheria.cc`. Claims +marked **(read)** come from source at the cited anchor. + +### The defect + +The impact cell is chosen as if every shot drops in from overhead. +`FireControl.HullKernel` (`FireControl.cs:558-578`) throws a 2D Gaussian at the top-down +schematic, centred on the aim point. `POnHull` (`:233-234`), `WeightedPick` (`:601-611`) and +`ResolveAimPoint` (`:583-589`) all read that kernel, and none of them takes the shooter's +bearing. **(probe)** `Probe3_BearingBlind`: pOnHull is `0.8748` at target facings (0,1), +(1,0) and (.6,.8) alike. + +Bearing is only used after the cell has been chosen: `Entity.ApplyHit` (`Entity.cs:381-409`) +walks penetration from that cell, and `Splash` (`FireControl.cs:515-527`) picks the half of +the hull that faces the blast. So armour facing does nothing. A wedge whose bow carries all +the plate gets hit in its soft stern from dead ahead. The game is planar (R7), so a shot +travels in the plane and should arrive edge-on. + +The damage rule makes it worse. `Entity.DamageSchematic` (`Entity.cs:336-374`) splits the +damage evenly over every cell of the hit shape. **(probe)** `Probe1`: 30 damage with +penetration 3 into column 1 of the 5x3 fixture, with 10 armour on the front cell only, puts +10 on each of the three path cells. The plate absorbs its 10, the item behind it still takes +10, and the stern cell passes 10 to the hull. The item receives exactly what it would have +with no plate in front of it. + +### Rulings (operator, 2026-09-22) + +First round: + +- **Model adopted: throw the dart at the silhouette the shooter sees.** Scatter is 1D and + perpendicular to the bearing. Project the hull's occupied cells onto the lateral axis and + throw a 1D Gaussian at that projection, with the same sigma rule, centred on the aim + point's projection. pOnHull is the Gaussian's mass on that shadow. Depth comes from + geometry: walk the ray in from outside along the bearing, and the first occupied cell is + the impact cell. Penetration continues along the same ray. +- Operator: "No, you're right, this is fine. Armor absorbs first. Shields remain + omnidirectional. Map it." + - **Armor absorbs first.** Damage runs down the ray in order. At each cell the armour + absorbs first, then the item, and only the remainder travels on. The even split over + the hit shape goes, for the penetration path and the DamageSpread expansion alike. + - **Shields remain omnidirectional.** The shield stays one whole-ship reserve with no + facings. +- **Rejected:** any fraction of shots arriving top-down to preserve cockpit sniping. The + operator accepted that cockpit shots become positional: the cockpit must be exposed from + your bearing, or you must flank, or penetration must reach it with what the armour did not + absorb. Cockpit destruction is still instant death (`Entity.cs:494`). + +Second round: + +- **Q12-1 = B: the bearing that places a hit is taken at Commit.** Operator: "Of course we + must use the target's facing at arrival, tanking a hit where you have armor is basic + maneuvering." After Self explained the commit horizon: "at commit." The target's facing + therefore stays live until Commit, as deviation already does. See **Bearing timing**. +- **Q12-2 = A: spread is width.** It becomes 2n+1 parallel lanes, and each lane is absorbed + from its own facing cell. Operator: "Spread is width makes sense for shotguns, but what + about explosive penetrators? Burrowing in before splashing radially would be a satisfying + way to damage vulnerable internals." That remark became sub-cut 12.4. + - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at + angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the + shared armour and mirror-image shots did different damage in ~30% of angled spread hits. + Operator: "I would prefer if the overlapping damage cells were diffused sideways to + thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): + **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` + width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly + that wide and half-open, so no two lanes can strike the same cell; the footprint widens at + angles instead of doubling up, and axis-aligned shots are unchanged. +- **Q12-3 = A (direct hits):** a lane's remainder goes into the hull wherever the lane + ends. +- **Q12-4 = A:** delete `ShotOutcome.Aimed`. +- **Penetration retune is a follow-up.** Operator: "Retuning penetration is a follow up." + See F12-2. Until it lands, interior cells are reached only by blasts. +- **The detonation primitive (12.4): a blast is an area, not a bundle of rays.** The first + draft of this section cast rays; the operator cut that. Operator, 2026-09-22: "sorry, why + are we doing blast rays instead of areas? I'd say distribute the damage over the affected + area, but that only works for areas". So a blast at a schematic point P with radius r + covers the cells within r of P, and **damage is distributed over the disc's area**: a cell + takes the share of the disc that lies inside it. The share of the disc that falls outside + the hull's occupied cells is lost. Each covered cell absorbs independently, in the + existing order (armour, then item, then hull). Nothing travels, nothing is occluded, and + no remainder is routed anywhere. (Superseded for items by the 2026-09-25 one-path + ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) + + **A shot has a direction; a blast does not.** That sentence is the rule behind the split: + direct hits keep the lane model of 12.2 and 12.3, and blasts are areas. Where P sits: + - proximity fuse: the intercept point outside the hull (today's airburst); + - contact fuse: the impact cell; + - delayed fuse or penetrator: the end of the penetration walk. + + All three are weapons that carry a blast radius. A weapon without one is not a blast at + any penetration: it keeps 12.3's lane (see **Where 12.3 and 12.4 meet**). + + 12.4 replaces `FireControl.Splash` and its half-hull footprint, which absorbs F12-1. +- **WeaponModifiers are labels, not behaviour.** Operator: "The AP modifier was merely + there as a label. Like the Incendiary modifier which doesn't actually determine that the + weapon does Thermal damage, but exists to mark weapons that do. Modifiers show up in + schematics as a shorthand for the weapon type." Detonation behaviour comes from weapon + data fields only. +- **Not ruled:** a HUD showing which target items are exposed from the current bearing + (Q12-5). + +### What the probes found + +- **Penetration does nothing in the shipped catalog.** **(probe)** The largest authored + Penetration is `0.25` (Autocannon, SRMM72, LRMM72). `ApplyHit` only walks when + `penetration > .5f` (`Entity.cs:392`), and `WeaponData.Penetration` is inspector-ranged + `0..1` (`Behaviors/Weapon.cs:28`). +- **LonginusX's cockpit cannot be reached edge-on until penetration reaches about two + cells.** **(probe)** `exposure.py` walked lanes 0.01 cells apart at every whole degree of + bearing over the real 6x17 schematic. The ControlModule (cockpit) and the AetherDrive are + never the first cell in any lane. They are still never reached at penetration 0.25 or + 1.0, and are first reached at 2.0. The Turret hull's ControlModule is enclosed too. + Until F12-2 lands, the only way to kill a LonginusX through its cockpit is a blast + delivered inside the hull (12.4). +- **DamageSpread is live content.** **(probe)** GT 3K authors 1-3.25, plight 1-3.3, and + pswarm and scorched void policy 0.9-1.5. `Shape.Expand` is an 8-neighbour ring + (`ItemData.cs:164-174`), so spread 3 produces a 7x7 footprint today. +- **No shipped weapon carries a blast, or the Airburst label.** **(probe)** `Probe4`: + every `WeaponItemData.AirburstRange` is null, and no `WeaponModifiers` includes Airburst. + The labels in use are RapidFire, Cluster and Incendiary. All three Thermal weapons carry + Incendiary, and no non-Thermal weapon does. No weapon carries ArmorPenetrating, + including the three with penetration 0.25. So the only live consequence of demoting the + Airburst label is the code path at `FireControl.cs:282`. +- **The handedness history.** `right = float2(forward.y, -forward.x)` has not changed since + `b7743789`/`482c47bf` (`git log -S`). No sign error ever shipped. What survived was the + *mutant* `-forward.x` → `forward.x`, which is invisible when |fy| > |fx|, and Cut 11 pinned + it. The formula is duplicated at `Entity.cs:394-396` and `FireControl.cs:516-520`, and 12.1 + collapses the two copies. +- **The code has two cell conventions.** The kernel, `Shape.CenterOfMass` + (`ItemData.cs:121-123`) and `ResolveAimPoint` put cell *i* at the integer point *i*. + `ApplyHit` instead starts from `cell + .5` and truncates (`Entity.cs:398-404`), which + treats cell *i* as the square [i, i+1). **(probe)** On bearing (.8,-.6) from (1,2), the + 0.5-step march went (1,2)→(2,1) and skipped (2,2), a cell the ray actually crosses. +- **Nothing reads `ShotOutcome.Aimed`.** `rg "\.Aimed\b"` finds only `FireControl.cs:292` + (PendingShot, which stays), `:432`, `:435` and `:481`, plus `FireControlCut7Tests.cs:190` + (which also reads PendingShot). +- **`Apply` reads live positions at arrival** (`FireControl.cs:463-465`), which is an R4 + leak and Stryker's L464/L465. 12.2 removes it. +- **No simulation code places the schematic in the world.** **(read)** `SchematicCellSize` + (`Settings.cs:255`, default 2) is read only by `PSpread`, and ApplyHit and Splash convert + directions only. A blast needs world↔schematic *points*, so 12.4 names that rule. + `HullCollider` is a physics surface only (`Gameplay/HullCollider.cs:4-8`). +- **Renaming slot 29 is compatible drift, not a break.** **(probe + read)** The catalog + embeds each type's schema descriptor, including `{"slot":29,"name":"AirburstRange", + "type":"System.Nullable"}`, and the wire format is positional + (`Probe5_Wire`: `[…,12.5,50,20]`, with no field names on the wire). On a content-hash + mismatch, `CultCache.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` + (`:672-752`), which compares slot type, reference, cardinality, target schema, name + semantics and index alias, **not the member name**. So renaming slot 29 opens as + `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` + warning. The warnings persist until the catalog is next written with the new descriptor. + Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, + 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs + (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: + no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as + `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. + +### Frame, geometry and the bearing (the rules every sub-cut reads) + +- **Schematic frame, one owner.** `Entity.ToSchematic(float2 worldPlanar) → float2` maps + world to schematic with x = starboard and y = bow: `forward = normalize(Direction)`, + `right = float2(forward.y, -forward.x)`, returning `float2(dot(v, right), dot(v, forward))`. + This is the transform ApplyHit and Splash already use, with the same sign. The Cut 11 + fixture comment and the LonginusX schematic (nose at y 11-16, reactor and radiators at + y 0-3) agree with it. 12.4 adds the point forms `ToSchematicPoint(float2 worldPlanar) = + ToSchematic(worldPlanar - Position.xz) / SchematicCellSize + Shape.CenterOfMass` and its + inverse `ToWorldPoint`, so the entity's position is the schematic's centre of mass. + Operator confirmed 2026-09-22, from the schematic images: "bow is indeed +y on the + schematic images." Still on the operator's check list after 12.4 lands, because it is the + half that only a running blast can show: that blasts land where the model shows them, + which is what confirms the centre-of-mass anchor. +- **A cell is the unit square centred on its integer coordinate.** This matches + `CenterOfMass`, the aim point and the old kernel. The `+ .5` convention dies with ApplyHit. +- **Travel direction.** `FireControl.TravelDirection(Weapon, Entity source, Entity target) → + float2` is the planar world direction of `PredictedIntercept(weapon, source, target) - + source.Position`. That intercept is Cut 3's one function, and for a beam it reduces to the + target position. Below a length of 1e-6 it falls back to world +z, which is today's + `Apply` fallback (`:463`). It belongs to the shot: Fire freezes it into `PendingShot`. +- **Bearing.** `bearing = normalize(target.ToSchematic(travelDirection))`. This is the only + place a world direction meets a target's facing. +- **Lateral axis** `ℓ = float2(-b.y, b.x)`. The sign is fixed once, here. +- **Shadow.** Each occupied cell *c* projects to `[dot(c,ℓ) - h, dot(c,ℓ) + h)`, with + `h = (|ℓ.x| + |ℓ.y|) / 2`. The shadow is the sorted and merged union of those intervals; + merging matters because off-axis intervals overlap. Squares are used rather than points + because a line at lateral offset *s* crosses square *c* exactly when *s* falls in *c*'s + interval. "s lies in the shadow" is therefore the same event as "this lane has metal", so + pOnHull is exactly that event's probability, and placement drawn from the shadow always + lands on metal. +- **`Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float + precision)`** replaces `HullKernel` and is the one function. It returns the merged + intervals, `a = dot(aimPoint, ℓ)`, sigma, `Span = hi_max - lo_min`, and + `POnHull = Σ_k Φ((hi_k - a)/σ) - Φ((lo_k - a)/σ)`, where `Φ(z) = ½(1 + erf(z/√2))`. + `aimPoint` comes from `ResolveAimPoint` unchanged; only its lateral projection matters. + `Sigma` keeps its rule (`FireControl.cs:549-550`). The 9.2 rationale in its comment is + gone, because an exact 1D integral does not undershoot, so rewrite it: the floor is now a + design minimum on group tightness (F12-3). The function allocates only on the ungated + path: one interval array of length N (≤128 on shipped hulls) plus a sort. +- **PSpread** keeps its formula, but takes values instead of a `Weapon`: + `PSpread(float spread, float span, float range, float cellSize)`. The half-extent becomes + `.5f * span * cellSize` instead of `max(Width, Height)` (`:224`). PSpread and pOnHull read + one silhouette, so they cannot disagree about the target's size. +- **Lateral draw.** One `NextFloat` u, taken after the hit roll (the draw `WeightedPick` used + to take). Walk the cumulative interval mass to `u · POnHull` to select interval k, then + invert inside it with `s = a + σ√2 · erfinv(2p - 1)`. Clamp into [lo_k, hi_k) to absorb + rounding. The clamp is a guard, not an invariant. +- **Lane walk** `Lane(HullData hull, float2 b, float s, buffer)` collects the occupied cells + whose interval contains s, each with slab entry and exit parameters `t` along b, where + `t = dot(point, b)`. Cells are ordered by entry, with ties broken by `dot(c, b)` and then + cell index. It is exact slab traversal and replaces the 0.5-step sampling. The result goes + into a caller-supplied pooled buffer (`ArrayPool.Shared`; netstandard2.1 has + `System.Buffers`). A direct hit starts from t → -∞ (outside), so its first element is the + impact cell. It is the walker's only caller: blasts are areas and do not traverse. + + Metal is continuous while `next.entry ≤ current.exit + 1e-4`, and the first gap ends the + walk. That keeps today's rule at `Entity.cs:401`. +- **Penetration along a direct-hit lane.** A cell is reached when + `entry - impact.entry < penetration`, in cells. The impact cell is always reached. The + `> .5f` threshold (`Entity.cs:392`) is deleted. + +**Bearing timing (Q12-1 = B).** A shot is split into what the shooter decided and what the +target is doing: + +- **Frozen at Fire:** everything the shooter decided (R10). `PendingShot.PBase` is renamed + `PFire` because its meaning changes: it is `Accuracy × PSensor` behind the same gates, and + it is 0 when any gate fails. Also frozen: `TravelDirection`, `Precision`, `Aimed`, the + weapon's `Spread`, and `FireRange` (the range at Fire, which PSpread reads). +- **Live until Commit:** everything the target is doing. That was already its position + (deviation); now it is also its facing. `Commit` computes, in this order: + 1. `pDeviation`, as today. + 2. `bearing = normalize(shot.Target.ToSchematic(shot.TravelDirection))`, from the + target's facing at the commit tick. + 3. `sil = Silhouette(target, hull, shot.Aimed, bearing, shot.Precision)`. + 4. `p = PFire × pDeviation × PSpread(shot.Spread, sil.Span, shot.FireRange, cellSize) × + sil.POnHull`. + 5. The one roll against p. On a hit, the lateral draw from `sil`, and the impact cell + from `Lane`. + + Steps 2-5 are one function, `FireControl.CommitProbability(PendingShot, float now, + out Silhouette)`. The debug HUD calls it, which retires the inline estimate at + `ActionGameManager.cs:1317-1318` (`shot.PBase * pDeviation`, which would otherwise be a + second, wrong copy of the model). +- **The outcome freezes the committed geometry** (R4). `ShotOutcome` gains `float2 Bearing` + (schematic frame, as committed) and `float Lateral`, and keeps `Cell`. `Apply` walks + `Lane(hull, outcome.Bearing, outcome.Lateral)`. After Commit, turning the target changes + nothing, and Apply reads no position or facing. +- **HitProbability and Inspect** report `PFire × PSpread × pOnHull` at the *current* + travel direction and facing. That is a forecast, in the same way the HUD already treats + deviation. `Inspect.PBase == HitProbability` still holds, because both are the forecast. + The hot path still gates first (`:150-159`) and builds the silhouette only after the gates. + +What this does to the earlier rules: + +- **R3 (one roll, and a hit always lands on metal) still holds.** The factor the roll is + charged with, pOnHull at the commit bearing, comes from the same `sil` that placement + draws from, in the same call. Placement is exactly consistent with the pOnHull applied, + and it always lands on metal. What changes is that HitProbability's number is no longer + the exact roll price when the target turns during flight. It is the price only if the + target holds its facing, just as it already is only if the target holds its course. +- **Cut 5's deviation factor keeps its ordering and meaning.** It is still measured at the + commit tick against the fire-time projection, and it is still multiplied into p before + the single roll. The two silhouette factors join it in the same product. The Cut 7 guard + (`p > 0f && shot.Target != null`, `:410`) extends to cover them: when `PFire × pDeviation` + is 0, Commit builds no silhouette. Multiplication order has no numerical effect. The guard + exists only to skip work. +- **6d's premise ("pOnHull depends only on the aim point, the hull shape and the frozen + Precision, so it folds into PBase at fire time") is retired.** pOnHull now depends on the + target's facing, which is live target state like position, so it is priced at Commit. + R10 is untouched: every *shooter* stat is still frozen at Fire, and the target's facing + was never a shooter stat. +- **Cut 10 still holds:** gated HitProbability allocates nothing. Commit is not on the + hot path. + +### Sub-cut order + +| Sub-cut | Repo | Nature | Depends on | +|---|---|---|---| +| 12.0 | CultLib | CultMath gains `erf` and `erfinv` (a gap filled in its owner) | none | +| 12.1 | Aetheria | subtraction and frame owner; no behaviour change | none | +| 12.2 | Aetheria | where a hit lands: silhouette at Commit, Apply stops reading positions | 12.0, 12.1 | +| 12.3 | Aetheria | how a direct hit travels: exact lane walk, sequential absorption, spread lanes | 12.2 | +| 12.4 | Aetheria | the detonation primitive (areas), fuse data, the Airburst label demoted; Splash deleted | 12.3 | + +The five are kept apart so that Soul falsifies each question on its own: where a shot +lands, what a direct hit does, and what a blast does. In 12.2, `ApplyHit` survives for one +more cut with a changed signature: it takes the schematic bearing and loses its own +rotation, while its march and its even split are left untouched until 12.3. + +--- + +### Cut 12.0. CultMath gains `erf` and `erfinv` + +- **Repo/branch:** `F:\Projects\CultLib`, off current `main`. +- **Adds:** `math.erf(float)` and `math.erfinv(float)` in + `packages/cultmath/src/CultMath/math.cs`, as standard single-precision approximations (for + example Abramowitz-Stegun 7.1.26 or a minimax fit; Giles 2010 for erfinv), with tests in + `packages/cultmath/tests/CultMath.Tests`. Neither is an HLSL intrinsic, so there is no + shader-parity obligation. +- **Release:** run `packages/cultmath/scripts/build-unity-package.ps1`, bump + `unity/org.gamecult.cultmath/package.json` from 0.2.3 to 0.2.4, and tag + `cultmath-unity-v0.2.4`. Aetheria then bumps `Packages/manifest.json:56` and the headless + `CultLibRoot` pin. +- **Verification:** + - `ErfMatchesReferenceValues`: erf at 0, ±0.5, ±1, ±2 and ±3 matches tabulated values + within 2e-6; erf is odd; the limits are ±1. + - `ErfinvInvertsErf`: over y ∈ [-0.999, 0.999]. + - The CultLib mutation run, with every survivor triaged by name. + +**Status: the code closes, the release does not.** Landed on CultLib +`hands/cultmath-erf`: `87b95ae`, `b688cb2` (erf, erfinv, tests), then the fix batch +`fc9ce97`, `4cdc02b`, `6e8362b`, `cc71f17`. 102 tests. + +Two verification lines above were stale when written and are corrected here. CultLib had +no mutation tooling at all, so there was no "CultLib mutation run"; Stryker.NET was adopted +in its own cut (`hands/adopt-stryker`) and its CultMath baseline is 2243 mutants, 350 +survived, triaged in that cut's report. The headless pin needed more than a bump. `Directory.Build.props:5` pins one CultLib revision +for every package, and that revision (`45c2f40`) is exactly Unity's `caching-unity-v1.4.0` / +`cultlib-unity-v1.0.60`; moving it to the release would have put six CultNet commits of newer +CultCache, including a breaking registration change, under the headless tests while Unity +stayed on 1.4.0. So the headless build now pins per package, mirroring the manifest: +`CultMathRoot`/`CultMathRevision` at the 0.2.4 tag's merge (`6d5e2096`), `CultLibRoot`/ +`CultLibRevision` still at `45c2f40` for CultCache. A package's pin moves only when its +manifest tag moves. (Self first repeated Soul's claim that there was no pin, from a read of +lines 3-4; line 5 is the pin, and the build refused the first attempt to use the release.) The +Unity manifest lives at `packages/cultmath/unity/org.gamecult.cultmath/package.json`, not +at the repo root. + +Soul's first pass found the defect this cut would otherwise have shipped: `erfinv(±1)` +returned the **wrong-signed** infinity, so a sample drawn for the far edge of an interval +was clamped onto the near edge. Reachable in ordinary play — `erf` saturates to exactly 1 +at 5.543σ, so any interval edge past that yields `p = 1`. Fixed with two guards; outside +the domain is NaN, now documented and pinned. Soul also measured the reported accuracy +figures 1.5× optimistic (true worst error: erf 6.621e-7, erfinv 5.066e-7, round trip +6.109e-7, all inside the 2e-6 bar), found `erfinv` had no accuracy test at all (the round +trip was blind by 300-400×, proven with a coefficient mutant), and found +`ErfinvIsMonotonicallyIncreasing` asserting a property neither function has (9662 backward +steps in `erfinv` at float granularity; the fixture's 0.001 step hid every one). The second +pass re-derived all 19 new reference values by a different method and closed the cut. + +**Cost, for 12.2 to answer rather than assume:** `erf` is 16.3 ns per call and +allocation-free, but a silhouette costs one `erf` per interval edge — ~4.2 µs per +evaluation on a 128-interval hull, 1.06 µs on 64, and worse under IL2CPP. The AI evaluates +`HitProbability` constantly. 12.2 must measure the new placement against the old kernel per +gated-in evaluation and report both, not assume 1D beats 2D. + +**Released 2026-09-24:** CultLib `20e6da5` (release) and `8c86bc9` (the changelog meta), merged to +main at `6d5e2096`, tagged `cultmath-unity-v0.2.4`. Release Soul pass: the DLL rebuilt byte- +identical from source (sha256 `2470cd48…`), public surface +2 (`erf`, `erfinv`), shipped +`erfinv(±1)` correctly signed; its one finding, `CHANGELOG.md` without a Unity `.meta`, was fixed +before tagging. Aetheria repinned in `2a68b4c0` (manifest and Unity-written lock, hash +`6d5e2096`); batchmode resolve and compile clean, no missing-meta warning. Follow-up for CultLib: +`build-unity-package.ps1:89-90` checks metas only for code and binary files, so it could not see +the missing `.md` meta. + +**Was owed (kept for the record):** Unity +consumes a *precompiled* `CultMath.dll` pinned at `cultmath-unity-v0.2.3` +(`Packages/manifest.json:56`), so no Unity-side fire-control code can call `erf` until the +package is rebuilt, bumped to 0.2.4, tagged and repinned. The headless tests see the +sources directly, which is why nothing failed. 12.2 must not land in Unity before that. + +**Recorded, not done:** `erf`/`erfinv` are scalar-only while every other scalar in +`math.cs` ships float2/3/4 overloads. The consumer is scalar; vector overloads are a +separate decision. + +### Cut 12.1. One frame owner, and the dead outcome flag + +- **Repo/branch:** Aetheria, off the Cut 11 merge. No behaviour change. +- **Deletes first:** + - The two inline frame copies: + - `FireControl.cs:516-520` becomes `target.ToSchematic(toTarget)`, normalized, keeping + the zero-length fallback. The whole of Splash dies in 12.4, but the frame owner lands + first. + - `Entity.cs:394-396` becomes `ToSchematic(hitDirection)`. + - `ShotOutcome.Aimed` and its plumbing (Q12-4 = A): + - the field, `FireControl.cs:702`; + - the `aimed` parameter of `MakeOutcome` (`:470`, `:481`); + - `Commit`'s `aimed` local and flag (`:420`, `:435`); + - the `AimedCells` half of `ResolveAimPoint`'s tuple (`:583-589`), which now returns + `float2`. +- **Adds:** `Entity.ToSchematic(float2)`. +- **Verification:** + - Builds: headless `Aetheria.Shared`, plus a Unity batchmode compile by Self. + - All 244 tests pass unchanged. `SplashDamagesTheSideTheBlastCameFrom` and + `PenetrationMarchIsPlanar` pin the frame through its new owner. + - Negative greps: + - `rg -n "forward\.y, -forward\.x" Assets/Scripts` returns exactly one hit, in + `Entity.cs`. + - `rg -n "Outcome\.Aimed|outcome\.Aimed|o\.Aimed" Assets/Scripts tests` returns + nothing. + + Both were checked against the current tree: the first finds today's two copies, and the + second finds nothing. + +**Status (2026-09-24): landed** at `b851b0d1` (+21/-18 across `Entity.cs` and +`FireControl.cs`). 244 tests pass, both greps as specified (the one frame copy is +`Entity.cs:383`, inside `ToSchematic`), Unity batchmode compile clean, and nothing outside +`FireControl.cs` read `ShotOutcome.Aimed` (the HUD holds outcomes but never touched it). +Stryker on the changed lines: the frame's sign mutant dies; the only survivors near the diff +are `ResolveAimPoint`'s pre-existing centroid arithmetic (`aimedCells.Length > 0` → `>= 0`, +`/` → `*`), which 12.2's `AimingAtAnItemCentresTheScatterOnItsLane` is specified to close. +`--since` does not work in this repo (Stryker's own diff comes back empty), so runs use +`--mutate` on the touched files and filter the report to changed lines. No separate Soul pass: +a no-behaviour cut this size folds into 12.2's. + +### Cut 12.2. Where a hit lands + +- **Repo/branch:** Aetheria, on top of 12.1, with the 12.0 pin in place. +- **Deletes first:** + - `HullKernel` and its comment block (`FireControl.cs:531-578`). + - `WeightedPick` (`:591-611`). + - `POnHull` (`:229-234`). + - Apply's live direction (`:463-465`). + - The bounding half-extent (`:224`). + - The 9.2 SigmaFloor rationale (`:542-548`). Rewrite it as described above. + - The ActionGameManager inline estimate (`:1317-1318`). +- **Keeps:** `Sigma`, `SigmaFloor`, `ResolveAimPoint`, `CellsOf`, the gate order + (`:150-159`) and `Inspect.PBase = HitProbability(...)` (`:204`). +- **Adds:** `TravelDirection`, `Silhouette`, `CommitProbability`, the lateral draw, `Lane` + (only its first element is used in 12.2), and these fields: + - `PendingShot`: `TravelDirection`, `Spread` and `FireRange`; `PBase` is renamed to + `PFire`. + - `ShotOutcome`: `Bearing` and `Lateral`. +- **Per-file changes:** + - `FireControl.cs:161-167`: after the gates, `PFire(...) × PSpread(...) × + Silhouette(...).POnHull`. The bearing comes from `TravelDirection` and the target's + current facing. Nothing new runs before `:159`. + - `:197-202`: Inspect uses the same functions. It gains nothing new beyond + `diagnostic.PFire` if the HUD wants it (optional; presentation). + - `:263`, `:286-308`: Fire freezes `PFire`, `TravelDirection`, `Spread = weapon.Spread` + and `FireRange`. + - `:409-435`: Commit takes steps 1-5 of **Bearing timing** through `CommitProbability`. + - `:463-467`: Apply passes `shot.Outcome.Bearing` (schematic frame) to `ApplyHit`. For + this one cut, ApplyHit's parameter becomes a schematic bearing and the `ToSchematic` + call 12.1 put inside it is removed. + - `ActionGameManager.cs:1317-1318`: the pending line prints + `FireControl.CommitProbability(shot, now, out _)` instead of `shot.PBase * pDeviation`. + - `Combat.cs:117` and `TurretController.cs:84` need no change. They read HitProbability, + which is now a facing-aware forecast. Expect the AI to prefer broadside shots on long + hulls, and targets that turn to lose fewer hits. That is tuning, not a defect. +- **Authority map:** + - Owner: + - `FireControl.Silhouette` owns scatter. + - `FireControl.CommitProbability` owns the commit-time roll price and placement + source. + - `FireControl.TravelDirection` owns the shot's world direction. + - `Entity.ToSchematic` owns the frame. + - Inputs: + - Frozen: `PFire`, `TravelDirection`, `Precision`, `Aimed`, `Spread`, `FireRange`. + - Live at Commit: target position (deviation) and target facing (bearing). + - Outputs: p for the one roll, then `Bearing`, `Lateral` and `Cell` in the immutable + outcome. + - Derived state: + - HitProbability and Inspect are a forecast of the commit price and decide nothing. + - `ShotOutcome.Cell` is display-only, for the HUD (`ActionGameManager.cs:1323`) and + the tests. + - Forbidden writers: + - Nothing else computes a sigma, a projection or a bearing. + - Nothing after Commit reads a target's facing or anyone's position for placement or + damage direction. + - The HUD computes no probability of its own. + - Shared paths: InstantWeapon (`InstantWeapon.cs:248`), beam rolls + (`ConstantWeapon.cs:181`, where zero flight time makes the fire tick the commit tick), + the AI, turrets and the HUD. + - Deletion line: `HullKernel`, `WeightedPick`, `POnHull` and the HUD estimate are gone + before `Silhouette` has a caller. +- **Verification.** Fixtures follow 11.3: the shooter is off the origin, fire times are + nonzero, facings include ones where |fx| > |fy|, and weapon spread is nonzero wherever + PSpread is asserted. + - `BroadsideIsEasierThanHeadOn`: on a 2x12 hull, pOnHull and PSpread head-on are strictly + below broadside, at facings (0,1) and (1,0). It kills the bearing being ignored and the + old `max(W,H)`. + - `TurningArmourIntoTheShotTakesItOnTheArmour` (the ruling's own case): + - Setup: a slow shot (flight time well above `CommitHorizon`) at the soft stern of a + wedge with bow-only plate. The target turns its bow to face the shot's + `TravelDirection` before commit. + - Pass: every hit's `Cell` is on the bow row. + - Kills: freezing the bearing at Fire; reading the facing at Fire. + - `TurningAfterCommitChangesNothing` (R4, and closes L464/L465): + - Setup: through `Fire`. After commit and before arrival, the target turns 90° and the + shooter moves. + - Pass: the damaged cells equal those implied by the committed `Bearing`/`Lateral`. + - Kills: Apply reading live positions or facing. + - `PlacementAndProbabilityShareOneSilhouette` replaces 6d's + `PlacementAndProbabilityShareOneKernel` (`FireControlCut6dTests.cs:318`). + - Setup: a static target at a diagonal bearing, so the intervals overlap and the + forecast equals the commit price. + - Pass: `CommitProbability` equals a pOnHull that the test computes independently, by + numerically integrating the Gaussian over a union of intervals the test builds itself. + The empirical `Lateral` distribution matches the per-interval shares, and the + empirical hit rate matches p. + - Kills: sigma or bearing perturbed at one site; the union merge skipped; ℓ swapped + with b. + - `TheHudEstimateIsTheCommitPrice`: for an in-flight shot, the value the HUD prints and + the p Commit rolls against are the same number at the commit tick. It kills a second + formula. + - `HitsLandOnTheFacingEdge` supersedes `EveryHitLandsOnMetal` (`:357`): on the concave + fixture, each hit's `Cell` is occupied, and nothing occupied precedes it in its own lane. + The test recomputes the lane from `Bearing` and `Lateral`. + - `AimingAtAnItemCentresTheScatterOnItsLane` closes L585/L587 and replaces + `AimingAtTheSternHitsTheStern` (`:213`), whose fixture fires from the target's stern + (`FireControlCut6dTests.cs:163-164`) and would pass for the wrong reason. + - From broadside, hits aimed at the stern land mostly in the stern half, and hits aimed + at the bow land mostly in the bow half. + - From dead astern, hits aimed at the bow land on the stern edge. + - `TheSigmaFloorHoldsAtHalfACell`: pOnHull at Precision 1000 equals pOnHull at Precision 2 + on a one-cell lane. The exact integral no longer collapses, so this test, not the cliff + test, is now what kills "delete the floor". Keep the cliff test and rewrite its + mutation comment. + - `ShieldIsOmnidirectional`: an active shield absorbs the same shot identically from bow, + stern and beam. + - Kept green: + - `GatedOutHitProbabilityAllocatesNothing`. + - `TheHudShowsTheFactorsTheSimulationMultiplies`, re-read as forecast factors. + - `PSpreadIsTheSilhouettesShareOfTheSpreadCone`, which fires from astern at a 5-wide + hull. There the span is 5, equal to the old `max(5,3)`, so its numbers stand. + - `ThinLimbCostsHitChance`, with its comment rewritten in 1D terms. + - Every test that reads `shot.PBase`, updated to `PFire` with its expectation + unchanged. + - Stryker: `--mutate "**/FireControl.cs" --since:<12.1 head>`. Survivors are triaged by + name, and float boundary flips count as equivalent (11.2). +- **Operator:** + - A smoke against a LonginusX-class AI from the bow and then the beam. The HUD `hull` + factor should read higher from the beam. + - Turn your armoured face into a slow missile and watch where it lands. + +**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: +`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` +(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. + +What the Soul passes found, in order, because two of them are lessons for 12.3: +- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, + and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so + every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause + found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; + `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration + could follow a turn made after commit; multi-cell aimed items were untested. +- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. + The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result + into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root + cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, + `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the + two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an + `IComparer` in a delegate per sort). +- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the + slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all + cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and + float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration + found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact + arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. + The `Commit` invariant throw is unreachable. + +Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX +from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside +fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an +item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per +`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. + +**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at +b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant +survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of +their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in +`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make +`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through +visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` +import. + +### Cut 12.3. How a direct hit travels + +- **Repo/branch:** Aetheria, on top of 12.2. +- **Deletes first:** `Entity.DamageSchematic` (`Entity.cs:333-374`) and `Entity.ApplyHit` + (`Entity.cs:376-409`). Going with them: + - the 0.5-step march; + - the `+ .5` cell convention; + - the `> .5f` threshold; + - the Expand spread footprint. +- **Adds:** + - `Entity.Absorb(int2 cell, float damage) → float`, moved verbatim from + `Entity.cs:348-366`. Armour absorbs up to its value, then the item when `d > .1f`, and + the remainder is returned. It emits `ArmorDamage((cell, incoming))` and + `ItemDamage((item, incoming))` with today's meaning, and only for incoming > 0. + - `Entity.DamageHull(float)`, moved from `Entity.cs:369-373` (`> .1f`, one `HullDamage` + event). +- **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches + unchanged: + 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. + 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's + rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and + **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per + cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two + derivations disagreed at cell edges and struck a cell twice). + 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. + 4. Each lane walks from its own facing cell, clipped to the penetration depth, with + `rem = target.Absorb(cell, rem)` at each cell. + 5. Each lane's final remainder goes to the hull, where the lane ends: penetration + exhausted, a gap, or the far side (Q12-3 = A). The remainders are summed and passed to + `DamageHull` once. + 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead + (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. + + **Where 12.3 and 12.4 meet.** Which model a shot uses is decided by the blast radius, not + by its penetration: + - **No blast radius — a dumb AP round.** Steps 1-5 are the whole model, whatever the + penetration. The shot travels its lane and is absorbed cell by cell in order, with the + remainder carrying on until it is spent or the lane ends (Q12-3). Damage in a line. More + penetration spreads the same damage over more cells; it never concentrates it at depth. + Q12-8 says nothing about this case. + - **With a blast radius — an explosive penetrator.** The shot reaches its fuse depth + without spending damage on the way, and then detonates as an area (12.4). Q12-8 governs + only this case, because the payload is the explosion, not the dart. + + The two roles that follow are the design intent: the AP round is the pinpoint component + sniper, a thin line of damage through whatever it crosses, and the explosive penetrator + is the interior wrecker, a disc across a whole section. + + Splash is untouched in 12.3 and deleted in 12.4. Until then it calls `Absorb` per + footprint cell and passes the summed remainder to `DamageHull`, which is arithmetically + identical to today. +- **Authority map:** + - Owner: `Entity.Absorb` owns what one cell does with incoming damage. + `FireControl.Apply` owns the order and the share per lane. + - Inputs: the committed `Bearing`, `Lateral` and `Cell`, plus the frozen `Penetration`, + `DamageSpread` and `Damage`. + - Outputs: `Armor[]`, item `Durability` and `Hull.Durability`, plus the events + `IncomingHit`, `ArmorDamage`, `ItemDamage` and `HullDamage`. Their consumers: + `ActionGameManager.cs:1070`, `EntityInstance.cs:292`, `InventoryPanel.cs:470-482`, + and `Entity.cs:488-494` (ItemDestroyed, HullArmorDepleted, Death). + - Forbidden writers: no weapon hit writes armour or item durability except through + `Absorb`. `Behaviors.CauseDamage` (`Behaviors/Behaviors.cs:62-73`) is self-damage and + out of scope. + - Deletion line: both Entity methods are deleted before `Absorb` gets a second caller. +- **Verification:** + - `DamageIsAbsorbedInOrderAlongTheRay` replaces `HardpointHitDamagesItemThenHull` and + `PenetrationMarchIsPlanar` (`FireAuthorityTests.cs:481-526`, which call the deleted + `ApplyHit`). + - Setup: through `Fire` at facing (1,0). The lane runs through a 10-armour front cell, + then a marker, then a soft cell, with damage 30 and penetration 3. + - Pass: armour −10, marker −20, soft cell untouched, hull −0. Today it would be + 10/10/10 **(probe)**. + - Kills: an even split; items before armour; no carry-forward. + - `ArmourFacesTheShot`: + - Setup: a wedge with bow-only plate, penetration 0, many shots. Facings (0,1), (.8,.6) + and one where |fx| > |fy|. + - Pass: from ahead only bow cells lose armour, and from astern the reverse. + - `BuriedCockpitNeedsPenetration`: a cockpit enclosed by one ring of cells. + - At penetration 0 it is never damaged from four bearings. + - At a penetration that clears the ring, it takes exactly `damage/lanes` minus what the + ring absorbed. + - Enough damage fires `Death` with `CockpitDestroyed`. + - `SpreadWidensTheImpactAcrossLanes`: spread 1 at penetration 0 puts damage/3 on each of + three facing-edge cells and nothing behind them. + - `ARayThroughACornerCrossesTheCornerCell`: the diagonal case from `Probe1` reaches (2,2). + - `LaneRemainderReachesTheHull`: a ray that exits a thin hull delivers its remainder to + `Hull.Durability` (Q12-3 = A). + - Kept green: every Splash test, and `AirburstSplashes…` (Cut 6). + - Negative: `rg -n "DamageSchematic|ApplyHit\(" Assets/Scripts tests` returns nothing. + Today it matches `Entity.cs`, `FireControl.cs`, `FireAuthorityTests.cs`, and two comment + lines at `Gameplay/EntityInstance.cs:280-281`. Rewrite those comments and + `FireControl.cs:492`. + - Stryker: `--since:<12.2 head>` over `FireControl.cs` and `Entity.cs`. +- **Operator:** a smoke with a GT 3K or pswarm launcher into a LonginusX bow, then from a + flank. The schematic display should pulse the facing edge only. + +**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: +- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. +- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. +- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. +- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. +- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. +- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. + +297 tests. Unity batchmode is clean at `cccf3a2a`. + +Proportional absorption, passes 4 and 5: +- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` + was already ruled out by the operator. Soul found three more defects: + - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. + - The pooled resolve skipped the item's `.1f` threshold. + - The model sweep still passed with proportionality deleted. +- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. + `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is + Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. +- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled + bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. + Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and + stale comments. All are fixed, and each mutant dies to a committed test. + +Recorded, not fixed: +- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, + which only a private seam offers. The operator rule forbids reflection, and no test seam is added + for them. + - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. + - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). + - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). +- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. + +What the three Soul passes found: +- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of + 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an + angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. + It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on + (Q12-2 amendment: no doubling up, the line thickens). +- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits + struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first + structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the + centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit + with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float + drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two + derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, + the `ceil` empty centre); the cure each time was one owner. +- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two + lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by + construction. Residual float effects (a side lane starting one cell deeper, a column falling + between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage + is never lost. + +**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike +exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% +of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing +ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since +it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. + +**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the +first-processed lane drains its durability and the second carries the leftover into its own deeper +cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on +a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs +from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. +Alternative: keep the processing order and pin it. + +**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell +item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane +carries on with a leftover in proportion to what it brought. The result must not depend on the order +lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. + +**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an +exception, this should be one code path." The first implementation (`56757913`) surveyed each shot +for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split +authority, and the second reachability derivation it needed is the same failure as the 12.2 empty +lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of +the lanes that reach it, a pool of one lane included. The survey and the sequential path die. + +**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. + +### Cut 12.4. The detonation primitive + +**Status (2026-09-29, Self): closed.** 12.4 landed at `2625768f`, `7c99909d` and `7e63d744`. + +Soul pass 1 (Opus) found three defects: +- **F1:** a tangent disc created damage, 127 of 100. +- **F2:** a candidate cull measured from the centre of mass missed hull corners. +- **F3:** a lethal blast threw mid-loop and detonated again. + +It also found four rules pinned only by tests that could not fail. + +The fixes landed at `8b4bf01e`, `76e98598` and `fd792013`: +- the tangent comparisons now include equality; +- the cull and the `radius <= 0` guard are deleted, so `RectDiskOverlap` is the only decider; +- the blast loop iterates a snapshot. + +Soul pass 2 closed it. Conservation held over 1,944 exact-lattice discs, with a worst error +of 2e-5. A blast costs 0.3-0.7 ms in a zone of 40-100 entities. + +Pins landed at `4629bc67` and `c7e20f08`. `Death` fires once per entity (`Entity.cs`, `.Take(1)`), +which also fixed a direct hit on a corpse raising `Death` again. A three-kill blast test was added. +362 tests pass. + +**Operator ruling needed:** a fused weapon fired with no target bursts at the shooter's own +position (`FireControl.cs:391`). This predates 12.4. Self recommends a max-range burst along the +aim line. Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship +still takes blast damage. + +Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path +rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. +Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see +Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. + +- **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: + - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no + weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has + not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). + - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. + +#### Deletes first + +In (a): + +- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), + `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. + `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. +- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The + initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a + proximity fuse. +- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. + +In (b): + +- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. +- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. + `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). +- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell + separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. + **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` + the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. + 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. +- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended + answer. It is not deleted: it is gated on `shot.Fuse == null`. +- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one + reach function (see **The penetrator**). +- Stale comments that name a deleted owner as live: + - `Entity.cs:391` and `:417`; + - `FireControl.cs:1201`; + - `Gameplay/EntityInstance.cs:280-283`; + - `Gameplay/HullCollider.cs:4-6`; + - `Gameplay/Weapons/Projectile.cs:8-9`; + - `Gameplay/Weapons/Mine.cs:90-95`. + +#### Data + +The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). + +- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name + `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache + opens the catalog as compatible drift (see 0b). +- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, + Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: + null means no blast, and every shipped record reads as null. +- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when + `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing + re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius + without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is + not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on + `BlastRadius` alone (Soul, `2625768f`). +- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That + write is content and changes no data. It belongs to F12-2. +- **Readers to update:** + - `FireControl.cs:386-388`. + - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` + parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, + because Q12-6 ruled that nothing checks labels against behaviour. + +#### Which model a shot uses + +`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: + +- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. +- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which + `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, + `AirburstSplashesEvenOnAGuaranteedMiss`. +- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P + is**). On a miss nothing happens. + +Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse +into Apply's switch, so `Step` has no damage branch left. + +`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and +only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one +consumer is the player's hit marker (`ActionGameManager.cs:1070`). + +This is a default, not a ruling. + +#### Where P is + +The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as +`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). + +**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: + +- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 + from its centre; +- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. + +So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. + +- **Proximity:** `BurstPosition.xz`, a world point. +- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and + `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. +- **Delayed:** the schematic point `point(t_fuse)`, with + `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that + 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, + whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last + reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. +- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already + proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not + read. The disc's radius is its width. This is a default, not a ruling. +- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the + host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like + every other entity in the radius: its cells are found again by `ToSchematicPoint`. + - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for + it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. + - Bystanders are judged against where the host actually is at arrival. That is the same live rule a + proximity burst already applies to everyone. + + There is exactly one input shape, a world point. There is no host-only schematic entry. + +#### The penetrator (Q12-8) + +A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` +the shot's whole `Damage`**. + +That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the +explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. + +The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at +`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → +int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while +`entry - impact.entry < penetration`. + +Two callers read it: + +- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; +- the delayed fuse point, which calls it on the centre lane. + +No second "lane damage with a blast" path exists, because no shot does both: + +- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; +- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. + +A shot takes one branch of Apply's switch or the other, never both. + +The mutant this must kill is a second reach derivation: for example, a delayed P computed as +`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. + +#### Area, per entity: `FireControl.Detonate` + +The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, +public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). + +- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes + `position.ToCultMath().xz`). +- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / + SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the + frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and + `SchematicCellSize` is `Settings.cs:255`. + +For each entity in the zone: + +1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. + Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a + long hull whose end lies inside the blast. +2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For + every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × + overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a + cell being the unit square centred on its integer coordinate. + - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing + computes an overlap twice. + - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. +3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, + under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact + or delayed blast, under Q12-9's recommended answer. + - If the shield absorbs, no cell of that entity takes anything. + - If it cannot, it breaks, and the cells take their shares. +4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects + its own cell's share and nothing else. +5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in + hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has + deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join + the hull total. +6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). + +Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the +other (F12-8). + +**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, +and no operator question is needed: + +- "Armor absorbs first", from the first round of rulings; +- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; +- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where + incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after + `ArmorAbsorb`; +- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." + +The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: +an item's cells deposit into one pool, and they do not absorb independently. + +**One owner, and why there is no walk.** + +- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on + the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. +- `ArmorAbsorb` is the one owner of what armour does. +- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, + `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the + item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no + cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the + reachability test and the cycle rule have nothing to decide. + +The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull +with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell +with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 +discs. Its hull total differed only by float summation order, at most 3.8e-6. + +`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a +starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. + +**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same +per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only +order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. + +**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the +disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` +between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it +conserves exactly up to float rounding. + +- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at + radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double + precision is not needed. +- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. +- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc + smaller than a cell, or for one straddling a cell boundary. + +**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than +128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not +the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. + +**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an +area is occluded. A bulkhead protects its own cell's share and no other. Falloff toward the rim is F12-7, not +this cut. + +**HitProbability and Inspect are unchanged.** A contact or delayed blast still needs the shot to hit, so the +forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. +That is pre-existing, and it is F12-5. + +#### Authority map + +**Owners:** + +- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. +- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the + per-entity armour→pool→hull pass. +- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with + 12.3's walk. +- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. +- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. +- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. + +**Inputs:** + +- P, the radius, the damage and the type; +- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; +- the fuse and the radius, frozen at Fire; +- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. + +**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, +`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. + +**Derived state:** + +- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, + `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. +- `BurstPosition` is read only for a proximity fuse. +- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. + Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` + finds only `FireControl.cs`). + +**Forbidden writers:** + +- No label decides behaviour. +- Nothing but `Detonate` applies area damage. +- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. +- Nothing but `Reach` decides how deep a lane reaches. +- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). +- `Step` has no damage branch. +- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they + decide no damage themselves. + +**Shared paths:** + +- the proximity burst: Apply at arrival, with no roll; +- the contact and delayed blasts: Apply on a committed hit; +- `Mine.Explode`; +- the tests, which call `Detonate` directly (see **Test seam**). + +**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a +caller. + +#### Test seam + +`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen +intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production +interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from +`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is +committable with no seam and no reflection. + +It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at +an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes +a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and +exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: + +- angled direct-hit correctness is covered by the model sweep and + `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; +- P1 and P2 stay uncommitted. + +#### Verification + +These fixture rules apply to every blast test that touches the frame: + +- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a + deleted `/ SchematicCellSize` survives. +- The target is off the origin. +- The facings include one with |fx| > |fy|. +- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. + +Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 +absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. + +**Geometry.** + +- `TheDiscIsConservedOverTheGrid`. + - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius + under one cell, one centred on a cell corner, and one off the grid. + - Pass: the total equals `damage`. + - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box + that clips the rim cells; a dropped chord breakpoint. +- `EachCellTakesItsShareOfTheDisc`. + - Setup: a solid hull with no armour and no items. + - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. + - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the + cell's centre. +- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). + - Setup: a blast straddling the hull's edge. + - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The + shares sum to the blast's damage minus what falls off the hull. + - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. +- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. + - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. + - Pass: the near end takes damage. + - Kills: Splash's centre-distance cull. + +**Absorption order.** + +- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` + (`FireControlCut123Tests.cs:750`). + - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage + levels of the old test: below the armour, between the armour and armour plus durability, and above both. + - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. + - Kills: the item absorbing before the armour, and armour being skipped. +- `ArmourProtectsOnlyItsOwnCellsShare`. + - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. + - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. + - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat + cell B's share. +- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. + - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but + more than `.1` on the two together (the `ProbeC2` geometry). + - Pass: the item absorbs the sum. + - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or + a pool that resolves before every covered cell has deposited. + + This test is also what pins that the result does not depend on which cell is considered first. As shown + above, the threshold is the only order-sensitive rule a blast has left. +- `ItemDamageFiresOncePerCoveredCell`. + - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive + post-armour deposit, and each event carries that cell's deposit. + - Kills: one event per item, and events for zero-deposit cells. + +**Symmetry and frame.** + +- `MirroredBlastsDoMirroredDamage`. + - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of + world points mirrored across the target's bow axis, with the mirror computed by the test from the facing + and not through `ToSchematic`. + - Pass: per-cell armour, item durability and hull damage mirror. + - Kills: + - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); + - a missing or wrong `CenterOfMass` anchor; + - any scheduling rule that depends on enumeration order. + + It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test + does. +- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` + (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). + - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's + width. + - Pass: port cells are damaged, and starboard cells are untouched. + - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. + +**Shields** (under Q12-9 = A). + +- `ShieldPaysForWhatReachesIt`. + - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. + - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` + (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the + covered share, never loosened. + - Kills: charging the full damage. +- `ABlastShotsShieldIsDecidedAtDetonation`. + - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at + arrival. + - Pass: the shield is charged the covered share. + - Second setup: a shield that could absorb the whole shot at commit. + - Pass: the shot still detonates, and the shield pays its covered share. + - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the + host. + +**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). + +- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit + behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. + - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour + of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. + - A contact fuse with the same stats leaves the cockpit untouched. + - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in + order, and the cockpit takes only what the ring left (12.3). Damage in a line. + - Kills: + - absorbing along the burrow; + - a contact fuse reading penetration; + - routing every penetrating shot through the fuse path; + - a fuse without a radius detonating. +- `TheFusePointStaysOnMetal`. + - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap + behind the first run. + - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the + gap's near edge). + - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. +- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. + - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. + - Pass: the host's per-cell damage equals that of the same shot with no turn. + - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. +- `LabelsDoNotDecideBehaviour`. + - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with + `Fuse = Proximity` and no label detonates. +- `AFuseWithoutARadiusIsADirectHit`. + - Pass: `Fuse = Contact` with a null or zero radius does lane damage. + - Kills: Fire freezing `Fuse` without the radius check. +- Re-fixtured to `Fuse`, with their behaviour unchanged: + - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes + `ProximityDetonatesEvenOnAGuaranteedMiss`; + - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); + - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; + - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes + `ABlastHitsEveryEntityItCovers`. +- `AContactBlastReportsTheHit`. + - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. + - Kills: `IncomingHit` moved inside the direct-hit branch. + +**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: + +- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes + `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with + a remainder of 0. +- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool + of `{ .1f }`. + +**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the +clip rule are otherwise untouched. + +**Negative greps:** + +- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` + returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and + `FireControlCut4/5/6/11/123Tests.cs`. + - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are + unrelated. + - Exclude `StrykerOutput`, `bin` and `obj`. +- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the + stale-comment list above. + +**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. +`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). + +#### Operator + +- Author one proximity weapon and one delayed-fuse weapon in a scratch catalog. +- Watch an airburst beside a LonginusX and a penetrator into its nose, and check that the damage lands where + the model shows the blast. That verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of + the frame that only a running blast can show. + +#### History (superseded 2026-09-25, kept for the record) + +The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: + +- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** + Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour + stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. +- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with + `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. +- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** + Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then + `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit + had judged unshielded: two authorities over one shield. +- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and + `Step` has no damage branch. +- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one + `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. +- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was + `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. + +### 0b. Identity, lifecycle, authority + +Two persisted changes, both on `aetheria.weaponitemdata` v1 and both in 12.4: + +| Field | Slot | Change | Lifecycle | Reader risk | +|---|---|---|---|---| +| `BlastRadius` (was `AirburstRange`) | 29 | rename, same `float?` type | authored catalog | compatible drift: `CompareSchemaShapes` ignores member names (`CultCache.cs:672-752`, CultLib `45c2f40`); drift warnings until the next catalog write | +| `Fuse` | 32 | new, `WeaponFuse?` | authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule | + +Runtime-only and never serialised (0b table, `FireControl.cs:626-629`, `:686-689`): + +- `PendingShot`: `PFire`, `TravelDirection`, `Spread`, `FireRange`, `Fuse`, `BlastRadius`. +- `ShotOutcome`: `Bearing`, `Lateral`. + +Nothing in saves changes shape. + +### Operator questions + +Ruled 2026-09-22 (the words are recorded under **Rulings**): + +- **Q12-1: B**, the bearing is taken at Commit. +- **Q12-2: A**, spread is width. +- **Q12-3: A**, the lane remainder goes into the hull where the lane ends (direct hits). +- **Q12-4: A**, `ShotOutcome.Aimed` is deleted. +- **Q12-6: no.** Operator: "12-6, nope". No catalog test ties a label to behaviour. + `WeaponModifiers` stays authored metadata that nothing checks and nothing reads in the + simulation, and a weapon whose label disagrees with its data is authoring's business. +- **Q12-7: dissolved, not answered.** It asked where a blast ray's remainder goes. With + blasts as areas there are no rays and no remainder to route, so the question has no + subject. The ray model's shadowing goes with it (see **Accepted loss** in 12.4). +- **Q12-8: A, and it is scoped to weapons that carry a blast.** Nothing is absorbed along + an *explosive penetrator's* burrow: it reaches its fuse depth without spending damage, + and the explosion is the payload. Penetration is a reach decision there — how deep the + fuse point can sit — not a damage budget. + + The first wording ("the burrow is travel, not damage") read as if it covered every + penetrating shot. It does not. Operator, 2026-09-22: "Q12-8 is odd. Like, penetration is + still a thing even if it's not a warhead. For a dumb AP round, you'd expect it to do + damage in a line, not dump all its damage at max depth, that would make AP rounds even + better at component sniping than AP explosives". Agreed, and the map never meant + otherwise: a weapon with no blast radius takes 12.3's lane, absorbed cell by cell in order + with the remainder carrying on, and Q12-8 does not reach it. See **Where 12.3 and 12.4 + meet**. + + So "armour absorbs first" holds everywhere it was ruled: down the lane for a direct hit, + and per covered cell for a blast. Against an explosive penetrator, armour protects by the + reach gate and by absorbing its own cell's share of the disc. The consequence to watch + when F12-2 tunes penetration is that an explosive penetrator with reach pays nothing for + the plate it crosses, while an AP round pays for every plate it passes. + +Open: + +**Q12-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. + +**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a +blast that needs a hit?** + +The current map lets two places decide it: + +- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. +- `Detonate` then decides every shield in the radius live, and that includes the host. + +**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so +the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can +absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split +this campaign has cut three times. + +- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield + for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the + radius, live at detonation. + - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no + direction", applied to shields. + - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that + reaches the hull. For a contact detonation on the hull's face, that is roughly half. + - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads + them. +- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole + and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" + parameter: a compensator that keeps two owners alive, one per entity. + +What depends on it: + +- the gate on Commit's shield block; +- whether `Detonate` takes a host parameter; +- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. + +Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half +of (b) can proceed while this waits. + +**Defaults taken, not rulings.** The operator may overrule any of these: + +- A blast shot ignores `DamageSpread`, and P sits on the centre lane. +- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. +- The delayed fuse point is clamped to the last reached cell's exit. +- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. + +- **Q12-5 (not ruled). Should the HUD show which target items are exposed from the current + bearing?** + - **Recommended:** a follow-up after 12.3. It would be a presentation read of `Silhouette` + and `Lane`, the way Inspect is: for each revealed item, is it first in some lane of the + current shadow. It belongs on the target-item cycling UI. + +### Follow-ups recorded, not in this cut + +- **F12-2. Penetration retune** (operator: "Retuning penetration is a follow up."). + - Shipped values are ≤ 0.25 against an inspector range of 0..1, and enclosed cockpits + need about 2 cells. + - The same pass decides which weapons get fuses and blast radii (none have one today), + and writes the catalog once to clear the slot-29/30 drift warnings. + - Until it lands, interior cells are reached only by blasts. +- **F12-3. SigmaFloor's purpose changed.** It was a numerical guard and is now a design + minimum on group tightness. Revisit it when Precision is next tuned. +- **F12-4. Double charging.** 6d's note that pSpread and pOnHull both price target size + still stands. Both now read one Span and shadow, so a later merge is mechanical. +- **F12-5. AI and proximity blasts.** `Combat.cs:117` gates a proximity weapon on a + direct-hit forecast that does not decide its damage. This is pre-existing. +- **F12-6. Mine's blast inputs.** The prefab `BlastRange` and the arming OverlapSphere + (`Mine.cs:69-78`, `:99`) are presentation-side (Cut 4 Q7). Mine has no catalog weapon + record carrying `Fuse`/`BlastRadius`. It can move when it ships (R9). +- **F12-7. Blast falloff toward the rim.** Every cell takes its area share flat, so a cell + at the rim is hurt as hard per unit of area as the one under the fuse. A radial falloff is + a tuning decision for the same pass that authors radii, and it changes only the weight + inside the integral, not the owner. +- **F12-8. Blasts do not shadow.** Nothing inside an area is occluded, so a bulkhead + protects only its own cell, and two ships caught by one blast do not shade each other. + This is the accepted cost of areas over rays. Reopen it only if play shows interiors are + too soft. +- **Behaviors.CauseDamage** (`Behaviors/Behaviors.cs:62-73`) writes durability and + `HullDamage` without `Absorb`. It is self-damage, not a hit, and is left alone. +- **Orphaned `Airburst*` YAML** in nine weapon prefabs. It is harmless, and Unity drops it + on the next save of each prefab. + +### Subtraction ledger (estimates, with actuals) + +| Sub-cut | Removed | Added | Notes | +|---|---|---|---| +| 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | +| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | +| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | +| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | +| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | +| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | + +Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: + +- 12.1: `b851b0d1^..b851b0d1`; +- 12.2: `b851b0d1..c7b6e80c`; +- 12.3: `c7b6e80c..d402283c`. + +The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: + +- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; +- 12.3: sequential, proportional, order-free absorption; +- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, + `Absorb` and `Step`'s branch. + +12.4's own delta is ~+60, and that is the estimate to hold it to. + +**Section history:** Cut 6d's "The rule" (the 2D kernel) and its premise that pOnHull folds +into PBase at Fire, 9.2's SigmaFloor rationale, and Cut 4's Splash rule (`FireControl.cs:946-959`) +describe models this cut replaces. Self should mark them as history in the status header +as 12.2 and 12.4 land. From b66ba5246a6c355b39b20d35a73b24c17a4672c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 08:00:22 +0200 Subject: [PATCH 076/116] Restore LF line endings in the fire-control map c50029d5 committed the file with CRLF endings: the inserted paragraph had mixed endings, and autocrlf does not normalise a mixed file. Content is unchanged, and the net diff against c50029d5^ is the 26-line status paragraph. Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 6610 +++++++++++++++++++------------------- 1 file changed, 3305 insertions(+), 3305 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index fe48b2c80..915c755dd 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1,3306 +1,3306 @@ -# Fire Control: Cut Map - -Date: 2026-09-19 - -Status: Imagination pass, cut map. Nothing here has landed. Ends are owned by -`docs/three-gates-scope.md` ("Fire control") and -`docs/shield-presentation-contract.md` ("The commit window"); this document owns -the means. - -Anchors are against `codex/item-provenance` HEAD `8a00fc92`, read-only. Nothing was -built in the main tree and Unity was not run. Claims marked **(probe)** were measured -by running `tools/AetherDb` (`census`, `hardpoint-fit`) against the live catalog -`GameData/Aetheria.cc` from the free worktree `F:\Projects\Aetheria-stats`, CultLib -pinned at the shared `45c2f40` worktree. Claims marked **(read)** come from reading -source at the cited anchor. - -**This map supersedes Cut 2 of `docs/headless-playground-cut.md`** -(`:490-810`, anchored to `562cdcf2`). That draft is now history for three reasons: -its anchors predate the stats-and-power campaign, which moved most of the code it -cites; it predates the commit-window ruling of 2026-09-18, which changes the shape of -a shot from an instant to a lifecycle; and it predates the per-hardpoint arc override -and 360-degree turret rulings. Its substrate probes are still good and are carried -forward below with citations. Where the two disagree, this document wins; nobody -should be reading two live designs. - -**Scope: fire control only.** What a presentation does with a committed outcome is the -capability/presentation seam's business (`docs/shield-presentation-contract.md`). -Faction territory is the campaign after this. §6 names every seam and stops there. - ---- - -## Rulings this map is built on (do not re-litigate) - -From `docs/three-gates-scope.md:47-99` and `docs/shield-presentation-contract.md:34-57`: - -- **R1. Hit detection goes.** Operator, 2026-09-17: "We wanted to get rid of hit - detection anyway... Just roll the dice based on weapon stats, targeting system stats - (new subsystem) and sensor state." -- **R2. Targeting systems are a new subsystem**, interior `Tool` gear. -- **R3. A shot resolves on arrival**, hit chance reduced by how far the target deviated - from the predicted intercept. Manoeuvring is evasion. -- **R4. The outcome commits a short fixed time before impact.** Operator, 2026-09-18: - "That hybrid is the way." Deviation feeds the roll up to that horizon; after it the - result stands and the rest of the flight is choreography. The horizon is one authored - fire-control setting. A commit is authoritative; presentations perform it and never - predict. -- **R5. Subsystems reveal between `TargetArmorInfoThreshold` and - `TargetGearInfoThreshold`**, and both the player and the AI may aim at a revealed - subsystem. -- **R6. Per-hardpoint firing arcs.** Mount direction from the item's `ItemRotation`, as - reaction thrusters do; default 120 degrees from `GameplaySettings`; an optional - per-hardpoint override; turrets 360. -- **R7. The simulation is 2D.** The 3D is set dressing. -- **R8. Unity weapon effects and `HullCollider` become presentation only.** Their - `Physics.*` queries stop deciding damage. -- **R9. Every weapon type in the catalog's history stays** - (`docs/headless-playground-cut.md` fork E). Nothing is deleted for being unused. - -From `docs/stats-and-power-cut.md:996-1030`, the seam the stats campaign left: - -- **R10. Fire control must snapshot what it rolled over.** The resolver hands out a - current value; it does not promise that value is still current a second later. -- **R11. A starved weapon does not fire** (Cut 4 of that campaign: no fraction of a - shot). A degraded shot instead is a ruling against that map, not a thing to work - around here. See Q6. - ---- - -## 0. Substrate - -### 0.1 What decides a hit today, end to end - -Nothing in `ServerShared` does. The whole chain is Unity **(read)**: - -1. `InstantWeapon.Execute` (`Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs:216-237`) - walks the burst and raises `OnFire` (`:228`), a parameterless `event Action` declared - at `:73`. It carries no outcome and nothing in `ServerShared` subscribes. -2. `Assets/Scripts/Gameplay/EntityInstance.cs:203-204` subscribes and calls - `InstantWeaponEffectManager.Fire(weapon, item, source, target)` - (`Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs:7`). The constant - family goes the same way at `:232-235`. -3. The manager spawns an effect and copies the weapon's resolved numbers onto it. - `ProjectileManager.cs:18-25` jitters the barrel forward vector by - `Random.Range(±Spread/2)` on all three Euler axes — that is the entire aiming model. -4. The effect raycasts. `Projectile.cs:58`, `GuidedProjectile.cs:153`, `Laser.cs:40`, - `Lightning.cs:34`, `ConstantLaser.cs:74`, `ConstantLightning.cs:54`, - `HitscanEffect.cs:31`, `Mine.cs:68,92`, plus `Projectile.cs:114` for airburst. -5. On a hull collider it calls `HullCollider.SendHit` - (`Assets/Scripts/Gameplay/HullCollider.cs:18-30`), or `SendSplash` (`:32-41`). -6. `EntityInstance.cs:316-396` subscribes to those subjects, converts a UV texture - coordinate into schematic cells, expands by `DamageSpread`, marches a penetration ray, - and calls the local function `DamageSchematic` (`:277-314`), which is the only writer - of `Armor`, item `Durability` and `Hull.Durability` from combat. -7. Death: `EntityInstance.cs:405-439` subscribes to `HullDamage`, rolls loot with - `UnityEngine.Random`, and calls `entity.Zone.Entities.Remove(entity)` (`:437`). - -**Authority versus presentation in that chain.** Everything from step 3 down is -authority wearing a presentation costume. The shield branch repeated in all seven -effects (`Projectile.cs:60-80` and its copies) is the absorb rule, duplicated seven -times. `DamageSchematic` is the damage rule. `EntityInstance.cs:437` is the death rule. -Genuine presentation is narrow: trail rendering, line renderers, the lightning compute -animation, hit effect prefabs, `ShieldManager.ShowHit`, and the destroy effect. - -**The 2D/3D violation.** `EntityInstance.cs:323` and `:380` call -`transform.InverseTransformDirection` to get a hit direction into the ship's frame. That -is a 3D rotation standing in for the planar rotation R7 says the simulation is made of. - -### 0.2 What already exists of targeting - -- `Entity.Target` — `Assets/Scripts/ServerShared/Entity.cs:46`, a - `ReactiveProperty`. Cleared when the target leaves `VisibleEnemies` (`:196`, - `:222`). `TargetRange` is recomputed each tick at `:920`. -- `Entity.EntityInfoGathered` — `Entity.cs:57`, a `ReactiveDictionary` in - [0,1], accumulated with decay by `Sensor.Execute` - (`Assets/Scripts/ServerShared/Behaviors/Sensor.cs:152-184`). -- `TargetDetectionInfoThreshold` gates `VisibleEnemies` at `Entity.cs:232-246`. -- **`TargetArmorInfoThreshold` and `TargetGearInfoThreshold` are authored and read by no - code.** They are declared at `Assets/Scripts/ServerShared/Settings.cs:212-213` and - appear nowhere else in `Assets/Scripts` **(read)**. R5's reveal tiers are unwired - settings. -- `LockWeapon` — `Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs`. Integrates a - 0..1 `_lock` scaled by `pow(EntityInfoGathered[target], SensorImpact)` (`:92`), gated by - a `LockAngle` cone off `Entity.LookDirection` (`:88-89`). `CanFire` (`:56`) requires - `_lock > .99f` and range within `[MinRange, Range]`. This is the closest thing in the - codebase to a to-hit probability, and it is per-weapon rather than per-ship. -- `TurretController` — `Assets/Scripts/ServerShared/Behaviors/TurretController.cs`. - `TurretControllerData` has no fields (`:15-25`). It writes `Entity.LookDirection` to a - first-order intercept (`:67-72`) and fires each weapon when - `dot(x.Direction, Entity.LookDirection) > .99f` (`:81-83`). -- `Sensor` — sensitivity, ping boost/energy/visibility/range/cooldown, all - `PerformanceStat` (`Sensor.cs:16-34`). -- Player target selection: `Assets/Scripts/Gameplay/ActionGameManager.cs:368-403` - (reticle, nearest, next, previous). Player fire is not arc-gated at all. -- **No targeting-system item, behavior or stat exists anywhere in code or catalog.** A - repo-wide grep for `Targeting|FireControl|Accuracy|Aim` finds one `#region Targeting` - label at `ActionGameManager.cs:368` and nothing else; the same grep over the catalog - returns zero **(probe)**. - -### 0.3 Where hardpoint rotation and arcs live - -- `HardpointData` — `Assets/Scripts/ServerShared/ItemData.cs:538-545`: `Type`, - `Position`, `Shape`, `Transform` (a prefab node name), `Rotation` (`ItemRotation`), - `Armor`. **There is no arc field on `HardpointData`, `WeaponData`, or any effect.** - A per-hardpoint override attaches here, as catalog data already authored per hull. -- `ItemRotation` is four-way (`Assets/Scripts/ServerShared/Enums.cs:7`); - `Extensions.cs:135-166` gives `None` = forward, `Reversed` = aft, `Clockwise` = right, - `CounterClockwise` = left. `Agent.cs:72` reads `Clockwise` as right and `Thruster` uses - the same convention for mount direction, which is the precedent R6 names. -- `Behavior.Direction` (`Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:24-44`) has - two paths. The first (`:30-34`) reads `Entity.HardpointTransforms`; the second - (`:37-38`) is the item-rotation fallback R6 wants. -- **`Entity.HardpointTransforms` (`Entity.cs:58-59`) is written only by Unity**, at - `EntityInstance.cs:514-517`, from the first `WeaponHardpoint.FiringPoint` barrel's - world forward. Headless it is empty. That single dictionary is why - `Combat.cs:100-102` throws `KeyNotFoundException` on the first combat tick outside - Unity, and why the simulation's own notion of where a gun points is a readback from a - renderer. -- Barrel swing is `ArticulationPoint`, yawing and pitching toward `LookAtPoint` - (`EntityInstance.cs:400-403`, `:519-520`). Authored limits on the Longinus energy - barrels are yaw [-1, 1] — effectively fixed forward — while the Turret prefab's yaw - pivot is [-720, 720] (`docs/headless-playground-cut.md:533-545`, prefab probe, not - re-measured here). That is presentation and stays. -- `AgentFiringMinDot` (`Settings.cs:218`) is **declared and read by nothing** **(read)**. - -### 0.4 What the catalog ships in weapons - -**(probe)**, `tools/AetherDb census` and `hardpoint-fit` over `GameData/Aetheria.cc`: -51 designs, 37 products, of which 18 are weapons. - -| Hardpoint kind | Designs | Sellers | -|---|---|---| -| Ballistic | 6 | Zhestokost 2, Lightsail 1, AU 1, DME 1, unsold 1 | -| Energy | 7 | NiteLife 3, Lucent 1, Adrasteia 1, Alakrita 1, unsold 1 | -| Launcher | 5 | DME 1, R&D 1, unsold 3 | - -Unsold, therefore unspawnable: `Autocannon`, `SRMM72`, `LRMM72`, `plight`, `pswarm`. -The earlier behaviour-class breakdown (8 `ProjectileManager`, 3 `LaserManager` at -velocity 0, 5 `GuidedProjectileManager`, 1 `LightningGunManager`, and **no design using -`ConstantWeaponData`, `HitscanEffect`, `Mine`, airburst or MIRV**) is carried from -`docs/headless-playground-cut.md:500-506` and was not re-measured; the census agent -could not reproduce it because `census` buckets by `HardpointType` and the one command -that prints behaviour fields was blocked. **Cut 4's safety argument depends on that -claim, so Hands re-measures it before Cut 4** (§4, First). - -**Stats a roll can read today** (`Weapon.cs:22-68`, all `PerformanceStat` unless noted): -`Damage`, `Penetration`, `DamageSpread`, `MinRange`, `Range`, `Energy`, `Heat`, -`Visibility`, `Spread` (an angular cone in degrees), `Velocity`; plus `DamageCurve` -(a `BezierCurve`, damage against normalized range), `MagazineSize`, `ReloadTime`, -`AmmoType`. `InstantWeaponData` adds `Count`, `BurstTime`, `Cooldown` -(`InstantWeapon.cs:13-22`). `LockWeaponData` adds `LockSpeed`, `SensorImpact`, -`LockAngle`, `DirectionImpact`, `Decay` (`LockWeapon.cs:13-25`). The item carries -`WeaponRange`, `WeaponCaliber`, `WeaponType`, `WeaponFireTypes`, `WeaponModifiers` -(`ItemData.cs:476-492`). - -Note against a misreading: **`DamageSpread` is not damage variance.** It is the -footprint, in schematic cells: `EntityInstance.cs:372-375` expands the hit shape -`round(hit.Spread)` times, where `hit.Spread` is the weapon's `DamageSpread`. - -**What is missing from the authored data for a roll to be meaningful:** - -- **No accuracy stat on anything.** No weapon, hull, cockpit or sensor contributes a - to-hit number. This is what R2's targeting system exists to supply, and it is the - whole reason the subsystem is required content rather than a nice-to-have. -- **No target-size term.** A roll that ignores how big the target is will feel wrong at - every range. The hull `Shape` and a cell size give an angular radius; `SchematicCellSize` - does not exist and must be authored (Q4). -- **No tracking or angular-velocity stat**, so nothing yet reads R3's deviation. The - targeting system supplies it. -- **Beams have nothing beam-specific.** `ConstantWeaponData` adds only `AmmoInterval` - (`ConstantWeapon.cs:15`); `Spread` is inherited and unread by any beam code, and - `Velocity` is meaningless for one. There is no dwell or wander stat. Unused today, so - Cut 4 gives beams the plainest possible rule and authors nothing new. -- **`ChargedWeaponData.ChargeFiringSpreadMultiplier` is a plain `float`** - (`ChargedWeapon.cs:34`), not a `PerformanceStat`, so unlike every other combat number - it cannot vary with quality or wear. Noted, not fixed here. -- **Lock is binary at 0.99** (`LockWeapon.cs:56`). There is no stat turning partial lock - into hit chance, and `GuidedWeaponData` (`Launcher.cs:37-55`) has no lock stats at all. - -### 0.5 Can the headless sim step a full engagement today - -No, and two specific things stop it **(read)**: - -1. `Combat.cs:100-102` indexes `HardpointTransforms`, which only Unity writes (§0.3). - `Minion` enters `CombatState` as soon as `VisibleEnemies` is non-empty, so any NPC - that sees an enemy throws on the tick it sees them. -2. There is no owner between `InstantWeapon.OnFire` and `Entity.HullDamage`. The only - `ServerShared` writers of durability are the repair consumable - (`Behaviors.cs:67,72-73`). - -Everything else is already there and already exercised. -`tests/Aetheria.Shared.Tests/IffAndCombatTests.cs:52` (`BuildWorld`) opens the real -catalog, builds an `ItemManager` and a `Zone`, and `:73` (`NewShip`) equips a gun on a -hardpoint and activates the ship — two ships, positions, look directions, sensor info, -targets and IFF, headless. `:241` and `:255` assert on `weapon.Progress` after -`Activate()`, because a cooldown starting is the only observable consequence of firing -that exists. That fixture is this campaign's harness; it does not need to be built. - -`Zone.Update` (`Zone.cs:126-151`) steps orbits, belts, agents, then entities. -`ItemManager.Random` is still clock-seeded (`ItemManager.cs:19`), so a seeded-roll test -needs a seed argument — see Q5. - -The xunit project is `tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj` -(net10.0, xunit), referencing `Aetheria.Shared/Aetheria.Shared.csproj` -(netstandard2.1, `noEngineReferences` mirrored by -`Assets/Scripts/ServerShared/Aetheria.Shared.Unity.asmdef`). The mutation harnesses are -`tests/mutation_tests_*.py`, 17 of them, each patching a named source anchor and -asserting a named test goes red with a no-op control. - ---- - -## Rulings (operator and Self, 2026-09-19) - -- **Q4, unaided accuracy: really, really bad.** Operator: "Targeting with the mark one eyeball - should be really, really bad." A ship with no targeting system can hit something close, - slow and unaware, and little else. That makes the targeting subsystem **required equipment** - rather than an upgrade: every hull needs somewhere to put one, the catalog needs them at - several price points, and a stripped hull is a genuine handicap. Hands picks the other two - balance numbers; this one is authored to make unaided fire a last resort, and the operator - tunes it in play. -- **Q1, turrets: author `FiringArc: 360`.** No new type for what a number already says. -- **Q2, player fire is arc-gated** like everyone else's. Manual and programmatic must not be - two truths. -- **Q3, death removes the ship**, and loot stays as it is until the pickup cut. -- **Q5, seed the rolls in this campaign.** Two lines here beats waiting on an unstarted cut. -- **Q6, snapshot at fire, not at commit** (Self, accepting Imagination's counter-proposal to - the stats map's §8 seam). A shot carries the gun that fired it: its parameters freeze when - the trigger is pulled, and nothing about the shooter's heat, power or wear changes them - mid-flight. Evasion still decides the outcome, because deviation is measured at arrival - against the predicted intercept. `docs/stats-and-power-cut.md` §8 said "snapshot at commit"; - that is superseded, and the resolver's promise (a current value, not a stable one) is - honoured either way. -- **Q7, mine proximity physics: leave it.** - -## 0b. Identity, lifecycle, authority - -One row per kind this campaign introduces. No cut below is mapped with a cell empty. - -| Kind | What names it | What happens to it over time | Who decides | -|---|---|---|---| -| **A shot in flight** | `PendingShot`, a struct in `Zone.PendingShots` (a `List`), identified by a monotonic `int ShotId` from `Zone`. Nothing outside `Zone` holds a reference. | Born in `FireControl.Fire` when a weapon's burst step fires, carrying a payload snapshot frozen at that instant. Ages by `dt` in `FireControl.Step`, called from `Zone.Update` after entities. Reaches its commit horizon, then its arrival time, then is removed the same tick it resolves. A shot whose source or target entity leaves the zone resolves as a miss and is removed. Never serialised: a zone saved mid-flight loses its shots, which is correct — a save is a scene boundary. | `Zone` owns the collection and the identity. `FireControl` owns the transitions. Nothing else may add, remove or mutate one. | -| **A roll** | Nothing. It is not a thing; it is one draw from `ItemManager.Random` inside `FireControl.Commit`, and only its result is named. | Exists for the duration of one call. Its inputs are the payload snapshot plus deviation measured at that instant; its output is written into the shot's `Outcome` and never recomputed. | `FireControl` alone. No behaviour, agent, presentation or UI may draw for a hit. | -| **A commit** | `ShotOutcome { int ShotId; Entity Source; Entity Target; EquippedItem Weapon; bool Hit; bool Shielded; EquippedItem Aimed; int2 Cell; float ArrivalIn; DamageType DamageType; }`, published on `Zone.ShotCommitted` (a `Subject`). | Created once, at `ArrivalTime - CommitHorizon`, or at fire time when the flight is shorter than the horizon. Immutable from then on. Published once. Read by presentations and by nothing else. Dies with the shot. | `FireControl` creates it and is the only writer. Presentations are the only readers. **Nothing outside presentation may read a commit to change state** (`docs/shield-presentation-contract.md:57`). | -| **A targeting solution** | Not a stored object. `FireControl.HitProbability(weapon, source, target)` is a pure function, recomputed on demand. | Has no lifetime. AI reads it to decide whether to fire; the HUD reads it to draw a number; `FireControl.Fire` reads it once per shot and freezes the result into the payload. Two reads a tick apart may differ; nothing caches one. | `FireControl`. The targeting-system behaviour owns only its own resolved stats; it does not compute probability. | -| **Target data / knowledge of a subsystem** | `Entity.EntityInfoGathered[other]` (existing, `Entity.cs:57`) is the knowledge. `FireControl.IsRevealed(observer, item)` is the derived predicate over it. The current aim point is `Entity.TargetItem`, a `ReactiveProperty`. | Info accumulates and decays continuously under `Sensor` (`Sensor.cs:152-184`); ownership of that does not change. Reveal is **derived on every read**, never stored and never cached, so it falls back the moment info decays. `TargetItem` is written only by `Entity.TrySelectTargetItem`, nulled when `Target` changes, and **read through a derived accessor that returns null if the item is no longer revealed or no longer belongs to the target** — no loop clears it. | `Sensor` owns info. `FireControl` owns the reveal rule and the thresholds it reads. `Entity` owns the selection slot; `TrySelectTargetItem` is its only writer. Player input, `CombatState` and the test harness are its three callers. | -| **A hardpoint arc** | `HardpointData.FiringArc`, a `float` on catalog data (`ItemData.cs:538-545`), zero meaning "use the default". Resolved through `FireControl.ArcFor(item)`. | Authored once, in the catalog. Immutable at runtime. Read per shot and per AI fire decision; never stored on the entity. Mount direction is likewise derived per read from `Behavior.Direction`'s item-rotation path. | The catalog authors it; `FireControl` resolves and applies it; `GameplaySettings.FiringArc` supplies the default. **No prefab, transform, articulation limit or renderer may influence it.** | - ---- - -## Cut order - -1. **Mount direction and arc become simulation data.** Deletes `HardpointTransforms` - and the Unity readback; adds the arc gate; unbreaks headless combat. -2. **Targeting system and reveal.** The new subsystem, the reveal rule, the selection - slot, and the two dead settings wired up. -3. **Hit authority moves.** The Physics path in the four effects the catalog actually - uses is deleted and `FireControl` plus the shot lifecycle replaces it, in one cut, so - damage is never applied twice. -4. **The unused kinds join the same path.** Beams, hitscan, mines, splash and airburst - stop querying Physics. They keep existing (R9). - -Cuts 3 and 4 cannot be swapped, and 3 cannot be split into "roll" and "delete the old -path": a cut boundary between them is a cut boundary with two damage owners. - ---- - -## Cut 1. Mount direction and arc become simulation data - -- **Repo/branch:** `Aetheria`, branch `codex/fire-control` from `codex/item-provenance` - `8a00fc92`. Depends on nothing. -- **First:** update `F:\Projects\Aetheria-stats` to HEAD (it sits 11 commits behind at - `c4f6029e`); capture `AetherDb census`, `loadout 1`, `dangling` as the before side. - -**Deletes first:** - -- `Assets/Scripts/ServerShared/Entity.cs:58-59` — `HardpointTransforms` (2). -- `Assets/Scripts/Gameplay/EntityInstance.cs:514-517` — its only writer (4). -- `Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:30-35` — the readback branch in - `Behavior.Direction` (6). The item-rotation path at `:37-38` becomes the whole body. -- `Assets/Scripts/ServerShared/Agents/States/Combat.cs:100-102` — the - `HardpointTransforms` dot test (3). -- `Assets/Scripts/ServerShared/Behaviors/TurretController.cs:81-83` — the `.99f` dot - test (3). -- `Assets/Scripts/ServerShared/Settings.cs:218` — `AgentFiringMinDot`, read by nothing - (1). - -**Adds:** - -- `Assets/Scripts/ServerShared/FireControl.cs` — a new static class, this cut holding - only geometry: `MountDirection(EquippedItem)` (the planar unit vector from - `Behavior.Direction`), `ArcFor(EquippedItem)` and - `bool InArc(EquippedItem weapon, float3 toTarget)`, which tests - `dot(normalize(weapon.Direction.xz), normalize(toTarget.xz)) >= cos(radians(arc/2))`. - The test is planar (R7); target height never enters it. An arc of 360 or more passes - unconditionally. -- `HardpointData.FiringArc` (`ItemData.cs:545`, next `Key`) — full width in degrees, - `0` meaning "use `GameplaySettings.FiringArc`". -- `GameplaySettings.FiringArc = 120` (`Settings.cs`, after `:214`). -- `GameplaySettings.AgentMinHitProbability` is **not** added here; Cut 1's AI gate is - `InArc` alone, which is strictly closer to today's behaviour than a probability the - roll does not yet produce. - -**Per-file changes (against `8a00fc92`):** - -- `Combat.cs:100-102`: `shouldFire = FireControl.InArc(testWeapon.Item, toTarget)`. The - intercept prediction at `:84-98` stays — it still aims `LookDirection`, and under R3 it - becomes the predicted intercept a deviation is measured against. -- `TurretController.cs:81-83`: the same call. Under the default arc a turret hull, whose - `Direction` never rotates (`TurretController` writes only `LookDirection`), would - engage only within 60 degrees of its spawn facing. R6 says turrets are 360; the turret - hull's hardpoints author `FiringArc: 360`. See Q1. -- Catalog: author `FiringArc` on the `Turret` hull's two Ballistic hardpoints. Follows - the Cut C precedent — a Hands scratch console upserts, Unity and Studio closed. - -**Authority map:** - -- **Owner:** `FireControl` owns whether a weapon bears on a point. `HardpointData` - authors the arc; `ItemRotation` authors the mount direction. -- **Inputs:** the equipped item's hardpoint rotation, `HardpointData.FiringArc`, - `GameplaySettings.FiringArc`, the planar vector to the target. -- **Outputs:** one bool, consumed by `CombatState`, `TurretController`, and from Cut 3 - by `FireControl.Fire`. -- **Derived state:** `Behavior.Direction` becomes purely derived from item rotation. -- **Forbidden writers:** no Unity transform, `ArticulationPoint`, `WeaponHardpoint` or - prefab node may influence aim. `Barrels`, `GetBarrel` and `ArticulationPoint` survive - as presentation and must never be read by `ServerShared`. -- **Shared paths:** player fire, AI fire and turret fire all go through `InArc`. Player - fire is arc-gated for the first time; see Q2. -- **Deletion line:** `HardpointTransforms` and its writer and readers are gone before - `FireControl.cs` is written. - -**Verification:** - -- builds: `Aetheria.Shared/Aetheria.Shared.csproj`, `tests/Aetheria.Shared.Tests`, - `tools/AetherDb`. -- tests, each named with the rule it pins: - - `ArcFollowsMountRotation` pins *mount direction is the item's rotation, not the - hull's facing*: a fixture hull with one weapon on a `None` hardpoint and one on a - `Clockwise` hardpoint; a target 90 degrees to starboard passes only for the - `Clockwise` weapon, a target dead ahead only for the `None` weapon. Mutation: make - `InArc` use `Entity.Direction`; swap `Rotate`'s `Clockwise`/`CounterClockwise` cases. - - `ArcBoundaryIsHalfWidth` pins *`FiringArc` is full width*: at 120, a target 59 - degrees off passes and one at 61 does not. Mutation: compare against - `cos(radians(arc))`. - - `ArcIsPlanar` pins R7: a target inside the arc horizontally but far above the firer - still passes. Mutation: use the 3D vector. - - `HardpointOverrideBeatsDefault` pins *the per-hardpoint override wins*: the same - weapon on a hardpoint with `FiringArc: 360` passes at 170 degrees, and fails there on - a hardpoint with `0`. Mutation: ignore the field; treat `0` as an arc of zero. - - `CombatStateStepsHeadless` pins *the simulation decides aim without Unity*: a - `Minion` with a target in range steps through `Zone.Update` without throwing. - Mutation: restore the `HardpointTransforms` read. -- negative: `rg -n "HardpointTransforms" Assets/Scripts` empty; - `rg -n "ArticulationPoint|FiringPoint|WeaponHardpoint" Assets/Scripts/ServerShared` - empty; `rg -n "AgentFiringMinDot" Assets/Scripts` empty. (`\.99f` is **not** a safe - negative grep — it occurs legitimately in `LockWeapon.cs:56`. Grep the two files by - name instead.) -- operator: in Unity, side-mounted weapons still fire at targets abeam; a turret still - tracks all the way around; nothing fires through the hull. - -**Risk:** this cut changes what the AI fires at before the roll exists, so combat feel -shifts once and shifts again in Cut 3. That is the price of deleting the obsolete -authority first, and it is the right price. - ---- - -## Cut 2. Targeting system and reveal - -- **Repo/branch:** continues Cut 1. Depends on Cut 1 (uses `FireControl.cs`), and on - provenance Cut C if that has not landed, because Cut C re-upserts every `ItemData`. -- **First:** `AetherDb census` and `loadout 1` over several seeds, as the before side. - -**Deletes first:** nothing. This cut is entirely additive, which needs its account -(CODE IS A LIABILITY): the invariant it buys is R5, which cannot be purchased by moving -authority, because no authority for it exists — two authored settings are read by -nothing and there is no subsystem to carry accuracy. Cut 3 is where the subtraction is. - -**Adds:** - -- `Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs` — - `TargetingSystemData : BehaviorData` (next free `Union` index on `BehaviorData`; Hands - reads the current list in `Behaviors.cs` rather than trusting the number 39 quoted in - the superseded draft) plus a `TargetingSystem : Behavior` that resolves and exposes: - - `Accuracy` (0..1) — the ceiling on hit probability. - - `Resolution` (detection threshold..1) — the info level at which sensor state stops - limiting hits. - - `Precision` (0..1) — the chance a hit lands on the aimed item rather than a random - cell. - - `Tracking` — how much deviation it forgives (consumed in Cut 3). - - All four are `PerformanceStat`, so lot quality, wear, heat and `PowerSupply` reach them - through `EquippedItem.Evaluate` — that is the brownout ruling applied to accuracy, and - it is why a starved targeting system rolls worse instead of switching off. Energy and - heat come from the existing `EnergyDraw` and `Heat` behaviours on the item; the - subsystem adds no power machinery of its own. -- `GameplaySettings.UnaidedAccuracy` — what an entity with no working targeting system - fires with. Such an entity uses `Resolution = 1` and `Precision = 0`: it can still - select a revealed item, and its hits scatter. -- `FireControl.IsRevealed(Entity observer, EquippedItem item)` — derived, never cached: - 1. Rank the target's non-hull equipment: hardpoint-mounted first, then size - descending, then equipment index. - 2. Item `i` of `N` is revealed when - `info >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1))`. - - This is the first and only reader of `Settings.cs:212-213`. -- `Entity.TargetItem` (`ReactiveProperty`, beside `Target` at - `Entity.cs:46`) and `bool Entity.TrySelectTargetItem(EquippedItem)`, the only writer. - It accepts null, or an item of `Target.Value` that `IsRevealed`. A `Target` change - nulls it. The read accessor returns null when the item is no longer revealed, so decay - drops the aim point without a loop. -- Player input: a select/cycle command beside `ActionGameManager.cs:368-403`, calling - `TrySelectTargetItem`. The target schematic - (`Assets/Scripts/UI/HUD/SchematicDisplay.cs:109-119`, which today lists only weapon - items) shows revealed items and marks the selection. That is presentation; it decides - nothing. -- AI: `CombatState` selects the revealed target `Weapon` item with the highest - `RangeDamagePerSecond(range)`, or null. -- Catalog: two targeting-system designs, 1-cell and 2-cell, each with products from at - least the manufacturers that sell the capacitor, so `IsAvailable` finds one in every - galaxy. `LoadoutGenerator.FillInterior` picks one with `required: true` for every - entity with `Weapons` — the starting LonginusX, NPC ships and turrets. Zenith, with no - weapon hardpoints, gets none. - -**Authority map:** - -- **Owner:** `FireControl` owns the reveal rule. `Entity` owns the selection slot. - `TargetingSystem` owns nothing but its own resolved stats. -- **Inputs:** `EntityInfoGathered`, the two threshold settings, the target's equipment - list and item sizes. -- **Outputs:** a bool per (observer, item); a nullable `EquippedItem` per entity. -- **Derived state:** reveal is derived on read. `TargetItem`'s *validity* is derived; - only the slot is stored. -- **Forbidden writers:** the HUD may not write `TargetItem` except through - `TrySelectTargetItem`; no code may store a reveal result; no code may read the two - thresholds except `IsRevealed`. -- **Shared paths:** player selection, AI selection and the test harness use one writer. -- **Deletion line:** none, and that is stated rather than hidden. - -**Verification:** - -- builds: as Cut 1. -- tests: - - `SelectionNeedsReveal` pins *you cannot aim at what you have not resolved*: below - the item's tier `TrySelectTargetItem` returns false; above it, true; a target change - nulls it; info decaying below the tier makes the read null. Mutation: `IsRevealed` - returns true; the `Target` change skips the null; the accessor returns the stored - value without re-checking. - - `RevealOrder` pins *the big obvious things resolve first*: hardpoint items reveal - before interior items, larger before smaller. Mutation: drop the ordering. - - `RevealSpansBothThresholds` pins *the tiers are the authored ones*: with N items, - the first reveals at `TargetArmorInfoThreshold` and the last at - `TargetGearInfoThreshold`. Mutation: swap them; use the detection threshold. - - `UnaidedFiresWorse` pins *the subsystem is what supplies accuracy*: an entity with - no targeting system resolves `UnaidedAccuracy` and `Precision` 0; one with a - destroyed or offline system resolves the same. Mutation: fall back to the system's - stats when offline. - - `StarvedTargetingRollsWorse` pins the brownout ruling on accuracy: with an - `Accuracy` stat carrying a `PowerSupply` term, a half-grant halves it rather than - switching the item off. Mutation: gate on `PowerSupply <= 1e-4`. - - `EveryArmedLoadoutGetsATargetingSystem` pins the required-item rule across seeds. - Mutation: drop `required: true`. -- negative: `rg -n "TargetArmorInfoThreshold|TargetGearInfoThreshold" Assets/Scripts` - returns `Settings.cs` and `FireControl.cs` **only**. -- operator: `AetherDb census` shows 2 more designs; `loadout 1` shows one more interior - item per armed entity; `dangling` is 0; reopen reports 0 schema issues; CultCache - Studio renders the new behaviour union (an operator check, because Studio's handling of - a new union index is not something this map can promise). - -**Risk:** `LoadoutGenerator.EquipHardpoints` can already fit the Tractor Beam into -LonginusX's only Sensors slot, leaving an NPC that gathers no info and therefore reveals -nothing and hits nothing (`docs/headless-playground-cut.md:520-523`). Check `loadout` -over several seeds. The fix is data or `EquipHardpoints`, not fire control. - ---- - -## Cut 3. Hit authority moves - -The rebuild. The old path is deleted and the new owner added in the same cut, because -any boundary between them is a boundary with two damage owners. - -- **Repo/branch:** continues Cut 2. Depends on Cuts 1 and 2. -- **First:** capture a Unity play smoke of current combat feel (kill time against one - NPC, roughly) as the before side, since this cut changes feel more than anything in - the campaign. - -**Deletes first** (paths and line counts against `8a00fc92`): - -- `Assets/Scripts/Gameplay/EntityInstance.cs:277-396` — `DamageSchematic` and both - `HullCollider` subscriptions (120). -- `Assets/Scripts/Gameplay/HullCollider.cs:13-14,18-41,48-65` — `Hit`, `Splash`, - `SendHit`, `SendSplash` and both argument classes (~45 of 65). `OnCollisionEnter` - (`:43-46`) stays; ship collision is a deferred Unity-physics surface. -- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:57-103` — the raycast, shield branch and - `SendHit` (47). `:109-122` (airburst) is Cut 4's. -- `Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs:152-189` — the same (38). -- `Assets/Scripts/Gameplay/Weapons/Laser.cs:40-75` — the same; the `LineRenderer` - endpoint writes at `:59,70,77` are presentation and are rebuilt from the outcome (36). -- `Assets/Scripts/Gameplay/Weapons/Lightning.cs:34-72` — the same; the endpoint capture - at `:67-71` stays as a visual (39). -- `Assets/Scripts/Gameplay/EntityInstance.cs:411-431` — the `UnityEngine.Random` loot - roll, **only if Q3 is answered (a)**; otherwise this cut leaves it and the seam is - named in §6. - -**Adds:** - -- `Entity.DamageSchematic(float damage, Shape hitShape)` — the rule moved verbatim from - `EntityInstance.cs:277-314`, now a method on `Entity` in `ServerShared`, writing - `Armor`, item `Durability`, `Hull.Durability` and raising the existing `ArmorDamage`, - `ItemDamage` and `HullDamage` subjects. Not a redesign: the same arithmetic, the same - order, the same thresholds. -- `Entity.ApplyHit(...)` — the shape construction moved from - `EntityInstance.cs:334-394`, with two changes forced by R7: the hit cell comes from the - rolled `Cell` rather than a UV texture coordinate, and the penetration march rotates - the firer-to-target vector into the target's frame with a planar rotation by - `-target.Direction` instead of `transform.InverseTransformDirection`. -- `FireControl.HitProbability(weapon, source, target)` — pure, no draw: - - Zero, with no draw consumed, when there is no target; the target is not in - `VisibleEntities`; range is outside `[MinRange, Range]`; a `LockWeapon` is not - locked; or `InArc` fails. - - Otherwise `p = Accuracy * pSensor * pSpread`, where - `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info))` and - `pSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1`, with - `angularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range))`. - - `SchematicCellSize` is the one new tuning constant (Q4). -- `FireControl.Fire(weapon, item, source)` — called once per burst step from - `InstantWeapon.Execute` (replacing the bare `OnFire?.Invoke()` at - `InstantWeapon.cs:228`). It computes the predicted intercept, the flight time - (`range / Velocity`, or zero when `Velocity` is at or near zero), **freezes the payload - snapshot** (R10) and appends a `PendingShot` to `Zone.PendingShots`. The snapshot holds - `Damage`, `Penetration`, `DamageSpread`, `DamageType`, `Aimed`, the resolved targeting - stats, and the intercept point. Nothing downstream re-evaluates a stat. -- `FireControl.Step(Zone, dt)` — called from `Zone.Update` after the entity loop - (`Zone.cs:150`). For each pending shot: age it; at `ArrivalTime - CommitHorizon` - compute deviation, roll, write `Outcome`, publish `Zone.ShotCommitted`; at - `ArrivalTime` apply the outcome and remove the shot. A shot whose flight is shorter - than the horizon commits at fire time, in the same call as `Fire` — that is every - velocity-0 weapon, which is three of the catalog's eighteen. -- `GameplaySettings.CommitHorizon` — R4's authored horizon, one number, in seconds. -- The deviation term: `pDeviation` folds in how far the target is from the predicted - intercept at commit time, forgiven by the targeting system's `Tracking`. This is R3's - evasion, and the commit horizon is what bounds how late it counts. -- The roll: one draw against `p`. On a hit, a second draw against `Precision` decides - where — on `Aimed`'s footprint cells on success, otherwise on one cell drawn uniformly - over the hull `Shape`. An active shield that `CanTakeHit` takes the hit instead, and - the outcome is marked `Shielded`; that rule now exists once, in `FireControl`, rather - than seven times in the effects. -- `Zone.ShotCommitted` and `Zone.ShotResolved` (`Subject`), the presentation - feed. -- `InstantWeapon.OnFire` becomes `event Action` carrying the `ShotId`, so the - Unity manager can bind its effect to the shot it belongs to. - -**Per-file changes:** - -- `InstantWeapon.cs:228`: `OnFire?.Invoke(FireControl.Fire(this, Item, Entity))`. - Charge multipliers already fold into `Damage` and `Spread` before this point. -- `Combat.cs:100-102`: - `shouldFire = FireControl.HitProbability(...) >= Settings.AgentMinHitProbability`, - replacing Cut 1's bare `InArc` (which the probability already subsumes, returning zero - out of arc). `AgentMinHitProbability` is added here, not in Cut 1. -- `TurretController.cs:81-83`: the same gate and threshold. -- `EntityInstance.cs:203-204,232-235`: the manager call takes the `ShotId` and looks the - outcome up, or subscribes to `ShotCommitted`. Effects fly to the rolled cell's world - point or to a miss offset and play the impact on arrival. **They apply nothing.** -- `Zone.cs:150`: `FireControl.Step(this, deltaTime)` after the entity loop. - -**Authority map:** - -- **Owner:** `FireControl` owns the engage gate, hit probability, the roll, the cell, the - shield branch, and when each of those happens. `Entity` owns the application of damage - to its own schematic. `Zone` owns the pending-shot collection. -- **Inputs:** weapon stats through `EquippedItem.Evaluate`; targeting-system stats; - `EntityInfoGathered`; `Target` and `TargetItem`; both entities' positions, velocities - and planar directions; mount direction and arc; `ItemManager.Random`. -- **Outputs:** `ShotOutcome` on two subjects; `Entity.DamageSchematic`; shield - `TakeHit`; `Entity.IncomingHit`. -- **Derived state:** `Behavior.Direction`, reveal, hit probability. Everything a - presentation shows about a shot is derived from the outcome. -- **Forbidden writers:** after this cut no `Physics.*` query in - `Assets/Scripts/Gameplay/Weapons` (except the unused four Cut 4 handles) may reach - durability; `HullCollider` may not raise a hit; `EntityInstance` may not damage a - schematic; no effect may call `TakeHit`; nothing outside `FireControl` may draw for a - hit; nothing outside presentation may read a commit to change state. -- **Shared paths:** player fire, AI fire, turret fire, instant, charged, guided and - locked weapons all reach damage through `FireControl.Fire` and `FireControl.Step`. - There is exactly one commit primitive. -- **Deletion line:** the six deletions above land before `FireControl.Fire` exists. - -**Verification:** - -- builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`; and Unity - batchmode with the editor closed (`docs/item-provenance-cut.md` §6) reporting no - `error CS`. -- tests, each with the mutation that must kill it: - - | Test | Pins | Mutation | - |---|---|---| - | `RollsAreSeeded` | *a fight is reproducible* — two zones, same seed, 50 shots, identical outcome sequences | `FireControl` draws from `new Random()` | - | `ProbabilityFollowsInputs` | *the roll reads what the ruling says it reads* — zero below detection and outside range; rising with info to `Resolution`; falling with `Spread`; capped at `UnaidedAccuracy` with no system | drop `pSensor`; drop the range gate; drop the cap | - | `OutOfArcConsumesNoDraw` | *the gate is a gate, not a penalty* — the RNG sequence is unchanged by an out-of-arc trigger | roll first and multiply by zero | - | `ShotResolvesOnArrival` | R3 — no durability change before `range / Velocity`; a target removed mid-flight takes none | apply at fire time | - | `OutcomeCommitsBeforeImpact` | R4 — `ShotCommitted` fires exactly `CommitHorizon` before `ShotResolved`, once per shot | publish at arrival; publish twice | - | `EvasionCountsUntilCommitAndNotAfter` | R4's second half — a target that jinks before the horizon changes the outcome distribution; the identical jink after the horizon does not | measure deviation at arrival; measure it at fire | - | `ShortFlightCommitsAtFire` | *the horizon degrades gracefully* — a velocity-0 weapon commits and resolves in one tick, still publishing both events in order | skip the commit for short flights | - | `OutcomeIsSnapshotNotReread` | R10, the seam the stats campaign named — a weapon whose `Damage` collapses (power cut, heat spike) between fire and arrival still lands the damage it rolled | re-evaluate `Damage` at arrival | - | `AimedHitLandsOnSelectedItem` | R5's payoff — `Precision` 1 and `p` 1: only the aimed item's durability falls | the roll ignores `Aimed` | - | `HardpointHitDamagesItemThenHull` | the damage rule survived the move verbatim — damage above armor plus item durability zeroes the item and the remainder hits the hull | armor not subtracted; hull skipped | - | `PenetrationMarchIsPlanar` | R7 — the march rotates by the target's planar direction and gives the same cells whatever the height difference | use a 3D rotation | - | `ShieldTakesHit` | *the absorb rule has one owner* — an active shield absorbs and the schematic is untouched | remove the shield branch | - | `DeadEntityStopsTakingShots` | *a shot cannot damage a corpse* — a target destroyed before arrival takes nothing and the shot is removed | resolve against a removed entity | - -- negative greps, each checked against legitimate names before publishing: - - `rg -n "Physics\." Assets/Scripts/Gameplay/Weapons/Projectile.cs Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs Assets/Scripts/Gameplay/Weapons/Laser.cs Assets/Scripts/Gameplay/Weapons/Lightning.cs Assets/Scripts/Gameplay/HullCollider.cs` - — empty. (A repo-wide `Physics\.` grep is **not** valid until Cut 4, and never for - `TractorBeam.cs`, which stays.) - - `rg -n "SendHit|SendSplash|DamageSchematic" Assets/Scripts --glob '!**/ServerShared/**'` - — empty. - - `rg -n "TakeHit\(" Assets/Scripts --glob '!**/ServerShared/**'` — empty. Note - `CanTakeHit` shares the substring; the trailing `(` on `TakeHit` distinguishes them, - and the `Can` prefix must be checked by eye in the first run. -- operator play smoke: shots show rolled impacts and misses; a miss reads as a - deliberate near-miss rather than a bug; damage matches the HUD; aiming at a revealed - subsystem visibly concentrates damage there; kill time is in the same order as before. - -**Risks.** `UnaidedAccuracy`, `SchematicCellSize`, `CommitHorizon` and the targeting stat -ranges are first guesses. The headless fixture is the tuning harness, and the operator -smoke is the arbiter. Second: `Combat.cs:84-98` aims `LookDirection` at a first-order -intercept, and R3's deviation is measured against `FireControl`'s own predicted -intercept; if the two predictions differ, an AI will systematically miss. They must be -one function. Hands makes `FireControl` own the prediction and `CombatState` call it. - ---- - -## Cut 4. The unused kinds join the same path - -R9 keeps every weapon type. This cut makes the ones no design currently ships obey the -same owner, so that authoring one later does not resurrect Unity hit detection. - -- **Repo/branch:** continues Cut 3. Depends on Cut 3. -- **First:** **re-measure the claim this cut's safety rests on** — that no live design - uses `ConstantWeaponData`, `HitscanEffect`, `Mine`, airburst or MIRV (§0.4, carried - from a superseded map and not reproduced by this campaign's probe). A behaviour-class - census needs `AetherDb roles-migrate` as a dry run, which the permission classifier - blocked; the operator can unblock it or Hands can add a read-only `behaviors` command. - **If any of these is live, it moves into Cut 3 instead.** - -**Deletes first:** - -- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:109-122` — airburst `OverlapSphere` and - `SendSplash` (14). -- `Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs:74-110` (~37), - `ConstantLightning.cs:54-70` (~17), `HitscanEffect.cs:31-55` (~25), - `Mine.cs:68-75,92-117` (~34), `ConstantParticleWeapon.cs:72` and its `_hull` plumbing - (~8) — every remaining `Physics.*` hit query and `SendHit`/`SendSplash` call in - `Assets/Scripts/Gameplay/Weapons`. - -**Adds:** - -- `ConstantWeapon` rolls once per `GameplaySettings.BeamResolveInterval` for - `Damage * interval`, through the same `FireControl.Fire`/`Step` pair with a flight time - of zero, raising `OnBeamShot(int shotId)`. About ten lines; no design uses it yet, and - it authors no new stat. -- Splash — airburst and mine blast — as `FireControl.Splash`, applying the existing - `DamageSchematic` over the directional half-shape the deleted - `EntityInstance.cs:318-329` computed, against every entity in radius. This is the one - genuinely new rule in the cut, and it exists because R9 keeps the flak cannon and the - mine. -- Nothing else. `Mine`, `TractorBeam` and ship collision stay Unity-physics surfaces for - *placement and contact*, which is not hit detection; only their damage application - moves. - -**Authority map:** unchanged from Cut 3 except that the forbidden-writer list now covers -every file in `Assets/Scripts/Gameplay/Weapons`, and `FireControl` gains splash. - -**Verification:** - -- builds: as Cut 3. -- tests: - - `BeamRollsPerInterval` pins *a beam is a sequence of rolls, not a continuous truth*: - over one second a beam produces `1 / BeamResolveInterval` outcomes totalling - `Damage`. Mutation: roll per tick; roll once. - - `SplashHitsEveryEntityInRadius` pins *splash is one rule, not per-effect*: two - targets in radius both take damage, one outside takes none. Mutation: damage only - the nearest. - - `SplashIsDirectional` pins that the moved half-shape rule survived: cells facing the - blast take it, cells facing away do not. Mutation: damage the whole shape. -- negative: `rg -n "Physics\." Assets/Scripts/Gameplay/Weapons` returns **only** - `Mine.cs`'s placement query, if Q7 keeps it — otherwise empty. `TractorBeam.cs:21` is - outside this directory and stays. - `rg -n "SendHit|SendSplash" Assets/Scripts` empty. - `rg -n "class HullHitEventArgs|class HullSplashEventArgs" Assets/Scripts` empty. -- operator: nothing to check in play, because nothing in the catalog spawns these. The - check is a Studio authoring pass: author one beam product and one flak product onto a - test hull and confirm they damage. - ---- - -## 5. Subtraction ledger - -Estimates, to be compared with reality in the postmortem. - -| Cut | Removed | Added | Deps / targets / formats | -|---|---|---|---| -| 1 | 19 production (Entity 2, EntityInstance 4, Behaviors 6, Combat 3, TurretController 3, Settings 1) | ~60 production (`FireControl.cs` geometry ~45, `HardpointData.FiringArc` 1, `GameplaySettings.FiringArc` 1, call sites ~13), ~90 test | +1 catalog field, authored on one hull. No new project, package or dependency. | -| 2 | 0 | ~140 production (`TargetingSystem.cs` ~55, reveal ~25, `Entity.TargetItem` ~25, AI selection ~10, player input ~15, `LoadoutGenerator` ~12), ~160 test | +1 behaviour union index, +2 designs and their products, +1 setting. | -| 3 | ~285 production (EntityInstance 120, HullCollider 45, Projectile 47, GuidedProjectile 38, Laser 36, Lightning 39) | ~330 production (`Entity.DamageSchematic`/`ApplyHit` ~130 moved, `FireControl` roll and lifecycle ~140, `Zone` pending shots ~35, call sites ~25), ~280 test | +2 settings. **Seven duplicated copies of the shield-absorb rule collapse to one.** | -| 4 | ~135 production | ~70 production, ~70 test | +1 setting. | -| **Total** | **~439** | **~600 production, ~600 test** | | - -Net production about +160. The account: it buys the invariant that the simulation -resolves combat without Unity — `docs/three-gates-scope.md:101-110` calls today's state -a velocity compromise — and it collapses seven copies of the absorb rule and one copy of -the damage rule into single owners. It does **not** buy that with a registry, an adapter, -a mode flag or a compatibility layer: the old path is deleted in the cut that replaces -it, and no cut leaves two writers of damage. - -The line count is the wrong thing to stare at here. The structural delta is: one new -owner (`FireControl`), one new subsystem (`TargetingSystem`), one field on catalog data, -four settings, and **minus one whole category of authority** — Unity physics deciding -gameplay. - ---- - -## 6. Build budget - -- **Packages and targets.** `Aetheria.Shared/Aetheria.Shared.csproj` (netstandard2.1), - `tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj` (net10.0, xunit), - `tools/AetherDb/AetherDb.csproj`. Three projects, no new ones. Debug only; no release - profile, no feature flags, no code generation, no new target platform. -- **Build host and target are the same machine**: a Windows workstation producing - managed assemblies for the same runtime. Nothing here is cross-platform, so no claim - about a Linux artifact is made or needed. -- **Where.** `F:\Projects\Aetheria-stats` (the free worktree; it must first be advanced - from `c4f6029e` to HEAD). Never the main tree — the operator plays there. Never - `F:\Projects\Aetheria-tiers`. CultLib via - `-p:CultLibRoot=\cultlib-45c2f40`, the shared pinned worktree, which is - never removed. `--artifacts-path` into scratch so no `bin`/`obj` lands in a working - tree. -- **Footprint.** Three managed projects plus their test host; on the order of a few - hundred MB of artifacts, comparable to the stats campaign. No expected growth: this - campaign adds no project, package reference or native component. -- **Unity.** Batchmode compile check with the editor closed, per - `docs/item-provenance-cut.md` §6, once per cut that touches `Assets/Scripts/Gameplay` - (Cuts 1, 3, 4). The operator's play smoke is the only Unity *run*. Imagination did not - run Unity and did not build. -- **Mutation harnesses.** One per cut, in the established shape - (`tests/mutation_tests_fire_control_cut1..4.py`): patch a named anchor, run - `dotnet test tests/Aetheria.Shared.Tests --cultlib-root `, assert the named test - goes red, with a no-op control. Note the scar from `8dddda4d`: later cuts move earlier - anchors, so Cut 4 re-anchors Cuts 1-3's harnesses before it lands. - ---- - -## 7. Seams this campaign names and stops at - -- **Presentation of shots.** `Zone.ShotCommitted` and `Zone.ShotResolved` are the whole - contract. What a projectile, panel, whip or near-miss *does* with a commit belongs to - `docs/shield-presentation-contract.md` and the panel and Lariat maps. This campaign - guarantees only that the commit exists, is authoritative, arrives `CommitHorizon` - before impact, and carries what the contract says it carries: what happens, where, - when it arrives, and which capability answers it — not who fired, not damage numbers. - The contract's open question "where the commit window lives" is answered here: - `FireControl`, with the horizon in `GameplaySettings`. -- **Death, removal and loot.** `Entity.Death` is already composed in `ServerShared` - (`Entity.cs:363-366`), but `Zone.Entities.Remove` and the loot roll live in Unity - (`EntityInstance.cs:411-437`) and use `UnityEngine.Random`. Fire control makes a - headless kill *happen*; it does not make the corpse go away. Q3. -- **Pickup.** `ShieldManager.cs:40-51` stores loot on trigger contact. Untouched. -- **Ship collision, `TractorBeam`, mine placement.** Deferred Unity-physics surfaces - (`docs/three-gates-scope.md:98-99`). Untouched. -- **The navigation planner.** R3 makes manoeuvring evasion, which changes what a good - flight path is. The planner is a separate item in the shipping scope and this map does - not anticipate it. -- **Stance and IFF.** Ruled and partly built (`Weapon.StanceAllowsFire`, - `Weapon.cs:101`). Fire control reads the gate and does not redesign it. -- **Faction territory.** The campaign after this. - ---- - -## 8. Operator questions - -Most blocking first. - -**Q1. What makes a turret 360?** R6 says turrets are 360 degrees, but a turret hull's -`Direction` never rotates — `TurretController` writes only `LookDirection` — so under a -120-degree default a turret would only engage within 60 degrees of its spawn facing. -**A:** author `FiringArc: 360` on the `Turret` hull's hardpoints; the override R6 already -grants does the whole job and no code knows what a turret is. **B:** add -`HardpointType.Turret` and give it an implicit 360. **C:** give the arc to the hull -rather than the hardpoint. **Recommended: A**, because it buys the invariant with -authored data instead of a new type, and because a future partially-traversing turret -(a 270-degree sponson) is then already expressible. B makes the arc a property of a -category and loses that. - -**Q2. Is player fire arc-gated?** Today it is not gated at all; the player fires whatever -is equipped, wherever they point. Cut 1 makes the arc a shared rule. **A:** yes, gate the -player exactly as the AI — a side-mounted gun simply does not fire forward, and the HUD -shows which groups bear. **B:** gate the AI only, leaving the player's rear gun able to -shoot forward. **Recommended: A.** Under R1 aiming is gone, so what the player does with -a weapon group *is* the decision, and arcs are what make that decision interesting. B -also splits the shared path the doctrine cares about: manual and programmatic fire would -be two truths. It is a real difficulty change and should be felt in the smoke before the -campaign closes. - -**Q3. Does this campaign move death removal and loot, or leave them in Unity?** A -headless kill currently leaves the corpse in `Zone.Entities`, because `Entities.Remove` -is at `EntityInstance.cs:437`. **A:** move removal and the loot roll into `Zone` in -Cut 3, using `ItemManager.Random` — about 50 lines, and it makes a headless kill a -complete event. **B:** move only the removal; leave loot to the run-structure work. -**C:** leave both; fire control's tests assert on durability, not on removal. -**Recommended: B.** Removal is the half without which the simulation is lying about its -own contents, and a seeded loot roll pulls in drop velocity, bay capacity and pickup — -the run-structure campaign's material. A is more complete but widens this campaign; C -leaves `ServerShared` unable to say who is alive. - -**Q4. Three balance numbers need first values:** `CommitHorizon` (R4's "short fixed -time"), `SchematicCellSize` (metres per schematic cell, which sets how much target size -matters), and `UnaidedAccuracy`. **A:** Hands picks defaults (0.3s, measured off the -LonginusX prefab, 0.35) and the operator tunes them in the smoke. **B:** the operator -states them now. **Recommended: A** for the first two and **B for `UnaidedAccuracy`**, -because it is not a tuning number — it decides whether a targeting system is a -requirement or an upgrade, which is a design ruling. If it is low, Cut 2's required-item -rule is load-bearing; if it is high, the subsystem is a nice-to-have and Cut 2 shrinks. - -**Q5. What seeds the rolls?** `ItemManager.Random` is clock-seeded -(`ItemManager.cs:19`), so `RollsAreSeeded` cannot pass without a seed argument. -**A:** add the seed parameter here, as a two-line change. **B:** wait for the `Run` -lifecycle work that owns it (`docs/headless-playground-cut.md` Cut 1, unlanded). -**Recommended: A.** A reproducible fight is how this campaign is tuned and how a -regression is ever found again; waiting on an unstarted cut for two lines is the -compensator pattern in miniature. - -**Q6. When is the snapshot taken — fire, or commit?** `docs/stats-and-power-cut.md:1020` -says "at commit time". **A:** one snapshot, at fire: the shot carries what left the -barrel, and only deviation is measured up to the commit. **B:** two snapshots — payload -at fire, targeting stats at commit — so a targeting system knocked out mid-flight -degrades the shot already in the air. **Recommended: A.** One rule, one freeze point, and -over a horizon measured in tenths of a second B's extra fidelity is invisible while its -extra state is permanent. B is the more simulationist answer and is worth having if the -operator wants killing the gunnery computer to spoil shots already fired. - -**Q7. Mines.** Cut 4 moves mine *damage* to `FireControl.Splash` but leaves -`Mine.cs:68`'s `OverlapSphere` for proximity triggering, which is contact detection -rather than hit detection. **A:** leave it, alongside `TractorBeam` and ship collision, -as a named deferred physics surface. **B:** move proximity into `Zone` too, so the last -`Physics.*` in the weapons directory goes. **Recommended: A.** No design ships a mine, -and B buys a clean grep rather than an invariant. Revisit when a mine is authored. - ---- - -## Cut 5: the rules that landed in one path and not its twin - -Date: 2026-09-19. Branch `codex/fire-control-5` off `ca57819d`. - -Soul's pass over the whole campaign (2026-09-19) confirmed eleven findings. Most -of them are the same shape: a rule this map declared, implemented on one path, -and left off the path beside it. Splash breaks a shield the discrete roll does -not. `InstantWeapon` gates on arc and `ConstantWeapon` does not. Unity removes a -corpse from the zone and the simulation does not. This cut closes the twins. -Catalog authoring (Soul's findings 1 and 2) is Cut 6; the shared-RNG stream -(finding 6) and airburst (finding 5) are Cut 6 as well; the missing mutation -harnesses (finding 8) are Cut 7. - -### 5.1 Tracking is a floor, not a cliff (Soul finding 3) - -`FireControl.cs:102-106` falls back to `Tracking = 0` with no targeting system, -and `:273-275` turns that zero into `deviation < .01f ? 1f : 0f`. A probe fired -334 shots at a target drifting at 5 u/s with `Accuracy` forced to 1 and landed -none. Q4 asked for unaided fire to be really, really bad; this is a wall. - -- `Settings.cs`, beside `UnaidedAccuracy`: add `public float UnaidedTracking = 10f;` - with the same authoring note. Ten world units of deviation forgiveness is the - first guess and the operator's knob: a target that has strayed five units from - its fire-time projection halves an already-5% chance. -- `FireControl.Tracking` falls back to `settings.UnaidedTracking`, exactly as - `Accuracy` falls back to `UnaidedAccuracy`. One shape for both. -- `Commit` then deletes the zero branch outright: `p *= saturate(1f - deviation / shot.Tracking)`. - Tracking is now always positive, so the branch protects nothing. - -**Authority:** `Settings` owns the unaided figures; `FireControl.Tracking` is the -only reader; `Commit` no longer carries a special case for a value that cannot occur. - -### 5.2 A shot the shield cannot absorb breaks it (Soul finding 4) - -`Commit:299-302` sets `Shielded` only when `CanTakeHit` passes, and `Apply:311-326` -routes the rest to the hull -- neither calls `Break()`. `Splash:365` does. The -deleted Unity path did (`git show b7743789^:Assets/Scripts/Gameplay/Weapons/Projectile.cs`). -So the F4/F5 shield-break mechanic is unreachable for every weapon in the catalog: -an unabsorbable 5000-damage hit leaves `Broken == false` and the shield absorbs the -very next shot, refilling at `RefillDuration` instead of the punitive `RestoreDuration`. - -- `ShotOutcome` gains `public bool ShieldBroken;`. -- `Commit` decides it where it already asks `CanTakeHit`: shield present, active, - and `CanTakeHit` false means `ShieldBroken = true`. The decision is frozen with - the rest of the outcome (R4) -- a shield that recharges during the flight does - not retroactively survive. -- `Apply` performs it: `if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();` - before the hull damage. `CanTakeHit` is still called exactly once per shot. -- Presentation gains the signal for free; nothing is required to read it yet. - -### 5.3 Continuous weapons obey their arc (Soul finding 9) - -`ConstantWeapon.cs:91,99` gate on `StanceAllowsFire` alone. A side-mounted beam -fires forward. Q2 says manual and programmatic must not be two truths; instant and -continuous currently are. - -- Both gates become `StanceAllowsFire && ArcAllowsFire`, matching `InstantWeapon.cs:105`. - -### 5.4 One bearing test, including at zero range (Soul finding 11) - -`Weapon.cs:117` allows a zero-length bearing; `FireControl.InArc:47` normalizes the -zero vector into NaN and `HitProbability` returns 0. The player's trigger says fire -and the probability says impossible, so AI and turrets refuse to fire at a -co-located target. - -- `InArc` takes the rule: a planar bearing shorter than `1e-6` returns true -- - point-blank cannot fail a bearing test. -- `Weapon.ArcAllowsFire` drops its own `lengthsq` special case and simply calls - `InArc`. One owner, one answer. - -### 5.5 A destroyed subsystem stops being the aim point (Soul finding 10) - -A destroyed item is never removed from `Equipment` (`Entity.cs:475` only observes -it), so `IsRevealed` still ranks it and `ResolvedTargetItem` still returns it -- -`Precision` keeps concentrating hits onto a dead item's footprint. - -- `FireControl.IsRevealed` returns false for an item whose - `EquippableItem.Durability < .01f`, and the ranking excludes destroyed items so - the reveal tiers of the survivors close up rather than leaving a gap. -- `Entity.ResolvedTargetItem` therefore drops a destroyed aim point on its next - read. Nothing caches it, so no clearing pass is needed. - -### 5.6 Death removes the ship, in the simulation (Soul finding 7) - -Operator ruling Q3 says death removes the ship. `Zone.Entities.Remove` for death -exists only at `EntityInstance.cs:324`, inside Unity's loot-drop subscription. So -headless, `Step`'s `targetGone` guard never fires, shots land on corpses, `Splash` -keeps hitting them, the AI keeps engaging them, and the tuning harness this map -calls its arbiter cannot end a fight. `DeadEntityStopsTakingShots` passes only -because it calls `Entities.Remove` by hand. - -- `Zone` subscribes each entity's `Death` observable when the entity joins, and - removes it from `Entities` on death. The simulation owns the corpse. -- `EntityInstance.cs:324` deletes its `Entities.Remove` call and keeps the loot - drop and destroy effect, which are its own business. Forbidden writer: no - presentation removes an entity from the zone. - -### 5.7 Delete the two carried-and-unread fields (Soul finding 12) - -`PendingShot.PredictedIntercept` and `PendingShot.FlightTime` are written at -`FireControl.cs:204-205` and read nowhere. The intercept in particular is -carry-weight from Cut 3's named risk: `Combat.cs:109` and `Fire` do share -`FireControl.PredictedIntercept`, but the roll judges deviation against a -straight-line projection from fire position, so the stored copy decides nothing. - -- Both fields are deleted from the struct. The local `flightTime` stays; it still - computes `ArrivalTime` and `CommitTime`. - -### Verification - -Each of 5.1-5.7 gets a test that fails under its own mutation, and the mutation -harness is committed as `tests/mutation_tests_fire_control_cut5.sh` with a no-op -control. In addition this cut pins four of the five mutations that survived 197 -green tests in Soul's pass, because they are rules 5.1-5.6 touch: - -- `FireControl.cs:365` -- delete `shield.Break()` from `Splash`. Must die. -- `Weapon.cs:117` -- `ArcAllowsFire => true`. Must die. -- `InstantWeapon.cs:105` -- delete the `if (!ArcAllowsFire) return;` player arc gate - (Q2, the ruling Cut 1 deferred to Cut 3 and nothing verifies). Must die. -- `FireControl.cs:270` -- `elapsed = shot.FlightTime` in place of `now - shot.FireTime`. - This is the map's own declared mutation for `EvasionCountsUntilCommitAndNotAfter`, - and it survives because that test's fixture gives the target zero velocity, so - `FireTargetVelocity * elapsed` is identically zero and the mutation is a no-op - inside it. Give the fixture a moving target. (After 5.7 deletes `FlightTime` the - mutation's spelling becomes `elapsed = shot.ArrivalTime - shot.FireTime`; the rule - it attacks is the same.) - -The fifth survivor -- freezing `source.TargetItem.Value` instead of -`ResolvedTargetItem` at `FireControl.cs:193`, bypassing the reveal re-check -- is -Cut 7's, alongside the missing Cut 1-3 harnesses. - -Negative checks: no `Entities.Remove` for death outside `Zone`; no `Tracking` read -that can return zero; no `PredictedIntercept` or `FlightTime` field on `PendingShot`. - ---- - -## Cut 6: the dice stop being shared, and the flak cannon works again - -Date: 2026-09-19. Branch `codex/fire-control-6` off Cut 5. - -Cut 6a (catalog authoring, worktree `Aetheria-catalog`, branch -`codex/fire-control-6-catalog`) runs in parallel and is specified by Soul's -findings 1 and 2 plus the Cut 2 authoring spec above. This section is Cut 6b, the -two remaining code findings. - -### 6.1 A shot's dice belong to the shot (Soul finding 6) - -`Commit:266,304` draws from `ItemManager.Random`, a single stream shared with -`ActionGameManager.cs:552,558,567,686,787` (wormhole exit velocity, loadout -generation, travel) and `Combat.cs:144` (`SampleDps`, drawn per AI per -evaluation). Q5's reproducible fight therefore holds only inside a hermetic -fixture: in play, adding an NPC or opening a shop changes every subsequent die. -Soul's determinism probes passed for exactly that reason, which makes them a -demonstration of the fixture rather than of the rule. - -The fix is a deletion, not a second stream. A stream makes the roll depend on -*how many* draws came before it; what this campaign actually promised is that a -shot's outcome is a function of the shot. So make it one. - -- `Zone` exposes `public uint CombatSeed { get; }`, set in the constructor from - the same expression that already seeds `_random`: - `galaxyZone?.Name.StableHash() ?? 1337u`. One zone, one stable identity — a - galaxy seed reproduces it, and nothing else in the zone can perturb it. -- `Commit` stops touching `ItemManager.Random` entirely. It builds its own local - `CultMath.Random` from the zone seed and the shot's own id: - `var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);` - The `| 1u` guards the degenerate zero seed. Every draw the commit makes — the - hit roll, the precision roll, the cell pick — comes from that one local - generator, and it dies with the call. -- The write-back at `:304` (`shot.Source.ItemManager.Random = random;`) is - deleted with it. - -**Authority:** the roll's randomness is a pure function of `(zone identity, shot -id)`. **Forbidden writers:** nothing outside `Commit` may seed or advance a -combat draw, and `Commit` may not read a shared stream. **Consequence worth -stating:** two shots that happen to share a `ShotId` across two zones roll -differently, because the zone seed differs; two runs of the same fight from the -same galaxy seed roll identically no matter what the UI did in between, which is -the promise Q5 actually made. - -### 6.2 Airburst resolves in the simulation (Soul finding 5) - -Cut 4 deleted `Projectile.cs`'s `OverlapSphere`/`SendSplash` and added -`FireControl.Splash` "because R9 keeps the flak cannon and the mine". Only the -mine was wired (`Mine.cs:99`). `Projectile.cs:16-17` still carries -`AirburstDistance`/`AirburstRange`, still written by `ProjectileManager.cs:32`, -read by nothing — so a flak round flies and does nothing. The stale comment at -`Projectile.cs:7-8` calls airburst "Cut 4's — untouched here". - -The wiring does **not** go back into `Projectile`. R8 says presentation does not -decide damage, and a projectile MonoBehaviour calling `Splash` would be exactly -the authority Cut 3 spent itself deleting. Airburst is a property of the weapon, -so the simulation resolves it: - -- `PendingShot` regains a burst point — `public float3 BurstPosition;` — frozen - at fire like every other payload field, from `PredictedIntercept` (which Cut 5 - deletes as unread; this is its live consumer, and it comes back named for what - it is rather than for what computed it). -- `PendingShot` gains `public float BurstRadius;`, frozen from the weapon's - authored airburst range, and zero for a weapon without the `Airburst` flag - (`Enums.cs:101`). -- `FireControl.Fire` sets both when the weapon's `WeaponData` carries `Airburst`. -- `FireControl.Step`, at arrival, resolves an airburst shot by calling - `Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType)` - **instead of** `Apply`. An airburst round does not also roll a discrete hit: - it is an area effect, which is the whole point of the flag, and applying both - would be the double-application Soul was told to hunt for. -- `Projectile.cs` deletes `AirburstDistance` and `AirburstRange` and the stale - comment; `ProjectileManager.cs:32` deletes the line that wrote one. The - projectile's remaining job is to fly and disappear, which is all presentation - should have been doing. - -**Open tuning question, recorded not asked:** the authored airburst radius has no -sibling in the catalog to argue from, because no product has ever used it. Cut 6a -or a later authoring pass sets it; until then the flag is wired and unexercised, -and the report must say so rather than implying a working flak cannon. - -### Verification - -Committed harness `tests/mutation_tests_fire_control_cut6.sh`, no-op control -required, same byte-exact I/O rules as Cut 5. - -- `SameFightRollsSameThroughUnrelatedDraws` pins 6.1: run a fixed fight twice from - one zone seed, drawing an arbitrary number of values from `ItemManager.Random` - between the two runs, and the outcomes must be identical. Mutation: restore the - `ItemManager.Random` draw. Must die — and note that this is the test Soul's own - probe could not be, because a hermetic fixture cannot see the defect. -- `ShotIdDecidesTheDie` pins that the roll is a function of the shot: two shots - with the same frozen payload and different ids may differ; the same id in the - same zone always gives the same result. Mutation: drop `ShotId` from the seed. -- `AirburstSplashesAndDoesNotAlsoRoll` pins 6.2: an airburst shot damages - everything in its radius, and its target takes area damage rather than a - discrete cell hit. Mutation: call `Apply` as well as `Splash`. Must die. -- `NonAirburstNeverSplashes` pins the other side. Mutation: splash unconditionally. -- negative: `ItemManager.Random` appears nowhere in `FireControl.cs`; - `Airburst` appears nowhere in `Assets/Scripts/Gameplay`. - ---- - -## Cut 6c: the formula gets fixed, and the single point of failure - -Date: 2026-09-19. Branch off Cut 6a (`e5f1318a`) once Cut 5 has merged — this -touches `FireControl.cs`, which Cut 5 owns until then. - -Cut 6a landed the catalog data that Cut 2 never shipped: 53 designs, 43 products, -`loadout` green on ten seeds, turret arcs migrated. Reviewing it turned up two -defects it introduced and one it inherited. - -### 6c.1 Resolution becomes reciprocal, so the name stops lying - -`HitProbability` computes `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution(source), info))`. -With the live `TargetDetectionInfoThreshold` of `.1`, a **higher** Resolution -means **more** gathered info is needed before sensor state stops limiting hits. -Resolution is a cost. The field's own comment says so accurately -- "the info -level at which sensor state stops limiting hits" -- and the word "Resolution" -says the opposite, because in every other context a higher resolution is a -better instrument. - -Cut 6a authored the 1-cell `Targeting Computer` at `.3` and the 2-cell -`Fire Control Array` at `.75`. Against a target scanned to info `.3`, that gives -the cheap design `pSensor = 1.0` and the 150000c design `pSensor = 0.31`. The -premium item is strictly worse until the player has scanned hard, which is not a -tradeoff anyone authored -- it is the formula misleading an author who had read -it and said so in the same paragraph. - -Operator ruling, 2026-09-19: "When the formula is unintuitive, we don't make the -handle more opaque for correctness, we fix the formula. Plenty of core gameplay -formulas are reciprocal just for this reason." An earlier draft of this cut -proposed renaming the field to `SensorDemand`; that is superseded. The name -stays `Resolution`, higher stays better, and the formula takes the reciprocal: - -- `HitProbability` derives the ceiling rather than reading it directly: - `var demandCeiling = detection + (1f - detection) / max(Resolution(source), 1e-3f);` - then `pSensor = saturate(unlerp(detection, demandCeiling, info))`. -- Resolution `1` therefore means "needs complete information before sensor data - stops penalising this system" -- which is precisely what the unaided fallback - of `1f` should mean. That fallback stays as it is and is now the floor by - construction instead of by coincidence. The guard against a zero or negative - authored Resolution is the `max`, and no authored value should ever reach it. -- Re-author both designs on the new scale: 1-cell `2` (sensor-limited until info - reaches `.55`), 2-cell `4` (until `.325`). Both comfortably better than unaided, - the premium one better than the cheap one, and the numbers now read the way a - designer reaching for the knob expects. -- `TargetingSystemData.Resolution`'s comment gains one sentence naming the - direction and the reciprocal, so the next author does not have to derive it. - -### 6c.2 One seller is a die roll against ship generation - -Cut 6a sold both targeting designs from NiteLife Energy alone, satisfying the -Cut 2 spec's letter ("at least the manufacturers that sell the capacitor") while -missing what that clause was for. `Galaxy.cs:109` selects -`factions.OrderBy(random).Take(settings.MegaCount)` — a **random subset** of the -twelve factions — plus the quest faction and the authored neutrals. A galaxy that -does not draw NiteLife has no targeting product, `IsAvailable` -(`LoadoutGenerator.cs:154`) filters every candidate out, and -`FillInterior`'s `required: true` throws `InvalidLoadoutException` again. Cut 6a's -ten green seeds do not disprove this; they sample the draw. - -Two changes, because content coverage and structural fragility are different -problems and only one of them is fixed by authoring: - -- **The generator degrades instead of throwing.** A `required: true` interior - item with no available product leaves the entity without one rather than - failing generation outright. This is only safe *because* Cut 5.1 gave unaided - fire a floor: such a ship now fires badly instead of not at all. The generator - logs the gap so it surfaces as content debt rather than silence, and `AetherDb - loadout` reports it per seed. -- **Thematic sellers, which also buy coverage.** NiteLife Energy is the roster's - power-generation faction; targeting belongs to Finch Cybernetics (passive - sensors) and Lucent Media (active sensors), both of which the roster names for - exactly this. Each design gains products from both. That is three sellers per - design, and it puts the item in the hands of the factions whose brand explains - why they make it. - -### 6c.3 Recorded, not fixed - -`Large Drive`'s new product declares no roles because the design declares none. -That is honest, and it is also a design with no role axes in a game where roles -are how products differentiate. It belongs to the content pass, not here. - -### Verification - -- A test pins 6c.1's direction: two entities, identical but for `Resolution`, at - the same info level -- the **higher** Resolution has the higher - `HitProbability`, at every info level between detection and full. Mutation: - drop the reciprocal and read Resolution as the ceiling directly (the shipped - behaviour). Must die. This is the assertion whose absence let a premium item - ship strictly worse than a cheap one. -- A test pins 6c.2: a galaxy containing no seller of any targeting design still - generates every hull, and those entities resolve `UnaidedAccuracy`. Mutation: - restore the throw. Must die. -- `AetherDb loadout` across at least 50 seeds, plus a run with the faction set - forced to exclude all three sellers. Report the gap count, not just "no throw". -- Census unchanged at 53 designs; products rise by 4 (two more sellers per design). - ---- - -## Cut 7: the harnesses that were prose - -Date: 2026-09-19. Branch `codex/fire-control-7` off Cut 6. - -`git log --all -- tests/mutation_tests_fire_control*` returns exactly one commit, -`034e8d3e`, adding `cut4` alone. §6 of this map declares `cut1..4`. So the Cut 3 -mutation table above (seeding, the commit horizon, the frozen snapshot, the -shield branch, the penetration march, the roll itself) is prose: it names the -mutations that should die and nothing has ever run them. - -That is not a paperwork gap. Soul wrote five mutations against rules this map -claims to pin and **all five survived 197 green tests**, including -`InstantWeapon.cs:105`, which is operator ruling Q2 — the one thing Cut 1 -explicitly deferred to Cut 3. Cut 5 pins four of them because it touches their -rules. This cut pins the fifth and builds the harnesses that should have existed -since Cut 1, so that every verdict this campaign has recorded becomes reproducible -rather than remembered. - -### 7.1 The fifth survivor: the reveal re-check - -`FireControl.cs:193` freezes `Aimed = source.ResolvedTargetItem`. Mutating it to -`source.TargetItem.Value` bypasses the reveal re-check — you could aim at a -subsystem you have lost resolution on — and 197 tests stayed green. - -- A test pins that an aim point whose reveal has decayed below its tier is **not** - frozen into the shot: the shot still fires, at a random cell, with no `Aimed`. - Mutation: read the raw `TargetItem.Value`. Must die. -- Note the interaction with Cut 5.5: a *destroyed* aimed item must fall out by the - same path, through `IsRevealed`, not through a second check. If pinning this - needs two tests, the rule has two owners and that is itself the finding. - -### 7.2 Harnesses for Cuts 1, 2 and 3 - -Three committed scripts — `tests/mutation_tests_fire_control_cut1.sh`, `cut2.sh`, -`cut3.sh` — each with a no-op control, byte-exact I/O, anchors matching exactly -once, and a reverse-write restore. Each runs the mutation table its cut already -declares in this document; do not invent a new table, and do not quietly -substitute an easier mutation for one that will not die. - -**Every survivor is a finding to report, never a test to weaken.** The expected -cause, seen twice in this campaign already, is a fixture too weak to distinguish -the mutant: `EvasionCountsUntilCommitAndNotAfter` survived its own declared -mutation because its target had zero velocity, so the sabotage was arithmetically -a no-op. When a mutation survives, first ask whether the fixture differs from the -rule's actual precondition, fix the fixture, and say so in the report. - -Where a declared mutation genuinely cannot be reached by the current suite, write -it up as **not yet reached**, never as unreachable, and name what reaching it -would take. Both times that call was made elsewhere in this project it was wrong, -and a fifteen-line probe killed the mutant. - -### 7.3 What the harnesses are allowed to assume - -Cut 6a authors the first targeting-system designs into the catalog. A harness that -depends on catalog content is a harness that breaks when content changes, so -these suites build their own fixtures through the same synthetic-upsert path -`TargetingSystemTests.cs:368-403` already uses. That file is also a cautionary -example: it passes by upserting a synthetic design into a throwaway cache, which -pins the generator and says nothing about whether the shipped catalog can -generate a ship. Both are worth having; only one of them was, and the map claimed -the other. - -So: **one test in this cut asserts against the live catalog** — that -`AetheriaStores.Open` over `GameData/Aetheria.cc` yields a targeting system the -player's own hull can be generated with. That is the assertion whose absence let -Cut 2 ship code without data. - -### Verification - -- All four harnesses (cut1-3 plus the existing cut4) run clean: every declared - mutation dies, every no-op control leaves the suite green. -- The report carries the full table — cut, mutation, died/survived, and for any - survivor the fixture defect behind it. -- `dotnet test` green, with the count. - ---- - -## Cut 6d: where a hit lands is a dart throw, not a coin flip - -Date: 2026-09-19. Operator direction, 2026-09-19. - -### The defect - -`FireControl.Commit` places a hit like this: - -```csharp -if (aimedCells != null && aimedCells.Length > 0 && random.NextFloat() < shot.Precision) - cell = aimedCells[random.NextInt(aimedCells.Length)]; -else - cell = coords[random.NextInt(coords.Length)]; -``` - -`Precision` is a coin flip between *exactly on the aimed item* and *uniformly -anywhere on the hull*. Aim at a stern thruster with Precision `.35` and the 65% -of hits that miss it are spread evenly over the bow, the cockpit and everything -else — no nearer the thruster than if the player had never aimed at all. There is -no spatial term anywhere in it. - -This is the same shape as 5.1's Tracking cliff: a quantity that should be a -gradient implemented as a binary, so the failure case is maximally wrong rather -than slightly wrong. - -Operator, 2026-09-19: "I would presume that aiming for an item in the stern would -still tend hit in that general area even if it misses the item. Like throwing a -dart at the grid." - -### The rule - -One Gaussian kernel over the target's hull schematic, in cell units: - -- **Aim point.** The centroid of the aimed item's cells, or the hull's - `CenterOfMass` when nothing is aimed at. One path — the uniform-random branch - is deleted, not demoted. -- **Sigma.** Derived from the frozen `shot.Precision`, higher Precision giving a - tighter group. Authored so that the unaided floor (`UnaidedPrecision`, joining - `UnaidedAccuracy` and `UnaidedTracking` in `GameplaySettings`) is a sigma wide - enough to read as spraying at the silhouette. -- **Weights.** `w(cell) = exp(-d² / 2σ²)` for each occupied hull cell, `d` the - distance from the aim point. - -### The tradeoff, and why it cannot be a second miss stage - -Operator, 2026-09-19: "It would also mean that aiming can be a tradeoff; if -you're not precise enough, you're better off aiming center mass than risking -misses on a thin limb." - -That tradeoff only exists if a stray dart can miss the ship. An earlier draft of -this section clamped a stray to the nearest occupied cell, which destroys it: -aiming at a wingtip would cost nothing and merely redistribute damage inward. - -But resolving the stray as a *second* miss stage would break R3 — one roll -decides damage — and make `HitProbability` a lie, since a shot could pass the -roll and then fizzle on placement. So the off-hull mass is priced into the single -roll instead: - -``` -pOnHull = Σ w(cell) / (2πσ²) -``` - -the share of the kernel's total mass that lands on metal. - -- `HitProbability` gains `pOnHull` as a factor. Aiming at a thin limb with a - loose group drops the number **on the HUD, before the trigger** — the tradeoff - is legible at decision time rather than discovered afterwards. -- `Commit` draws the cell as a weighted pick over the same `w(cell)`. A hit - always lands on metal, because the chance of not landing on metal was already - charged at the roll. - -One kernel, computed by one function, used for both the probability and the -placement. Cut 3's named risk was two functions answering "where will this shot -go" and eventually disagreeing; this does not reintroduce it. - -`pOnHull` depends only on the aim point, the hull shape and the frozen -`Precision`, so it folds into `PBase` at fire time and the freeze discipline -(R10, Q6) is unchanged. - -### Consequences, named rather than discovered - -- **Every hit in the game moves**, not only aimed ones: unaimed fire becomes - "aim at the centre of mass with the worst sigma", so big central sections soak - more fire than extremities. That is the intended change, and it is a balance - change. -- **A fat hull is inherently easier to hit than a needle**, for free. -- **The two targeting products' authored `Precision` values (`.05-.2` and - `.35-.55`) become meaningless as written**, because they are probabilities and - this makes Precision a grouping tightness. They are re-authored in the same - pass as Cut 6c's reciprocal `Resolution`. -- **Double-charging risk.** `HitProbability` already carries `pSpread`, which - penalises a small target against a wide weapon spread. `pOnHull` also - penalises small targets. Check whether the two charge the same thing twice - before authoring numbers; if they do, one of them owns it and the other goes. -- **AI willingness shifts.** `Combat.cs` and `TurretController.cs` gate on - `AgentMinHitProbability` against this number, so NPCs will hold fire more than - they used to the moment `pOnHull` lands. That is a tuning pass, not a bug, but - it will show up in the first smoke and should not be mistaken for broken AI. - -### Verification - -- `AimingAtTheSternHitsTheStern`: with a mid-range Precision, hits aimed at a - stern item land in the stern half far more often than the bow half. Mutation: - restore the uniform-random branch. Must die. This is the assertion the shipped - coin flip would fail. -- `ThinLimbCostsHitChance`: `HitProbability` against an aim point on a - single-cell extremity is strictly lower than against the centre of mass, at the - same Precision — and the gap widens as Precision falls. Mutation: drop - `pOnHull` from the probability. Must die. -- `PlacementAndProbabilityShareOneKernel`: the empirical on-hull rate over many - seeded draws matches the `pOnHull` the probability reported, within tolerance. - Mutation: perturb sigma in one of the two call sites only. Must die — this is - the test that would catch the two functions drifting apart. -- `EveryHitLandsOnMetal`: no committed hit ever resolves to an unoccupied cell, - across seeds and aim points including extremities. - -### 7.4 Amendment (2026-09-19): the live-catalog test has a second job - -7.3 above asks for one test that opens the real `GameData/Aetheria.cc` through -`AetheriaStores.Open`, because Cut 2 shipped a `required: true` rule with no data -to satisfy it and every green suite in the campaign missed it. - -Merging Cuts 6b and 6c gave that test a second and larger job. 6b added -`WeaponItemData.AirburstRange` as a non-nullable `float` at a new MessagePack -key. Every weapon record in the shipped catalog was serialized before that key -existed, so the reader was handed `nil` for the absent slot, a `float` cannot -take nil, and `AetheriaStores.Open` threw outright — the game could not read its -own catalog. Fixed at `c896489b` by making the field `float?`. - -Neither cut could have caught it. 6b verified with unit tests over synthetic -fixtures and never opened the shipped catalog; 6c opened the catalog constantly -but on a branch without the field. The defect existed only in the merge, and -only when something read real data. - -So the live-catalog test is not a content check that happens to touch a file. It -is **the campaign's only assertion that the shipped data is readable by the -shipped code at all**, and it is the one test whose absence let a branch that -bricks the catalog pass every gate. Write it so it fails loudly on a -deserialization throw, not only on a missing design: - -- `ShippedCatalogOpensAndGeneratesAnArmedHull`: open the real catalog read-only - through `AetheriaStores.Open`, enumerate every `EquippableItemData` so every - document type is actually deserialized (a lazy read that never touches - `WeaponItemData` would have passed on the broken merge), and generate a - loadout for `LonginusX`. Any exception is a failure. -- Mutation: none needed for the throw path — the shipped catalog either - deserializes or it does not. For the generation half, the Cut 2 mutation - applies: drop the targeting designs' products and the test must go red. - -**The standing rule this cut records**, because it will recur every time a field -is added: a new non-nullable value-type field on a persisted CultCache document -cannot read records written before it existed. Nullable, or a migration that -rewrites every affected record — and the migration cannot run while the read -still throws, so tolerant deserialization comes first either way. - ---- - -## Cut 8: a removed entity is a dead entity, and the anchors the diagnostics moved - -Date: 2026-09-22. Branch `codex/fire-control-8` off `72c0109c`. - -Operator play smoke, 2026-09-20: heat spike on entry with the ship unable to -move, a nearby ship likewise glowing hot and motionless, shots that registered no -hits, and finally a NullReferenceException out of `LockWeapon` reaching -`Entity.EntityInfoGathered` as the other ship vanished. Diagnosis (Codex, relayed -by the operator): the other ship's behaviors updated after the entity had been -cleaned out. Not reproducible afterwards, which makes it a hidden bug rather than -a fixed one -- the diagnostics commit `72c0109c` changed timing and allocation, -not the cause. - -### 8.1 Removal and deactivation are one transition - -`Entity.Deactivate` (`Entity.cs:445`) disposes every subscription, clears -`EntityInfoGathered`, `VisibleEntities`, `VisibleEnemies` and `VisibleFriendlies`, -and sets `_active = false`. `Zone.TryDock` (`Entity.cs:943`) has always paired the -two: `Zone.Entities.Remove(ship); ship.Deactivate();`. Removal from the zone means -the entity stops living there. - -Cut 5.6 added the death path and did only half of it. `Zone.cs:78`: - - Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => Entities.Remove(add.Value))); - -The entity leaves the collection and stays **active**, holding live subscriptions -and stale references, while every surviving entity's `ObserveRemove` handler -(`Entity.cs:200-213`) nulls its `Target` and drops it from `EntityInfoGathered`, -`EntityHostility` and the visibility sets. Half the world has forgotten it and it -does not know it is dead. - -- The death subscription becomes `Entities.Remove(e); e.Deactivate();`, matching - `TryDock`'s existing pairing exactly. One transition, one owner. -- **Ordering risk to verify, not assume:** `EntityInstance` also subscribes to - `Entity.Death` (its loot drop and destroy effect, `EntityInstance.cs:300-325`). - Subscription order decides whether loot drops before or after `Deactivate`. - `Deactivate` does not touch `Equipment` or `CargoBays`, so loot should survive, - but this must be confirmed in play rather than reasoned about -- if loot stops - dropping, that is this cut's fault and the deactivate moves after it. - -### 8.2 A deactivated entity does not update - -`Zone.Update` (`Zone.cs:172`) iterates a snapshot: - - foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); - -so an entity removed part-way through a frame still receives its `Update` in that -same frame, after the rest of the world has dropped its references. `Entity.Update` -(`Entity.cs:1031`) never consults `_active`. Every behaviour then runs against -torn-down state, and `LockWeapon.cs:98` reaches `Entity.EntityInfoGathered[Entity.Target.Value]` -through a raw indexer. - -- `Entity.Update` returns immediately when `!_active`. - -This is the fix, and the raw indexer is deliberately **not** the fix. Guarding -`LockWeapon` with `TryGetValue` would silence this one call site and leave every -other behaviour running on a corpse -- the compensator the doctrine warns about. -Liveness is the entity's own property and the entity is the one place to enforce -it. `IsActive` already exists at `Entity.cs:116`; nothing was asking it. - -### 8.3 The other symptoms are not explained by this, and are not closed - -The heat spike, the motionless ships and the shots that registered no hits are -**unexplained**. One coherent story fits all three -- an item's heat driving the -targeting system offline makes `FireControl.Accuracy` fall back to -`UnaidedAccuracy` (`.05`), which reads exactly like "I can fire but nothing -lands," and an overheated thruster reads as "I cannot move" -- but that is a -hypothesis, not a finding, and nothing here has reproduced it. - -What can be said from the source: `ActionGameManager.cs:1281` passes -`Time.deltaTime` into `Zone.Update` unclamped, and `Zone.cs:160` divides by it -(`orbit.Value.Velocity = (Position - PreviousPosition) / deltaTime`). Unity caps -`Time.deltaTime` at `Time.maximumDeltaTime`, so a large spike is bounded, but a -**zero or near-zero** first frame is not bounded from below and that division is -unguarded. Worth a probe before it is worth a fix. - -This cut does not chase it. It records the symptom, fixes the defect that is -proven, and leaves the rest open with the evidence that exists -- an unreproducible -heat spike is not something to patch speculatively. - -### 8.4 Re-anchor the four harnesses the diagnostics refactor stranded - -`72c0109c` routed `HitProbability` through a new `FireControl.Inspect`, replacing -five early returns with flags and one gate. That is the campaign's own doctrine -applied correctly -- the operator's comment says it outright, that keeping the -calculation here stops the debug HUD growing a second, subtly different fire -control model -- and it rewrote the exact lines four harnesses anchor into. - -Seven anchors now fail: `cut3` (2), `cut5` (1, its **no-op control**), `cut6c` -(2, including its control), `cut6d` (2). Every one reports `tree-clean: PASS`, so -nothing leaked, but a harness whose control cannot run is defending nothing. - -- Re-anchor all seven onto the current spelling in `Inspect`. The rules they - attack are unchanged; only the text moved. -- A control that cannot find its anchor must stay a hard failure. Do not make - anchors fuzzy to survive refactors -- loudly stale is the property that makes - these harnesses trustworthy, and it is what surfaced this within one run. - -### Verification - -- `DeadEntityDoesNotUpdate`: an entity killed mid-frame receives no further - `Update`, and a `LockWeapon` on a ship whose target dies the same frame does not - throw. Mutation: drop the `_active` guard. Must die. -- `DeathDeactivates`: a dead entity reports `IsActive == false` and holds no live - subscriptions. Mutation: remove the `Deactivate()` call. Must die. -- `LootStillDropsOnDeath` if it can be reached headlessly; otherwise an explicit - operator check, named as such rather than assumed. -- All seven harnesses clean with tree-clean PASS, controls included. -- 222 tests plus the new ones; catalog reads; Unity batchmode exit 0. - ---- - -## Cut 9: the die is not a die - -Date: 2026-09-22. Branch `codex/fire-control-9` off `dcb8c448`. - -Soul pass over `ca57819d..dcb8c448` (2026-09-22). Ten findings; this cut takes the -three that block play. The rest are Cut 10. - -### 9.1 The hit roll has a range of 0.125 (Soul C1). **Blocking.** - -`FireControl.cs:356`: - - var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u); - ... - var hit = p > 0f && random.NextFloat() < p; - -`CultMath.Random` is xorshift32 and `NextFloat()` returns the top 24 bits of the -**first** round (`Random.cs:22-42`). One round does not carry a low-bit seed -difference into the high bits. `ShotId` varies only in the low bits, so the top -three bits of the first draw are fixed by the zone seed alone. - -Measured, 2000 shot ids per zone: **every zone's roll spans 0.1249**. Reproduced -independently with a different hash function — the span is structural, not a -property of `StableHash`. The consequence is that a shot is a step function of -`p` whose threshold is fixed when the zone is created: below the band it never -hits, above it always does. An unaided shooter hit 0 of 18,926 shots against the -shipped catalog. Roughly one zone in eight cannot land a fully aided shot at -p = .74. - -This is the operator's "I can target and fire with no hits registering at all," -and it is stable per zone, which is why it came and went. - -It is also this map's own prescription (Cut 6b, §6.1), written by Self, shipped -through three cuts, and never once measured. - -**The rule:** the roll must be uniform on [0,1). Not merely deterministic — -determinism is what a constant already gives, and is exactly why three -determinism tests passed over it. - -- Mix the seed before the first draw, with a standard 32-bit finalizer - (`fmix32`/`splitmix32`: xor-shift, multiply, xor-shift, multiply, xor-shift). - The per-shot seed derivation is unchanged; only its diffusion into the first - output changes. Determinism, the `(zone, shot id)` purity and the freeze - discipline all survive untouched. -- **This is a caller's obligation, not a CultMath defect.** Seeding xorshift with - a structured value and drawing once is the documented way to get a correlated - first output. Record a follow-up to give `CultMath.Random` a mixed-seed entry - point so no other GameCult consumer re-derives this the hard way — that is a - CultLib cut with its own release, not this one. - -**Verification, and the test whose absence caused this:** - -- `TheDieIsUniform`: fire many shots across several zone seeds at an - intermediate `p` (say .3 and .6) and assert the observed hit fraction is near - `p`, and that the observed rolls span substantially more than 0.2. Mutation: - remove the finalizer. Must die. -- No test anywhere in this campaign measures a distribution. Every behavioural - fixture uses `accuracy: 1` or `accuracy: 0`, which is why a die stuck on one - face satisfied all of them. That gap is the finding, not the line of code. - -### 9.2 `pOnHull` collapses to zero above Precision ~4 (Soul C2). **Blocking.** - -`FireControl.cs:497-516`. `Sigma = 1/Precision`, and once sigma falls below about -half a cell every `exp(-d²/2σ²)` underflows against an aim point that is not -exactly on a cell centre — which is **every shipped hull**, since no centre of -mass is integral (LonginusX 2.5/6.697, Zenith 5.5/5.5, Turret 3.5/3.5). Measured -`pOnHull` on LonginusX: `.516` at Precision .3, `1.000` at 1.19, `.112` at 5, -`.000` at 10 and beyond. - -So a better targeting system makes you miss, and past Precision ≈ 5 the weapon -cannot hit anything. Authored content sits at .5-1.3, just under the cliff, with -a margin of about 4x — and the operator is about to tune this number. - -- Clamp sigma at a floor of about half a cell, or normalize by the discrete sum - rather than the continuous 2πσ². Either makes `pOnHull` monotonic in Precision - over the whole domain instead of only below the cliff. -- `WeightedPick` (`:535-545`) carries a comment claiming it "can never fall - through." It can, whenever `totalWeight == 0`, returning a fixed corner cell. - It is unreachable today only because `pOnHull == 0` forces `PBase == 0` first. - Fix the cause and delete the false comment. -- Cut 6d's map section claims to name its consequences; it does not name this. - Add it. - -### 9.3 The LockWeapon crash is still reachable (Soul C3). **High.** - -Cut 8 fixed a real liveness defect and did **not** fix the operator's crash. -`LockWeapon.cs:98` only runs on an *active* entity, so 8.2's `!_active` guard -cannot be what prevents it — `tests/mutation_tests_fire_control_cut8.sh:17-19` -says as much outright, and I merged it anyway. - -The live precondition is `Target.Value` set but absent from `EntityInfoGathered` -on an active entity, and docking still reaches it: `Deactivate` (`Entity.cs:445`) -disposes the `Entities.ObserveRemove` subscription that is the only thing nulling -a stale `Target`, and nulls nothing itself; `Activate` (`Entity.cs:167`) reseeds -`EntityInfoGathered` and likewise leaves `Target` alone. Dock, let the target die -or leave, undock, and the first frame throws. - -- `Deactivate` nulls `Target` (and `TargetItem`), or `Activate` reconciles it - against the reseeded `EntityInfoGathered`. One of the two owns it; say which. -- Still not a `TryGetValue` in `LockWeapon`. The invariant is that an active - entity's `Target` is always a live entity it has info on. -- Pin it with the dock/die/undock sequence, not with a unit test on the guard. - -### Verification - -- The three tests above, each dying under its own mutation, in a committed - `tests/mutation_tests_fire_control_cut9.sh` with a no-op control, byte-exact - I/O, sha256-verified restore and an explicit tree-clean verdict. -- All nine harnesses sequentially, one at a time, all clean. -- 226 tests plus the new ones; catalog reads; `dangling` 0. -- After 9.1 lands, re-measure the fight: Soul's probe, run with a corrected die, - gave 60.8% hit rate and a 4.3 s median time to kill for an aided shooter, 2.6% - unaided. Confirm the shipped build now reproduces that, because those are the - numbers the operator will tune against. - -### Recorded for Cut 10, not fixed here - -Soul C4 (a committed hit on a target that dies mid-flight publishes as a hit), -C5 (`Inspect` is numerically exact but costs 9.87 µs and 448 B per call on paths -that used to bail — the operator's own comment on the hot path looking like a -diagnostic one has an empirical answer now), C6 (`RollsAreSeeded` is obsolete — -Cut 7 retired one such test and missed this one), C7 (`FireControlCut6cTests` -authors `Precision = 0`, making six of seven checks vacuous), C8 (`Precision = -1000` fixtures make several kernel-shape mutants unkillable, and `pOnHull`'s -deletion is caught by exactly one test), C9 (`EveryHitLandsOnMetal` runs one -configuration, not the range it claims), plus the harness-audit items: the -tree-clean verdict restores before it measures, a red result cannot distinguish a -killed mutant from a compile failure, and the no-op control covers only one file -per harness. - ---- - -## Cut 11: mutation testing moves to Stryker, and the survivors it found - -Date: 2026-09-22. Operator, on the hand-written anchor harnesses: "What is the -point of those mutation anchors? Tests should cover how the code behaves, not how -it is shaped." And on Stryker: "sounds good; we don't need to run with every -mutation active, right? > to >= on float thresholds is indeed petty." - -### Why - -The Eureka principle (2026-09-15) is sound and stays: every rule needs a test that -fails when the rule breaks. The mechanism that accreted on it over the following -week -- literal-text anchors, perl substitution, per-cut bash and Python harnesses --- coupled mutation testing to code shape. It stranded anchors on every refactor -(`72c0109c`, Cut 9, Cut 10), and at Cut 10 an anchor kept matching exactly once -while silently re-targeting onto `Inspect`, mutating code the tests no longer ran. -Soul's harness audit also found the tree-clean verdict restoring before it -measured, and a compile failure counting as a kill. - -A Stryker spike against `FireControl.cs` alone: 276 mutants, 7m18s, 208 killed, -67 survived. It killed every mutation the hand-written harnesses defended, never -edits a source file, and has nothing to re-anchor. - -### 11.1 Retire the ServerShared harnesses - -Delete the 23 hand-rolled harnesses (5,816 lines) whose targets are all under -`Assets/Scripts/ServerShared` -- every `mutation_tests_fire_control_*`, every -`mutation_tests_stats_power_*`, `mutation_tests_shield_reserve.py`, -`mutation_tests_condition_ratio_cut8.py` and `mutation_tests.py`. Stryker's scope -covers all of their targets; the behavioural tests they measured stay. - -**Kept, recorded, not yet answered:** `mutation_tests_addressables_cut.py` and the -three `mutation_tests_shield_panel_cut*.py`. They target Unity Editor probes -(`EngineAssetCheck.cs`, `ShieldPanelCut*Verify.cs`) that Unity compiles outside -`Aetheria.Shared`, so Stryker cannot reach them. They carry the same anchor -fragility; the right answer for GPU and editor code is probably that the batchmode -probes are themselves the behavioural tests, but that is a separate decision. - -### 11.2 Scope, pinned - -`tests/Aetheria.Shared.Tests/stryker-config.json` scopes mutation to -`Assets/Scripts/ServerShared/**` and excludes plugins. Per cut, run against the -cut's own diff: - - dotnet tool restore - cd tests/Aetheria.Shared.Tests - CULTLIB_ROOT= CULTMATH_ROOT= dotnet stryker --since: - -Each root must sit at its pinned revision in `Directory.Build.props` (see the Cut 12.0 -status note). - -**Float-threshold boundary flips are known-equivalent.** Stryker files `>`→`>=` -under the same Equality mutator as `==`→`!=`, and the latter catches real bugs, so -the mutator stays on. A survivor that only moves a float comparison across its -boundary is triaged as equivalent and never chased. For the same reason the raw -mutation score is not a gate; the survivor list is the signal. - -### 11.3 Degenerate fixtures - -Most of the 67 survivors trace to three constants shared across the fire-control -fixtures, each of which makes a family of mutants arithmetically identical to the -original -- the same defect class as the zero-velocity target and the durability -coincidence, found here as a pattern instead of one at a time: - -- **Shooter at `float3.zero`.** `target - source` equals `target + source`, so four - separate position mutations survive (`FireControl.cs` HitProbability, Inspect, - Fire, Apply). -- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time - mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does - not defend the rule Cut 5 gave it a moving target to defend. -- **Spread zero everywhere.** `PSpread` returns 1 on every fixture, so it is - effectively untested, and `*` versus `/` against it is indistinguishable. - -Move shooters off the origin, fire at nonzero times, and give some fixtures real -spread. Fix the fixture, never weaken the assertion. - -### 11.4 Behavioural tests the survivors point at - -- **Splash handedness.** Flipping the sign in `var right = float2(forward.y, - -forward.x)` passes every test. The operator's "damage on the wrong side of the - ship" was a legacy Unity bug; this line has read the same since `482c47bf`, so - the rewrite never shipped it, but nothing defended against it either. Self said on 2026-09-19 that Cut 6b would add a test pinning - that a port blast damages port cells; it never landed and nothing checked. Pin it. -- **The shield loses energy when it absorbs.** Deleting `Shield.TakeHit` from the - discrete path survives, because tests assert the hull was untouched and never - that the shield paid for it. -- **Splash's shield-absorb branch** has no coverage at all. -- **Zones roll differently.** `CombatSeed *` → `/` makes the seed zone-independent - and survives; `TheDieIsUniform` checks uniformity within a zone only. Tighten it - enough that the `MixSeed` weakenings that survive it (`>>`→`<<`, `^=`→`|=`) die. -- **A shot whose target left the zone resolves as a miss** (Soul C4). Today only - the uncommitted branch rewrites the outcome; a committed hit on a target that - died mid-flight is published as a hit. Fix the code and pin it. - -### Verification - -Stryker over `FireControl.cs` before and after: every non-equivalent survivor -above killed, the remainder triaged by name. 231 tests plus the new ones; Unity -batchmode clean. - -### Status (2026-09-22) - -Landed on `codex/fire-control-10`: `8ef3b34c` (Stryker tool), `b305debd` (map, -config), `724ec5e7` (11.1), `a6543af7` (11.4 and the C4 fix), `1b187db4` (11.3), -`b0fd3cd0` (second round, below). 244 tests. - -Stryker over `FireControl.cs`, 278 mutants per run: - -| Run | Killed | Survived | Score | -|---|---|---|---| -| Spike, before Cut 11 | 208 | 67 | 74.6% | -| After 11.4 | 219 | 58 | 78.6% | -| After 11.3 fixtures | 225 | 51 | 81.1% | -| After the second round | 225 | 43 | 83.3% | - -**The second round** came from reading the 51. Four were real undefended -behaviour and one was a duplicated formula: - -- `Inspect` recomputed `PBase` as its own product of the factors, so the gates and - the product had two owners, and a `*`→`/` mutant of the HUD's copy survived the - agreement test (every fixture had spread 0, so `PSpread` was 1). `PBase` is now - `HitProbability`'s own answer; the test became - `TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not - fill the silhouette. `Fire`'s redundant `target != null ?` guard around - `HitProbability` went with it. -- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed - the expected damage from its own roll count, a tautology, now deleted. - `ADamageOverrideIsWhatTheShotCarries`. -- `Tracking` could ignore the fitted targeting system: no fixture's target ever - moved off its predicted line. `AFittedTargetingSystemForgivesAJink`. -- A shot orphaned before commit could skip `ShotCommitted`, or publish its miss as - shielded. `ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss`. -- `ArrivalIn` (the HUD countdown) was unchecked; asserted in the C4 test. - -**The 43 that remain, triaged:** - -- *Boundary flips, equivalent (23):* L52, 54, 56, 75, 135 (two), 157 (two), 193 - (two), 223, 273, 410, 418 (two), 465 (two), 503, 518, 524, 586, 605, 608. -- *`MixSeed` shift and xor weakenings, measured equivalent (4):* L382, 384, 386 - (two). 11.4 asked for them to die; measured, the die stays uniform and - zone-distinct under each, so there is no behaviour to pin. They change how the - mixer is built, not whether the die is fair. -- *Unreachable through the live callers (8):* L240 and L241 - (`DeviationProbability`'s null-target branch; the engine guards it, only the HUD - can pass null), L283 (three; airburst has no authored radius anywhere, recorded - since Cut 6b), L320 (empty-list early return, an optimisation), L410 `||` (a - nonzero `PBase` implies a target), L158 and L610 (no coverage: an unlocked lock - weapon reaching this line, and the last-cell fallback of the weighted pick). -- *Recorded gaps, not yet reached (8):* L135 ternary (two; no test that the AI in - `Combat.cs` leads a moving target), L435 (three; the aimed flag on an outcome), - L464 and L465 negate (the `Apply` hit direction through the `FireControl` - path), L585 and L587 (the aimed item's centroid as aim point). These are "not - yet reached," not "unreachable." - -## Cut 12: shots arrive edge-on - -Date: 2026-09-22. Imagination pass, revised the same day after the second round of rulings -below. Anchors are against `origin/codex/item-provenance` `cd846916` (Cuts 10 and 11 merged). -Claims marked **(probe)** were measured in a scratch copy of the test project -(`scratchpad/cut12probe`, the Cut 11 fixture plus probe methods) or by a lane-model script -(`scratchpad/exposure.py`), in both cases against a copy of `GameData/Aetheria.cc`. Claims -marked **(read)** come from source at the cited anchor. - -### The defect - -The impact cell is chosen as if every shot drops in from overhead. -`FireControl.HullKernel` (`FireControl.cs:558-578`) throws a 2D Gaussian at the top-down -schematic, centred on the aim point. `POnHull` (`:233-234`), `WeightedPick` (`:601-611`) and -`ResolveAimPoint` (`:583-589`) all read that kernel, and none of them takes the shooter's -bearing. **(probe)** `Probe3_BearingBlind`: pOnHull is `0.8748` at target facings (0,1), -(1,0) and (.6,.8) alike. - -Bearing is only used after the cell has been chosen: `Entity.ApplyHit` (`Entity.cs:381-409`) -walks penetration from that cell, and `Splash` (`FireControl.cs:515-527`) picks the half of -the hull that faces the blast. So armour facing does nothing. A wedge whose bow carries all -the plate gets hit in its soft stern from dead ahead. The game is planar (R7), so a shot -travels in the plane and should arrive edge-on. - -The damage rule makes it worse. `Entity.DamageSchematic` (`Entity.cs:336-374`) splits the -damage evenly over every cell of the hit shape. **(probe)** `Probe1`: 30 damage with -penetration 3 into column 1 of the 5x3 fixture, with 10 armour on the front cell only, puts -10 on each of the three path cells. The plate absorbs its 10, the item behind it still takes -10, and the stern cell passes 10 to the hull. The item receives exactly what it would have -with no plate in front of it. - -### Rulings (operator, 2026-09-22) - -First round: - -- **Model adopted: throw the dart at the silhouette the shooter sees.** Scatter is 1D and - perpendicular to the bearing. Project the hull's occupied cells onto the lateral axis and - throw a 1D Gaussian at that projection, with the same sigma rule, centred on the aim - point's projection. pOnHull is the Gaussian's mass on that shadow. Depth comes from - geometry: walk the ray in from outside along the bearing, and the first occupied cell is - the impact cell. Penetration continues along the same ray. -- Operator: "No, you're right, this is fine. Armor absorbs first. Shields remain - omnidirectional. Map it." - - **Armor absorbs first.** Damage runs down the ray in order. At each cell the armour - absorbs first, then the item, and only the remainder travels on. The even split over - the hit shape goes, for the penetration path and the DamageSpread expansion alike. - - **Shields remain omnidirectional.** The shield stays one whole-ship reserve with no - facings. -- **Rejected:** any fraction of shots arriving top-down to preserve cockpit sniping. The - operator accepted that cockpit shots become positional: the cockpit must be exposed from - your bearing, or you must flank, or penetration must reach it with what the armour did not - absorb. Cockpit destruction is still instant death (`Entity.cs:494`). - -Second round: - -- **Q12-1 = B: the bearing that places a hit is taken at Commit.** Operator: "Of course we - must use the target's facing at arrival, tanking a hit where you have armor is basic - maneuvering." After Self explained the commit horizon: "at commit." The target's facing - therefore stays live until Commit, as deviation already does. See **Bearing timing**. -- **Q12-2 = A: spread is width.** It becomes 2n+1 parallel lanes, and each lane is absorbed - from its own facing cell. Operator: "Spread is width makes sense for shotguns, but what - about explosive penetrators? Burrowing in before splashing radially would be a satisfying - way to damage vulnerable internals." That remark became sub-cut 12.4. - - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at - angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the - shared armour and mirror-image shots did different damage in ~30% of angled spread hits. - Operator: "I would prefer if the overlapping damage cells were diffused sideways to - thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): - **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` - width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly - that wide and half-open, so no two lanes can strike the same cell; the footprint widens at - angles instead of doubling up, and axis-aligned shots are unchanged. -- **Q12-3 = A (direct hits):** a lane's remainder goes into the hull wherever the lane - ends. -- **Q12-4 = A:** delete `ShotOutcome.Aimed`. -- **Penetration retune is a follow-up.** Operator: "Retuning penetration is a follow up." - See F12-2. Until it lands, interior cells are reached only by blasts. -- **The detonation primitive (12.4): a blast is an area, not a bundle of rays.** The first - draft of this section cast rays; the operator cut that. Operator, 2026-09-22: "sorry, why - are we doing blast rays instead of areas? I'd say distribute the damage over the affected - area, but that only works for areas". So a blast at a schematic point P with radius r - covers the cells within r of P, and **damage is distributed over the disc's area**: a cell - takes the share of the disc that lies inside it. The share of the disc that falls outside - the hull's occupied cells is lost. Each covered cell absorbs independently, in the - existing order (armour, then item, then hull). Nothing travels, nothing is occluded, and - no remainder is routed anywhere. (Superseded for items by the 2026-09-25 one-path - ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) - - **A shot has a direction; a blast does not.** That sentence is the rule behind the split: - direct hits keep the lane model of 12.2 and 12.3, and blasts are areas. Where P sits: - - proximity fuse: the intercept point outside the hull (today's airburst); - - contact fuse: the impact cell; - - delayed fuse or penetrator: the end of the penetration walk. - - All three are weapons that carry a blast radius. A weapon without one is not a blast at - any penetration: it keeps 12.3's lane (see **Where 12.3 and 12.4 meet**). - - 12.4 replaces `FireControl.Splash` and its half-hull footprint, which absorbs F12-1. -- **WeaponModifiers are labels, not behaviour.** Operator: "The AP modifier was merely - there as a label. Like the Incendiary modifier which doesn't actually determine that the - weapon does Thermal damage, but exists to mark weapons that do. Modifiers show up in - schematics as a shorthand for the weapon type." Detonation behaviour comes from weapon - data fields only. -- **Not ruled:** a HUD showing which target items are exposed from the current bearing - (Q12-5). - -### What the probes found - -- **Penetration does nothing in the shipped catalog.** **(probe)** The largest authored - Penetration is `0.25` (Autocannon, SRMM72, LRMM72). `ApplyHit` only walks when - `penetration > .5f` (`Entity.cs:392`), and `WeaponData.Penetration` is inspector-ranged - `0..1` (`Behaviors/Weapon.cs:28`). -- **LonginusX's cockpit cannot be reached edge-on until penetration reaches about two - cells.** **(probe)** `exposure.py` walked lanes 0.01 cells apart at every whole degree of - bearing over the real 6x17 schematic. The ControlModule (cockpit) and the AetherDrive are - never the first cell in any lane. They are still never reached at penetration 0.25 or - 1.0, and are first reached at 2.0. The Turret hull's ControlModule is enclosed too. - Until F12-2 lands, the only way to kill a LonginusX through its cockpit is a blast - delivered inside the hull (12.4). -- **DamageSpread is live content.** **(probe)** GT 3K authors 1-3.25, plight 1-3.3, and - pswarm and scorched void policy 0.9-1.5. `Shape.Expand` is an 8-neighbour ring - (`ItemData.cs:164-174`), so spread 3 produces a 7x7 footprint today. -- **No shipped weapon carries a blast, or the Airburst label.** **(probe)** `Probe4`: - every `WeaponItemData.AirburstRange` is null, and no `WeaponModifiers` includes Airburst. - The labels in use are RapidFire, Cluster and Incendiary. All three Thermal weapons carry - Incendiary, and no non-Thermal weapon does. No weapon carries ArmorPenetrating, - including the three with penetration 0.25. So the only live consequence of demoting the - Airburst label is the code path at `FireControl.cs:282`. -- **The handedness history.** `right = float2(forward.y, -forward.x)` has not changed since - `b7743789`/`482c47bf` (`git log -S`). No sign error ever shipped. What survived was the - *mutant* `-forward.x` → `forward.x`, which is invisible when |fy| > |fx|, and Cut 11 pinned - it. The formula is duplicated at `Entity.cs:394-396` and `FireControl.cs:516-520`, and 12.1 - collapses the two copies. -- **The code has two cell conventions.** The kernel, `Shape.CenterOfMass` - (`ItemData.cs:121-123`) and `ResolveAimPoint` put cell *i* at the integer point *i*. - `ApplyHit` instead starts from `cell + .5` and truncates (`Entity.cs:398-404`), which - treats cell *i* as the square [i, i+1). **(probe)** On bearing (.8,-.6) from (1,2), the - 0.5-step march went (1,2)→(2,1) and skipped (2,2), a cell the ray actually crosses. -- **Nothing reads `ShotOutcome.Aimed`.** `rg "\.Aimed\b"` finds only `FireControl.cs:292` - (PendingShot, which stays), `:432`, `:435` and `:481`, plus `FireControlCut7Tests.cs:190` - (which also reads PendingShot). -- **`Apply` reads live positions at arrival** (`FireControl.cs:463-465`), which is an R4 - leak and Stryker's L464/L465. 12.2 removes it. -- **No simulation code places the schematic in the world.** **(read)** `SchematicCellSize` - (`Settings.cs:255`, default 2) is read only by `PSpread`, and ApplyHit and Splash convert - directions only. A blast needs world↔schematic *points*, so 12.4 names that rule. - `HullCollider` is a physics surface only (`Gameplay/HullCollider.cs:4-8`). -- **Renaming slot 29 is compatible drift, not a break.** **(probe + read)** The catalog - embeds each type's schema descriptor, including `{"slot":29,"name":"AirburstRange", - "type":"System.Nullable"}`, and the wire format is positional - (`Probe5_Wire`: `[…,12.5,50,20]`, with no field names on the wire). On a content-hash - mismatch, `CultCache.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` - (`:672-752`), which compares slot type, reference, cardinality, target schema, name - semantics and index alias, **not the member name**. So renaming slot 29 opens as - `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` - warning. The warnings persist until the catalog is next written with the new descriptor. - Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, - 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs - (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: - no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as - `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. - -### Frame, geometry and the bearing (the rules every sub-cut reads) - -- **Schematic frame, one owner.** `Entity.ToSchematic(float2 worldPlanar) → float2` maps - world to schematic with x = starboard and y = bow: `forward = normalize(Direction)`, - `right = float2(forward.y, -forward.x)`, returning `float2(dot(v, right), dot(v, forward))`. - This is the transform ApplyHit and Splash already use, with the same sign. The Cut 11 - fixture comment and the LonginusX schematic (nose at y 11-16, reactor and radiators at - y 0-3) agree with it. 12.4 adds the point forms `ToSchematicPoint(float2 worldPlanar) = - ToSchematic(worldPlanar - Position.xz) / SchematicCellSize + Shape.CenterOfMass` and its - inverse `ToWorldPoint`, so the entity's position is the schematic's centre of mass. - Operator confirmed 2026-09-22, from the schematic images: "bow is indeed +y on the - schematic images." Still on the operator's check list after 12.4 lands, because it is the - half that only a running blast can show: that blasts land where the model shows them, - which is what confirms the centre-of-mass anchor. -- **A cell is the unit square centred on its integer coordinate.** This matches - `CenterOfMass`, the aim point and the old kernel. The `+ .5` convention dies with ApplyHit. -- **Travel direction.** `FireControl.TravelDirection(Weapon, Entity source, Entity target) → - float2` is the planar world direction of `PredictedIntercept(weapon, source, target) - - source.Position`. That intercept is Cut 3's one function, and for a beam it reduces to the - target position. Below a length of 1e-6 it falls back to world +z, which is today's - `Apply` fallback (`:463`). It belongs to the shot: Fire freezes it into `PendingShot`. -- **Bearing.** `bearing = normalize(target.ToSchematic(travelDirection))`. This is the only - place a world direction meets a target's facing. -- **Lateral axis** `ℓ = float2(-b.y, b.x)`. The sign is fixed once, here. -- **Shadow.** Each occupied cell *c* projects to `[dot(c,ℓ) - h, dot(c,ℓ) + h)`, with - `h = (|ℓ.x| + |ℓ.y|) / 2`. The shadow is the sorted and merged union of those intervals; - merging matters because off-axis intervals overlap. Squares are used rather than points - because a line at lateral offset *s* crosses square *c* exactly when *s* falls in *c*'s - interval. "s lies in the shadow" is therefore the same event as "this lane has metal", so - pOnHull is exactly that event's probability, and placement drawn from the shadow always - lands on metal. -- **`Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float - precision)`** replaces `HullKernel` and is the one function. It returns the merged - intervals, `a = dot(aimPoint, ℓ)`, sigma, `Span = hi_max - lo_min`, and - `POnHull = Σ_k Φ((hi_k - a)/σ) - Φ((lo_k - a)/σ)`, where `Φ(z) = ½(1 + erf(z/√2))`. - `aimPoint` comes from `ResolveAimPoint` unchanged; only its lateral projection matters. - `Sigma` keeps its rule (`FireControl.cs:549-550`). The 9.2 rationale in its comment is - gone, because an exact 1D integral does not undershoot, so rewrite it: the floor is now a - design minimum on group tightness (F12-3). The function allocates only on the ungated - path: one interval array of length N (≤128 on shipped hulls) plus a sort. -- **PSpread** keeps its formula, but takes values instead of a `Weapon`: - `PSpread(float spread, float span, float range, float cellSize)`. The half-extent becomes - `.5f * span * cellSize` instead of `max(Width, Height)` (`:224`). PSpread and pOnHull read - one silhouette, so they cannot disagree about the target's size. -- **Lateral draw.** One `NextFloat` u, taken after the hit roll (the draw `WeightedPick` used - to take). Walk the cumulative interval mass to `u · POnHull` to select interval k, then - invert inside it with `s = a + σ√2 · erfinv(2p - 1)`. Clamp into [lo_k, hi_k) to absorb - rounding. The clamp is a guard, not an invariant. -- **Lane walk** `Lane(HullData hull, float2 b, float s, buffer)` collects the occupied cells - whose interval contains s, each with slab entry and exit parameters `t` along b, where - `t = dot(point, b)`. Cells are ordered by entry, with ties broken by `dot(c, b)` and then - cell index. It is exact slab traversal and replaces the 0.5-step sampling. The result goes - into a caller-supplied pooled buffer (`ArrayPool.Shared`; netstandard2.1 has - `System.Buffers`). A direct hit starts from t → -∞ (outside), so its first element is the - impact cell. It is the walker's only caller: blasts are areas and do not traverse. - - Metal is continuous while `next.entry ≤ current.exit + 1e-4`, and the first gap ends the - walk. That keeps today's rule at `Entity.cs:401`. -- **Penetration along a direct-hit lane.** A cell is reached when - `entry - impact.entry < penetration`, in cells. The impact cell is always reached. The - `> .5f` threshold (`Entity.cs:392`) is deleted. - -**Bearing timing (Q12-1 = B).** A shot is split into what the shooter decided and what the -target is doing: - -- **Frozen at Fire:** everything the shooter decided (R10). `PendingShot.PBase` is renamed - `PFire` because its meaning changes: it is `Accuracy × PSensor` behind the same gates, and - it is 0 when any gate fails. Also frozen: `TravelDirection`, `Precision`, `Aimed`, the - weapon's `Spread`, and `FireRange` (the range at Fire, which PSpread reads). -- **Live until Commit:** everything the target is doing. That was already its position - (deviation); now it is also its facing. `Commit` computes, in this order: - 1. `pDeviation`, as today. - 2. `bearing = normalize(shot.Target.ToSchematic(shot.TravelDirection))`, from the - target's facing at the commit tick. - 3. `sil = Silhouette(target, hull, shot.Aimed, bearing, shot.Precision)`. - 4. `p = PFire × pDeviation × PSpread(shot.Spread, sil.Span, shot.FireRange, cellSize) × - sil.POnHull`. - 5. The one roll against p. On a hit, the lateral draw from `sil`, and the impact cell - from `Lane`. - - Steps 2-5 are one function, `FireControl.CommitProbability(PendingShot, float now, - out Silhouette)`. The debug HUD calls it, which retires the inline estimate at - `ActionGameManager.cs:1317-1318` (`shot.PBase * pDeviation`, which would otherwise be a - second, wrong copy of the model). -- **The outcome freezes the committed geometry** (R4). `ShotOutcome` gains `float2 Bearing` - (schematic frame, as committed) and `float Lateral`, and keeps `Cell`. `Apply` walks - `Lane(hull, outcome.Bearing, outcome.Lateral)`. After Commit, turning the target changes - nothing, and Apply reads no position or facing. -- **HitProbability and Inspect** report `PFire × PSpread × pOnHull` at the *current* - travel direction and facing. That is a forecast, in the same way the HUD already treats - deviation. `Inspect.PBase == HitProbability` still holds, because both are the forecast. - The hot path still gates first (`:150-159`) and builds the silhouette only after the gates. - -What this does to the earlier rules: - -- **R3 (one roll, and a hit always lands on metal) still holds.** The factor the roll is - charged with, pOnHull at the commit bearing, comes from the same `sil` that placement - draws from, in the same call. Placement is exactly consistent with the pOnHull applied, - and it always lands on metal. What changes is that HitProbability's number is no longer - the exact roll price when the target turns during flight. It is the price only if the - target holds its facing, just as it already is only if the target holds its course. -- **Cut 5's deviation factor keeps its ordering and meaning.** It is still measured at the - commit tick against the fire-time projection, and it is still multiplied into p before - the single roll. The two silhouette factors join it in the same product. The Cut 7 guard - (`p > 0f && shot.Target != null`, `:410`) extends to cover them: when `PFire × pDeviation` - is 0, Commit builds no silhouette. Multiplication order has no numerical effect. The guard - exists only to skip work. -- **6d's premise ("pOnHull depends only on the aim point, the hull shape and the frozen - Precision, so it folds into PBase at fire time") is retired.** pOnHull now depends on the - target's facing, which is live target state like position, so it is priced at Commit. - R10 is untouched: every *shooter* stat is still frozen at Fire, and the target's facing - was never a shooter stat. -- **Cut 10 still holds:** gated HitProbability allocates nothing. Commit is not on the - hot path. - -### Sub-cut order - -| Sub-cut | Repo | Nature | Depends on | -|---|---|---|---| -| 12.0 | CultLib | CultMath gains `erf` and `erfinv` (a gap filled in its owner) | none | -| 12.1 | Aetheria | subtraction and frame owner; no behaviour change | none | -| 12.2 | Aetheria | where a hit lands: silhouette at Commit, Apply stops reading positions | 12.0, 12.1 | -| 12.3 | Aetheria | how a direct hit travels: exact lane walk, sequential absorption, spread lanes | 12.2 | -| 12.4 | Aetheria | the detonation primitive (areas), fuse data, the Airburst label demoted; Splash deleted | 12.3 | - -The five are kept apart so that Soul falsifies each question on its own: where a shot -lands, what a direct hit does, and what a blast does. In 12.2, `ApplyHit` survives for one -more cut with a changed signature: it takes the schematic bearing and loses its own -rotation, while its march and its even split are left untouched until 12.3. - ---- - -### Cut 12.0. CultMath gains `erf` and `erfinv` - -- **Repo/branch:** `F:\Projects\CultLib`, off current `main`. -- **Adds:** `math.erf(float)` and `math.erfinv(float)` in - `packages/cultmath/src/CultMath/math.cs`, as standard single-precision approximations (for - example Abramowitz-Stegun 7.1.26 or a minimax fit; Giles 2010 for erfinv), with tests in - `packages/cultmath/tests/CultMath.Tests`. Neither is an HLSL intrinsic, so there is no - shader-parity obligation. -- **Release:** run `packages/cultmath/scripts/build-unity-package.ps1`, bump - `unity/org.gamecult.cultmath/package.json` from 0.2.3 to 0.2.4, and tag - `cultmath-unity-v0.2.4`. Aetheria then bumps `Packages/manifest.json:56` and the headless - `CultLibRoot` pin. -- **Verification:** - - `ErfMatchesReferenceValues`: erf at 0, ±0.5, ±1, ±2 and ±3 matches tabulated values - within 2e-6; erf is odd; the limits are ±1. - - `ErfinvInvertsErf`: over y ∈ [-0.999, 0.999]. - - The CultLib mutation run, with every survivor triaged by name. - -**Status: the code closes, the release does not.** Landed on CultLib -`hands/cultmath-erf`: `87b95ae`, `b688cb2` (erf, erfinv, tests), then the fix batch -`fc9ce97`, `4cdc02b`, `6e8362b`, `cc71f17`. 102 tests. - -Two verification lines above were stale when written and are corrected here. CultLib had -no mutation tooling at all, so there was no "CultLib mutation run"; Stryker.NET was adopted -in its own cut (`hands/adopt-stryker`) and its CultMath baseline is 2243 mutants, 350 -survived, triaged in that cut's report. The headless pin needed more than a bump. `Directory.Build.props:5` pins one CultLib revision -for every package, and that revision (`45c2f40`) is exactly Unity's `caching-unity-v1.4.0` / -`cultlib-unity-v1.0.60`; moving it to the release would have put six CultNet commits of newer -CultCache, including a breaking registration change, under the headless tests while Unity -stayed on 1.4.0. So the headless build now pins per package, mirroring the manifest: -`CultMathRoot`/`CultMathRevision` at the 0.2.4 tag's merge (`6d5e2096`), `CultLibRoot`/ -`CultLibRevision` still at `45c2f40` for CultCache. A package's pin moves only when its -manifest tag moves. (Self first repeated Soul's claim that there was no pin, from a read of -lines 3-4; line 5 is the pin, and the build refused the first attempt to use the release.) The -Unity manifest lives at `packages/cultmath/unity/org.gamecult.cultmath/package.json`, not -at the repo root. - -Soul's first pass found the defect this cut would otherwise have shipped: `erfinv(±1)` -returned the **wrong-signed** infinity, so a sample drawn for the far edge of an interval -was clamped onto the near edge. Reachable in ordinary play — `erf` saturates to exactly 1 -at 5.543σ, so any interval edge past that yields `p = 1`. Fixed with two guards; outside -the domain is NaN, now documented and pinned. Soul also measured the reported accuracy -figures 1.5× optimistic (true worst error: erf 6.621e-7, erfinv 5.066e-7, round trip -6.109e-7, all inside the 2e-6 bar), found `erfinv` had no accuracy test at all (the round -trip was blind by 300-400×, proven with a coefficient mutant), and found -`ErfinvIsMonotonicallyIncreasing` asserting a property neither function has (9662 backward -steps in `erfinv` at float granularity; the fixture's 0.001 step hid every one). The second -pass re-derived all 19 new reference values by a different method and closed the cut. - -**Cost, for 12.2 to answer rather than assume:** `erf` is 16.3 ns per call and -allocation-free, but a silhouette costs one `erf` per interval edge — ~4.2 µs per -evaluation on a 128-interval hull, 1.06 µs on 64, and worse under IL2CPP. The AI evaluates -`HitProbability` constantly. 12.2 must measure the new placement against the old kernel per -gated-in evaluation and report both, not assume 1D beats 2D. - -**Released 2026-09-24:** CultLib `20e6da5` (release) and `8c86bc9` (the changelog meta), merged to -main at `6d5e2096`, tagged `cultmath-unity-v0.2.4`. Release Soul pass: the DLL rebuilt byte- -identical from source (sha256 `2470cd48…`), public surface +2 (`erf`, `erfinv`), shipped -`erfinv(±1)` correctly signed; its one finding, `CHANGELOG.md` without a Unity `.meta`, was fixed -before tagging. Aetheria repinned in `2a68b4c0` (manifest and Unity-written lock, hash -`6d5e2096`); batchmode resolve and compile clean, no missing-meta warning. Follow-up for CultLib: -`build-unity-package.ps1:89-90` checks metas only for code and binary files, so it could not see -the missing `.md` meta. - -**Was owed (kept for the record):** Unity -consumes a *precompiled* `CultMath.dll` pinned at `cultmath-unity-v0.2.3` -(`Packages/manifest.json:56`), so no Unity-side fire-control code can call `erf` until the -package is rebuilt, bumped to 0.2.4, tagged and repinned. The headless tests see the -sources directly, which is why nothing failed. 12.2 must not land in Unity before that. - -**Recorded, not done:** `erf`/`erfinv` are scalar-only while every other scalar in -`math.cs` ships float2/3/4 overloads. The consumer is scalar; vector overloads are a -separate decision. - -### Cut 12.1. One frame owner, and the dead outcome flag - -- **Repo/branch:** Aetheria, off the Cut 11 merge. No behaviour change. -- **Deletes first:** - - The two inline frame copies: - - `FireControl.cs:516-520` becomes `target.ToSchematic(toTarget)`, normalized, keeping - the zero-length fallback. The whole of Splash dies in 12.4, but the frame owner lands - first. - - `Entity.cs:394-396` becomes `ToSchematic(hitDirection)`. - - `ShotOutcome.Aimed` and its plumbing (Q12-4 = A): - - the field, `FireControl.cs:702`; - - the `aimed` parameter of `MakeOutcome` (`:470`, `:481`); - - `Commit`'s `aimed` local and flag (`:420`, `:435`); - - the `AimedCells` half of `ResolveAimPoint`'s tuple (`:583-589`), which now returns - `float2`. -- **Adds:** `Entity.ToSchematic(float2)`. -- **Verification:** - - Builds: headless `Aetheria.Shared`, plus a Unity batchmode compile by Self. - - All 244 tests pass unchanged. `SplashDamagesTheSideTheBlastCameFrom` and - `PenetrationMarchIsPlanar` pin the frame through its new owner. - - Negative greps: - - `rg -n "forward\.y, -forward\.x" Assets/Scripts` returns exactly one hit, in - `Entity.cs`. - - `rg -n "Outcome\.Aimed|outcome\.Aimed|o\.Aimed" Assets/Scripts tests` returns - nothing. - - Both were checked against the current tree: the first finds today's two copies, and the - second finds nothing. - -**Status (2026-09-24): landed** at `b851b0d1` (+21/-18 across `Entity.cs` and -`FireControl.cs`). 244 tests pass, both greps as specified (the one frame copy is -`Entity.cs:383`, inside `ToSchematic`), Unity batchmode compile clean, and nothing outside -`FireControl.cs` read `ShotOutcome.Aimed` (the HUD holds outcomes but never touched it). -Stryker on the changed lines: the frame's sign mutant dies; the only survivors near the diff -are `ResolveAimPoint`'s pre-existing centroid arithmetic (`aimedCells.Length > 0` → `>= 0`, -`/` → `*`), which 12.2's `AimingAtAnItemCentresTheScatterOnItsLane` is specified to close. -`--since` does not work in this repo (Stryker's own diff comes back empty), so runs use -`--mutate` on the touched files and filter the report to changed lines. No separate Soul pass: -a no-behaviour cut this size folds into 12.2's. - -### Cut 12.2. Where a hit lands - -- **Repo/branch:** Aetheria, on top of 12.1, with the 12.0 pin in place. -- **Deletes first:** - - `HullKernel` and its comment block (`FireControl.cs:531-578`). - - `WeightedPick` (`:591-611`). - - `POnHull` (`:229-234`). - - Apply's live direction (`:463-465`). - - The bounding half-extent (`:224`). - - The 9.2 SigmaFloor rationale (`:542-548`). Rewrite it as described above. - - The ActionGameManager inline estimate (`:1317-1318`). -- **Keeps:** `Sigma`, `SigmaFloor`, `ResolveAimPoint`, `CellsOf`, the gate order - (`:150-159`) and `Inspect.PBase = HitProbability(...)` (`:204`). -- **Adds:** `TravelDirection`, `Silhouette`, `CommitProbability`, the lateral draw, `Lane` - (only its first element is used in 12.2), and these fields: - - `PendingShot`: `TravelDirection`, `Spread` and `FireRange`; `PBase` is renamed to - `PFire`. - - `ShotOutcome`: `Bearing` and `Lateral`. -- **Per-file changes:** - - `FireControl.cs:161-167`: after the gates, `PFire(...) × PSpread(...) × - Silhouette(...).POnHull`. The bearing comes from `TravelDirection` and the target's - current facing. Nothing new runs before `:159`. - - `:197-202`: Inspect uses the same functions. It gains nothing new beyond - `diagnostic.PFire` if the HUD wants it (optional; presentation). - - `:263`, `:286-308`: Fire freezes `PFire`, `TravelDirection`, `Spread = weapon.Spread` - and `FireRange`. - - `:409-435`: Commit takes steps 1-5 of **Bearing timing** through `CommitProbability`. - - `:463-467`: Apply passes `shot.Outcome.Bearing` (schematic frame) to `ApplyHit`. For - this one cut, ApplyHit's parameter becomes a schematic bearing and the `ToSchematic` - call 12.1 put inside it is removed. - - `ActionGameManager.cs:1317-1318`: the pending line prints - `FireControl.CommitProbability(shot, now, out _)` instead of `shot.PBase * pDeviation`. - - `Combat.cs:117` and `TurretController.cs:84` need no change. They read HitProbability, - which is now a facing-aware forecast. Expect the AI to prefer broadside shots on long - hulls, and targets that turn to lose fewer hits. That is tuning, not a defect. -- **Authority map:** - - Owner: - - `FireControl.Silhouette` owns scatter. - - `FireControl.CommitProbability` owns the commit-time roll price and placement - source. - - `FireControl.TravelDirection` owns the shot's world direction. - - `Entity.ToSchematic` owns the frame. - - Inputs: - - Frozen: `PFire`, `TravelDirection`, `Precision`, `Aimed`, `Spread`, `FireRange`. - - Live at Commit: target position (deviation) and target facing (bearing). - - Outputs: p for the one roll, then `Bearing`, `Lateral` and `Cell` in the immutable - outcome. - - Derived state: - - HitProbability and Inspect are a forecast of the commit price and decide nothing. - - `ShotOutcome.Cell` is display-only, for the HUD (`ActionGameManager.cs:1323`) and - the tests. - - Forbidden writers: - - Nothing else computes a sigma, a projection or a bearing. - - Nothing after Commit reads a target's facing or anyone's position for placement or - damage direction. - - The HUD computes no probability of its own. - - Shared paths: InstantWeapon (`InstantWeapon.cs:248`), beam rolls - (`ConstantWeapon.cs:181`, where zero flight time makes the fire tick the commit tick), - the AI, turrets and the HUD. - - Deletion line: `HullKernel`, `WeightedPick`, `POnHull` and the HUD estimate are gone - before `Silhouette` has a caller. -- **Verification.** Fixtures follow 11.3: the shooter is off the origin, fire times are - nonzero, facings include ones where |fx| > |fy|, and weapon spread is nonzero wherever - PSpread is asserted. - - `BroadsideIsEasierThanHeadOn`: on a 2x12 hull, pOnHull and PSpread head-on are strictly - below broadside, at facings (0,1) and (1,0). It kills the bearing being ignored and the - old `max(W,H)`. - - `TurningArmourIntoTheShotTakesItOnTheArmour` (the ruling's own case): - - Setup: a slow shot (flight time well above `CommitHorizon`) at the soft stern of a - wedge with bow-only plate. The target turns its bow to face the shot's - `TravelDirection` before commit. - - Pass: every hit's `Cell` is on the bow row. - - Kills: freezing the bearing at Fire; reading the facing at Fire. - - `TurningAfterCommitChangesNothing` (R4, and closes L464/L465): - - Setup: through `Fire`. After commit and before arrival, the target turns 90° and the - shooter moves. - - Pass: the damaged cells equal those implied by the committed `Bearing`/`Lateral`. - - Kills: Apply reading live positions or facing. - - `PlacementAndProbabilityShareOneSilhouette` replaces 6d's - `PlacementAndProbabilityShareOneKernel` (`FireControlCut6dTests.cs:318`). - - Setup: a static target at a diagonal bearing, so the intervals overlap and the - forecast equals the commit price. - - Pass: `CommitProbability` equals a pOnHull that the test computes independently, by - numerically integrating the Gaussian over a union of intervals the test builds itself. - The empirical `Lateral` distribution matches the per-interval shares, and the - empirical hit rate matches p. - - Kills: sigma or bearing perturbed at one site; the union merge skipped; ℓ swapped - with b. - - `TheHudEstimateIsTheCommitPrice`: for an in-flight shot, the value the HUD prints and - the p Commit rolls against are the same number at the commit tick. It kills a second - formula. - - `HitsLandOnTheFacingEdge` supersedes `EveryHitLandsOnMetal` (`:357`): on the concave - fixture, each hit's `Cell` is occupied, and nothing occupied precedes it in its own lane. - The test recomputes the lane from `Bearing` and `Lateral`. - - `AimingAtAnItemCentresTheScatterOnItsLane` closes L585/L587 and replaces - `AimingAtTheSternHitsTheStern` (`:213`), whose fixture fires from the target's stern - (`FireControlCut6dTests.cs:163-164`) and would pass for the wrong reason. - - From broadside, hits aimed at the stern land mostly in the stern half, and hits aimed - at the bow land mostly in the bow half. - - From dead astern, hits aimed at the bow land on the stern edge. - - `TheSigmaFloorHoldsAtHalfACell`: pOnHull at Precision 1000 equals pOnHull at Precision 2 - on a one-cell lane. The exact integral no longer collapses, so this test, not the cliff - test, is now what kills "delete the floor". Keep the cliff test and rewrite its - mutation comment. - - `ShieldIsOmnidirectional`: an active shield absorbs the same shot identically from bow, - stern and beam. - - Kept green: - - `GatedOutHitProbabilityAllocatesNothing`. - - `TheHudShowsTheFactorsTheSimulationMultiplies`, re-read as forecast factors. - - `PSpreadIsTheSilhouettesShareOfTheSpreadCone`, which fires from astern at a 5-wide - hull. There the span is 5, equal to the old `max(5,3)`, so its numbers stand. - - `ThinLimbCostsHitChance`, with its comment rewritten in 1D terms. - - Every test that reads `shot.PBase`, updated to `PFire` with its expectation - unchanged. - - Stryker: `--mutate "**/FireControl.cs" --since:<12.1 head>`. Survivors are triaged by - name, and float boundary flips count as equivalent (11.2). -- **Operator:** - - A smoke against a LonginusX-class AI from the bow and then the beam. The HUD `hull` - factor should read higher from the beam. - - Turn your armoured face into a slow missile and watch where it lands. - -**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: -`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` -(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. - -What the Soul passes found, in order, because two of them are lessons for 12.3: -- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, - and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so - every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause - found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; - `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration - could follow a turn made after commit; multi-cell aimed items were untested. -- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. - The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result - into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root - cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, - `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the - two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an - `IComparer` in a delegate per sort). -- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the - slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all - cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and - float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration - found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact - arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. - The `Commit` invariant throw is unreachable. - -Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX -from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside -fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an -item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per -`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. - -**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at -b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant -survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of -their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in -`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make -`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through -visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` -import. - -### Cut 12.3. How a direct hit travels - -- **Repo/branch:** Aetheria, on top of 12.2. -- **Deletes first:** `Entity.DamageSchematic` (`Entity.cs:333-374`) and `Entity.ApplyHit` - (`Entity.cs:376-409`). Going with them: - - the 0.5-step march; - - the `+ .5` cell convention; - - the `> .5f` threshold; - - the Expand spread footprint. -- **Adds:** - - `Entity.Absorb(int2 cell, float damage) → float`, moved verbatim from - `Entity.cs:348-366`. Armour absorbs up to its value, then the item when `d > .1f`, and - the remainder is returned. It emits `ArmorDamage((cell, incoming))` and - `ItemDamage((item, incoming))` with today's meaning, and only for incoming > 0. - - `Entity.DamageHull(float)`, moved from `Entity.cs:369-373` (`> .1f`, one `HullDamage` - event). -- **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches - unchanged: - 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. - 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's - rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and - **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per - cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two - derivations disagreed at cell edges and struck a cell twice). - 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. - 4. Each lane walks from its own facing cell, clipped to the penetration depth, with - `rem = target.Absorb(cell, rem)` at each cell. - 5. Each lane's final remainder goes to the hull, where the lane ends: penetration - exhausted, a gap, or the far side (Q12-3 = A). The remainders are summed and passed to - `DamageHull` once. - 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead - (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. - - **Where 12.3 and 12.4 meet.** Which model a shot uses is decided by the blast radius, not - by its penetration: - - **No blast radius — a dumb AP round.** Steps 1-5 are the whole model, whatever the - penetration. The shot travels its lane and is absorbed cell by cell in order, with the - remainder carrying on until it is spent or the lane ends (Q12-3). Damage in a line. More - penetration spreads the same damage over more cells; it never concentrates it at depth. - Q12-8 says nothing about this case. - - **With a blast radius — an explosive penetrator.** The shot reaches its fuse depth - without spending damage on the way, and then detonates as an area (12.4). Q12-8 governs - only this case, because the payload is the explosion, not the dart. - - The two roles that follow are the design intent: the AP round is the pinpoint component - sniper, a thin line of damage through whatever it crosses, and the explosive penetrator - is the interior wrecker, a disc across a whole section. - - Splash is untouched in 12.3 and deleted in 12.4. Until then it calls `Absorb` per - footprint cell and passes the summed remainder to `DamageHull`, which is arithmetically - identical to today. -- **Authority map:** - - Owner: `Entity.Absorb` owns what one cell does with incoming damage. - `FireControl.Apply` owns the order and the share per lane. - - Inputs: the committed `Bearing`, `Lateral` and `Cell`, plus the frozen `Penetration`, - `DamageSpread` and `Damage`. - - Outputs: `Armor[]`, item `Durability` and `Hull.Durability`, plus the events - `IncomingHit`, `ArmorDamage`, `ItemDamage` and `HullDamage`. Their consumers: - `ActionGameManager.cs:1070`, `EntityInstance.cs:292`, `InventoryPanel.cs:470-482`, - and `Entity.cs:488-494` (ItemDestroyed, HullArmorDepleted, Death). - - Forbidden writers: no weapon hit writes armour or item durability except through - `Absorb`. `Behaviors.CauseDamage` (`Behaviors/Behaviors.cs:62-73`) is self-damage and - out of scope. - - Deletion line: both Entity methods are deleted before `Absorb` gets a second caller. -- **Verification:** - - `DamageIsAbsorbedInOrderAlongTheRay` replaces `HardpointHitDamagesItemThenHull` and - `PenetrationMarchIsPlanar` (`FireAuthorityTests.cs:481-526`, which call the deleted - `ApplyHit`). - - Setup: through `Fire` at facing (1,0). The lane runs through a 10-armour front cell, - then a marker, then a soft cell, with damage 30 and penetration 3. - - Pass: armour −10, marker −20, soft cell untouched, hull −0. Today it would be - 10/10/10 **(probe)**. - - Kills: an even split; items before armour; no carry-forward. - - `ArmourFacesTheShot`: - - Setup: a wedge with bow-only plate, penetration 0, many shots. Facings (0,1), (.8,.6) - and one where |fx| > |fy|. - - Pass: from ahead only bow cells lose armour, and from astern the reverse. - - `BuriedCockpitNeedsPenetration`: a cockpit enclosed by one ring of cells. - - At penetration 0 it is never damaged from four bearings. - - At a penetration that clears the ring, it takes exactly `damage/lanes` minus what the - ring absorbed. - - Enough damage fires `Death` with `CockpitDestroyed`. - - `SpreadWidensTheImpactAcrossLanes`: spread 1 at penetration 0 puts damage/3 on each of - three facing-edge cells and nothing behind them. - - `ARayThroughACornerCrossesTheCornerCell`: the diagonal case from `Probe1` reaches (2,2). - - `LaneRemainderReachesTheHull`: a ray that exits a thin hull delivers its remainder to - `Hull.Durability` (Q12-3 = A). - - Kept green: every Splash test, and `AirburstSplashes…` (Cut 6). - - Negative: `rg -n "DamageSchematic|ApplyHit\(" Assets/Scripts tests` returns nothing. - Today it matches `Entity.cs`, `FireControl.cs`, `FireAuthorityTests.cs`, and two comment - lines at `Gameplay/EntityInstance.cs:280-281`. Rewrite those comments and - `FireControl.cs:492`. - - Stryker: `--since:<12.2 head>` over `FireControl.cs` and `Entity.cs`. -- **Operator:** a smoke with a GT 3K or pswarm launcher into a LonginusX bow, then from a - flank. The schematic display should pulse the facing edge only. - -**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: -- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. -- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. -- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. -- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. -- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. -- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. - -297 tests. Unity batchmode is clean at `cccf3a2a`. - -Proportional absorption, passes 4 and 5: -- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` - was already ruled out by the operator. Soul found three more defects: - - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. - - The pooled resolve skipped the item's `.1f` threshold. - - The model sweep still passed with proportionality deleted. -- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. - `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is - Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. -- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled - bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. - Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and - stale comments. All are fixed, and each mutant dies to a committed test. - -Recorded, not fixed: -- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, - which only a private seam offers. The operator rule forbids reflection, and no test seam is added - for them. - - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. - - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). - - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). -- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. - -What the three Soul passes found: -- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of - 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an - angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. - It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on - (Q12-2 amendment: no doubling up, the line thickens). -- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits - struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first - structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the - centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit - with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float - drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two - derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, - the `ceil` empty centre); the cure each time was one owner. -- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two - lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by - construction. Residual float effects (a side lane starting one cell deeper, a column falling - between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage - is never lost. - -**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike -exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% -of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing -ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since -it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. - -**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the -first-processed lane drains its durability and the second carries the leftover into its own deeper -cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on -a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs -from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. -Alternative: keep the processing order and pin it. - -**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell -item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane -carries on with a leftover in proportion to what it brought. The result must not depend on the order -lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. - -**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an -exception, this should be one code path." The first implementation (`56757913`) surveyed each shot -for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split -authority, and the second reachability derivation it needed is the same failure as the 12.2 empty -lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of -the lanes that reach it, a pool of one lane included. The survey and the sequential path die. - -**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. - -### Cut 12.4. The detonation primitive - -**Status (2026-09-29, Self): closed.** 12.4 landed at `2625768f`, `7c99909d` and `7e63d744`. - -Soul pass 1 (Opus) found three defects: -- **F1:** a tangent disc created damage, 127 of 100. -- **F2:** a candidate cull measured from the centre of mass missed hull corners. -- **F3:** a lethal blast threw mid-loop and detonated again. - -It also found four rules pinned only by tests that could not fail. - -The fixes landed at `8b4bf01e`, `76e98598` and `fd792013`: -- the tangent comparisons now include equality; -- the cull and the `radius <= 0` guard are deleted, so `RectDiskOverlap` is the only decider; -- the blast loop iterates a snapshot. - -Soul pass 2 closed it. Conservation held over 1,944 exact-lattice discs, with a worst error -of 2e-5. A blast costs 0.3-0.7 ms in a zone of 40-100 entities. - -Pins landed at `4629bc67` and `c7e20f08`. `Death` fires once per entity (`Entity.cs`, `.Take(1)`), -which also fixed a direct hit on a corpse raising `Death` again. A three-kill blast test was added. -362 tests pass. - -**Operator ruling needed:** a fused weapon fired with no target bursts at the shooter's own -position (`FireControl.cs:391`). This predates 12.4. Self recommends a max-range burst along the -aim line. Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship -still takes blast damage. +# Fire Control: Cut Map -Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path -rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. -Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see -Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. - -- **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: - - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no - weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has - not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). - - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. - -#### Deletes first - -In (a): - -- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), - `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. - `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. -- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The - initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a - proximity fuse. -- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. - -In (b): - -- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. -- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. - `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). -- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell - separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. - **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` - the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. - 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. -- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended - answer. It is not deleted: it is gated on `shot.Fuse == null`. -- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one - reach function (see **The penetrator**). -- Stale comments that name a deleted owner as live: - - `Entity.cs:391` and `:417`; - - `FireControl.cs:1201`; - - `Gameplay/EntityInstance.cs:280-283`; - - `Gameplay/HullCollider.cs:4-6`; - - `Gameplay/Weapons/Projectile.cs:8-9`; - - `Gameplay/Weapons/Mine.cs:90-95`. - -#### Data - -The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). - -- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name - `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache - opens the catalog as compatible drift (see 0b). -- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, - Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: - null means no blast, and every shipped record reads as null. -- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when - `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing - re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius - without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is - not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on - `BlastRadius` alone (Soul, `2625768f`). -- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That - write is content and changes no data. It belongs to F12-2. -- **Readers to update:** - - `FireControl.cs:386-388`. - - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` - parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, - because Q12-6 ruled that nothing checks labels against behaviour. - -#### Which model a shot uses - -`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: - -- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. -- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which - `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, - `AirburstSplashesEvenOnAGuaranteedMiss`. -- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P - is**). On a miss nothing happens. - -Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse -into Apply's switch, so `Step` has no damage branch left. - -`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and -only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one -consumer is the player's hit marker (`ActionGameManager.cs:1070`). - -This is a default, not a ruling. - -#### Where P is - -The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as -`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). - -**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: - -- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 - from its centre; -- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. - -So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. - -- **Proximity:** `BurstPosition.xz`, a world point. -- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and - `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. -- **Delayed:** the schematic point `point(t_fuse)`, with - `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that - 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, - whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last - reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. -- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already - proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not - read. The disc's radius is its width. This is a default, not a ruling. -- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the - host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like - every other entity in the radius: its cells are found again by `ToSchematicPoint`. - - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for - it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. - - Bystanders are judged against where the host actually is at arrival. That is the same live rule a - proximity burst already applies to everyone. - - There is exactly one input shape, a world point. There is no host-only schematic entry. - -#### The penetrator (Q12-8) - -A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` -the shot's whole `Damage`**. - -That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the -explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. - -The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at -`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → -int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while -`entry - impact.entry < penetration`. - -Two callers read it: - -- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; -- the delayed fuse point, which calls it on the centre lane. - -No second "lane damage with a blast" path exists, because no shot does both: - -- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; -- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. - -A shot takes one branch of Apply's switch or the other, never both. - -The mutant this must kill is a second reach derivation: for example, a delayed P computed as -`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. - -#### Area, per entity: `FireControl.Detonate` - -The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, -public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). - -- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes - `position.ToCultMath().xz`). -- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / - SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the - frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and - `SchematicCellSize` is `Settings.cs:255`. - -For each entity in the zone: - -1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. - Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a - long hull whose end lies inside the blast. -2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For - every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × - overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a - cell being the unit square centred on its integer coordinate. - - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing - computes an overlap twice. - - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. -3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, - under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact - or delayed blast, under Q12-9's recommended answer. - - If the shield absorbs, no cell of that entity takes anything. - - If it cannot, it breaks, and the cells take their shares. -4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects - its own cell's share and nothing else. -5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in - hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has - deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join - the hull total. -6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). - -Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the -other (F12-8). - -**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, -and no operator question is needed: - -- "Armor absorbs first", from the first round of rulings; -- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; -- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where - incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after - `ArmorAbsorb`; -- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." - -The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: -an item's cells deposit into one pool, and they do not absorb independently. - -**One owner, and why there is no walk.** - -- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on - the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. -- `ArmorAbsorb` is the one owner of what armour does. -- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, - `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the - item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no - cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the - reachability test and the cycle rule have nothing to decide. - -The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull -with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell -with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 -discs. Its hull total differed only by float summation order, at most 3.8e-6. - -`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a -starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. - -**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same -per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only -order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. - -**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the -disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` -between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it -conserves exactly up to float rounding. - -- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at - radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double - precision is not needed. -- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. -- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc - smaller than a cell, or for one straddling a cell boundary. - -**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than -128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not -the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. - -**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an -area is occluded. A bulkhead protects its own cell's share and no other. Falloff toward the rim is F12-7, not -this cut. - -**HitProbability and Inspect are unchanged.** A contact or delayed blast still needs the shot to hit, so the -forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. -That is pre-existing, and it is F12-5. - -#### Authority map - -**Owners:** - -- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. -- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the - per-entity armour→pool→hull pass. -- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with - 12.3's walk. -- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. -- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. -- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. - -**Inputs:** - -- P, the radius, the damage and the type; -- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; -- the fuse and the radius, frozen at Fire; -- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. - -**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, -`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. - -**Derived state:** - -- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, - `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. -- `BurstPosition` is read only for a proximity fuse. -- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. - Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` - finds only `FireControl.cs`). - -**Forbidden writers:** - -- No label decides behaviour. -- Nothing but `Detonate` applies area damage. -- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. -- Nothing but `Reach` decides how deep a lane reaches. -- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). -- `Step` has no damage branch. -- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they - decide no damage themselves. - -**Shared paths:** - -- the proximity burst: Apply at arrival, with no roll; -- the contact and delayed blasts: Apply on a committed hit; -- `Mine.Explode`; -- the tests, which call `Detonate` directly (see **Test seam**). - -**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a -caller. - -#### Test seam - -`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen -intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production -interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from -`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is -committable with no seam and no reflection. - -It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at -an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes -a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and -exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: - -- angled direct-hit correctness is covered by the model sweep and - `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; -- P1 and P2 stay uncommitted. - -#### Verification - -These fixture rules apply to every blast test that touches the frame: - -- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a - deleted `/ SchematicCellSize` survives. -- The target is off the origin. -- The facings include one with |fx| > |fy|. -- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. - -Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 -absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. - -**Geometry.** - -- `TheDiscIsConservedOverTheGrid`. - - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius - under one cell, one centred on a cell corner, and one off the grid. - - Pass: the total equals `damage`. - - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box - that clips the rim cells; a dropped chord breakpoint. -- `EachCellTakesItsShareOfTheDisc`. - - Setup: a solid hull with no armour and no items. - - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. - - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the - cell's centre. -- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). - - Setup: a blast straddling the hull's edge. - - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The - shares sum to the blast's damage minus what falls off the hull. - - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. -- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. - - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. - - Pass: the near end takes damage. - - Kills: Splash's centre-distance cull. - -**Absorption order.** - -- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` - (`FireControlCut123Tests.cs:750`). - - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage - levels of the old test: below the armour, between the armour and armour plus durability, and above both. - - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. - - Kills: the item absorbing before the armour, and armour being skipped. -- `ArmourProtectsOnlyItsOwnCellsShare`. - - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. - - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. - - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat - cell B's share. -- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. - - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but - more than `.1` on the two together (the `ProbeC2` geometry). - - Pass: the item absorbs the sum. - - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or - a pool that resolves before every covered cell has deposited. - - This test is also what pins that the result does not depend on which cell is considered first. As shown - above, the threshold is the only order-sensitive rule a blast has left. -- `ItemDamageFiresOncePerCoveredCell`. - - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive - post-armour deposit, and each event carries that cell's deposit. - - Kills: one event per item, and events for zero-deposit cells. - -**Symmetry and frame.** - -- `MirroredBlastsDoMirroredDamage`. - - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of - world points mirrored across the target's bow axis, with the mirror computed by the test from the facing - and not through `ToSchematic`. - - Pass: per-cell armour, item durability and hull damage mirror. - - Kills: - - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); - - a missing or wrong `CenterOfMass` anchor; - - any scheduling rule that depends on enumeration order. - - It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test - does. -- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` - (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). - - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's - width. - - Pass: port cells are damaged, and starboard cells are untouched. - - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. - -**Shields** (under Q12-9 = A). - -- `ShieldPaysForWhatReachesIt`. - - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. - - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` - (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the - covered share, never loosened. - - Kills: charging the full damage. -- `ABlastShotsShieldIsDecidedAtDetonation`. - - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at - arrival. - - Pass: the shield is charged the covered share. - - Second setup: a shield that could absorb the whole shot at commit. - - Pass: the shot still detonates, and the shield pays its covered share. - - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the - host. - -**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). - -- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit - behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. - - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour - of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. - - A contact fuse with the same stats leaves the cockpit untouched. - - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in - order, and the cockpit takes only what the ring left (12.3). Damage in a line. - - Kills: - - absorbing along the burrow; - - a contact fuse reading penetration; - - routing every penetrating shot through the fuse path; - - a fuse without a radius detonating. -- `TheFusePointStaysOnMetal`. - - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap - behind the first run. - - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the - gap's near edge). - - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. -- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. - - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. - - Pass: the host's per-cell damage equals that of the same shot with no turn. - - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. -- `LabelsDoNotDecideBehaviour`. - - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with - `Fuse = Proximity` and no label detonates. -- `AFuseWithoutARadiusIsADirectHit`. - - Pass: `Fuse = Contact` with a null or zero radius does lane damage. - - Kills: Fire freezing `Fuse` without the radius check. -- Re-fixtured to `Fuse`, with their behaviour unchanged: - - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes - `ProximityDetonatesEvenOnAGuaranteedMiss`; - - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); - - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; - - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes - `ABlastHitsEveryEntityItCovers`. -- `AContactBlastReportsTheHit`. - - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. - - Kills: `IncomingHit` moved inside the direct-hit branch. - -**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: - -- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes - `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with - a remainder of 0. -- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool - of `{ .1f }`. - -**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the -clip rule are otherwise untouched. - -**Negative greps:** - -- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` - returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and - `FireControlCut4/5/6/11/123Tests.cs`. - - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are - unrelated. - - Exclude `StrykerOutput`, `bin` and `obj`. -- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the - stale-comment list above. - -**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. -`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). - -#### Operator - -- Author one proximity weapon and one delayed-fuse weapon in a scratch catalog. -- Watch an airburst beside a LonginusX and a penetrator into its nose, and check that the damage lands where - the model shows the blast. That verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of - the frame that only a running blast can show. - -#### History (superseded 2026-09-25, kept for the record) - -The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: - -- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** - Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour - stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. -- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with - `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. -- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** - Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then - `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit - had judged unshielded: two authorities over one shield. -- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and - `Step` has no damage branch. -- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one - `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. -- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was - `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. - -### 0b. Identity, lifecycle, authority - -Two persisted changes, both on `aetheria.weaponitemdata` v1 and both in 12.4: - -| Field | Slot | Change | Lifecycle | Reader risk | -|---|---|---|---|---| -| `BlastRadius` (was `AirburstRange`) | 29 | rename, same `float?` type | authored catalog | compatible drift: `CompareSchemaShapes` ignores member names (`CultCache.cs:672-752`, CultLib `45c2f40`); drift warnings until the next catalog write | -| `Fuse` | 32 | new, `WeaponFuse?` | authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule | - -Runtime-only and never serialised (0b table, `FireControl.cs:626-629`, `:686-689`): - -- `PendingShot`: `PFire`, `TravelDirection`, `Spread`, `FireRange`, `Fuse`, `BlastRadius`. -- `ShotOutcome`: `Bearing`, `Lateral`. - -Nothing in saves changes shape. - -### Operator questions - -Ruled 2026-09-22 (the words are recorded under **Rulings**): - -- **Q12-1: B**, the bearing is taken at Commit. -- **Q12-2: A**, spread is width. -- **Q12-3: A**, the lane remainder goes into the hull where the lane ends (direct hits). -- **Q12-4: A**, `ShotOutcome.Aimed` is deleted. -- **Q12-6: no.** Operator: "12-6, nope". No catalog test ties a label to behaviour. - `WeaponModifiers` stays authored metadata that nothing checks and nothing reads in the - simulation, and a weapon whose label disagrees with its data is authoring's business. -- **Q12-7: dissolved, not answered.** It asked where a blast ray's remainder goes. With - blasts as areas there are no rays and no remainder to route, so the question has no - subject. The ray model's shadowing goes with it (see **Accepted loss** in 12.4). -- **Q12-8: A, and it is scoped to weapons that carry a blast.** Nothing is absorbed along - an *explosive penetrator's* burrow: it reaches its fuse depth without spending damage, - and the explosion is the payload. Penetration is a reach decision there — how deep the - fuse point can sit — not a damage budget. - - The first wording ("the burrow is travel, not damage") read as if it covered every - penetrating shot. It does not. Operator, 2026-09-22: "Q12-8 is odd. Like, penetration is - still a thing even if it's not a warhead. For a dumb AP round, you'd expect it to do - damage in a line, not dump all its damage at max depth, that would make AP rounds even - better at component sniping than AP explosives". Agreed, and the map never meant - otherwise: a weapon with no blast radius takes 12.3's lane, absorbed cell by cell in order - with the remainder carrying on, and Q12-8 does not reach it. See **Where 12.3 and 12.4 - meet**. - - So "armour absorbs first" holds everywhere it was ruled: down the lane for a direct hit, - and per covered cell for a blast. Against an explosive penetrator, armour protects by the - reach gate and by absorbing its own cell's share of the disc. The consequence to watch - when F12-2 tunes penetration is that an explosive penetrator with reach pays nothing for - the plate it crosses, while an AP round pays for every plate it passes. - -Open: - -**Q12-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. - -**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a -blast that needs a hit?** - -The current map lets two places decide it: - -- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. -- `Detonate` then decides every shield in the radius live, and that includes the host. - -**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so -the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can -absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split -this campaign has cut three times. - -- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield - for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the - radius, live at detonation. - - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no - direction", applied to shields. - - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that - reaches the hull. For a contact detonation on the hull's face, that is roughly half. - - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads - them. -- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole - and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" - parameter: a compensator that keeps two owners alive, one per entity. - -What depends on it: - -- the gate on Commit's shield block; -- whether `Detonate` takes a host parameter; -- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. - -Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half -of (b) can proceed while this waits. - -**Defaults taken, not rulings.** The operator may overrule any of these: - -- A blast shot ignores `DamageSpread`, and P sits on the centre lane. -- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. -- The delayed fuse point is clamped to the last reached cell's exit. -- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. - -- **Q12-5 (not ruled). Should the HUD show which target items are exposed from the current - bearing?** - - **Recommended:** a follow-up after 12.3. It would be a presentation read of `Silhouette` - and `Lane`, the way Inspect is: for each revealed item, is it first in some lane of the - current shadow. It belongs on the target-item cycling UI. - -### Follow-ups recorded, not in this cut - -- **F12-2. Penetration retune** (operator: "Retuning penetration is a follow up."). - - Shipped values are ≤ 0.25 against an inspector range of 0..1, and enclosed cockpits - need about 2 cells. - - The same pass decides which weapons get fuses and blast radii (none have one today), - and writes the catalog once to clear the slot-29/30 drift warnings. - - Until it lands, interior cells are reached only by blasts. -- **F12-3. SigmaFloor's purpose changed.** It was a numerical guard and is now a design - minimum on group tightness. Revisit it when Precision is next tuned. -- **F12-4. Double charging.** 6d's note that pSpread and pOnHull both price target size - still stands. Both now read one Span and shadow, so a later merge is mechanical. -- **F12-5. AI and proximity blasts.** `Combat.cs:117` gates a proximity weapon on a - direct-hit forecast that does not decide its damage. This is pre-existing. -- **F12-6. Mine's blast inputs.** The prefab `BlastRange` and the arming OverlapSphere - (`Mine.cs:69-78`, `:99`) are presentation-side (Cut 4 Q7). Mine has no catalog weapon - record carrying `Fuse`/`BlastRadius`. It can move when it ships (R9). -- **F12-7. Blast falloff toward the rim.** Every cell takes its area share flat, so a cell - at the rim is hurt as hard per unit of area as the one under the fuse. A radial falloff is - a tuning decision for the same pass that authors radii, and it changes only the weight - inside the integral, not the owner. -- **F12-8. Blasts do not shadow.** Nothing inside an area is occluded, so a bulkhead - protects only its own cell, and two ships caught by one blast do not shade each other. - This is the accepted cost of areas over rays. Reopen it only if play shows interiors are - too soft. -- **Behaviors.CauseDamage** (`Behaviors/Behaviors.cs:62-73`) writes durability and - `HullDamage` without `Absorb`. It is self-damage, not a hit, and is left alone. -- **Orphaned `Airburst*` YAML** in nine weapon prefabs. It is harmless, and Unity drops it - on the next save of each prefab. - -### Subtraction ledger (estimates, with actuals) - -| Sub-cut | Removed | Added | Notes | -|---|---|---|---| -| 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | -| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | -| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | -| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | -| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | -| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | - -Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: - -- 12.1: `b851b0d1^..b851b0d1`; -- 12.2: `b851b0d1..c7b6e80c`; -- 12.3: `c7b6e80c..d402283c`. - -The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: - -- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; -- 12.3: sequential, proportional, order-free absorption; -- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, - `Absorb` and `Step`'s branch. - -12.4's own delta is ~+60, and that is the estimate to hold it to. - -**Section history:** Cut 6d's "The rule" (the 2D kernel) and its premise that pOnHull folds -into PBase at Fire, 9.2's SigmaFloor rationale, and Cut 4's Splash rule (`FireControl.cs:946-959`) -describe models this cut replaces. Self should mark them as history in the status header -as 12.2 and 12.4 land. +Date: 2026-09-19 + +Status: Imagination pass, cut map. Nothing here has landed. Ends are owned by +`docs/three-gates-scope.md` ("Fire control") and +`docs/shield-presentation-contract.md` ("The commit window"); this document owns +the means. + +Anchors are against `codex/item-provenance` HEAD `8a00fc92`, read-only. Nothing was +built in the main tree and Unity was not run. Claims marked **(probe)** were measured +by running `tools/AetherDb` (`census`, `hardpoint-fit`) against the live catalog +`GameData/Aetheria.cc` from the free worktree `F:\Projects\Aetheria-stats`, CultLib +pinned at the shared `45c2f40` worktree. Claims marked **(read)** come from reading +source at the cited anchor. + +**This map supersedes Cut 2 of `docs/headless-playground-cut.md`** +(`:490-810`, anchored to `562cdcf2`). That draft is now history for three reasons: +its anchors predate the stats-and-power campaign, which moved most of the code it +cites; it predates the commit-window ruling of 2026-09-18, which changes the shape of +a shot from an instant to a lifecycle; and it predates the per-hardpoint arc override +and 360-degree turret rulings. Its substrate probes are still good and are carried +forward below with citations. Where the two disagree, this document wins; nobody +should be reading two live designs. + +**Scope: fire control only.** What a presentation does with a committed outcome is the +capability/presentation seam's business (`docs/shield-presentation-contract.md`). +Faction territory is the campaign after this. §6 names every seam and stops there. + +--- + +## Rulings this map is built on (do not re-litigate) + +From `docs/three-gates-scope.md:47-99` and `docs/shield-presentation-contract.md:34-57`: + +- **R1. Hit detection goes.** Operator, 2026-09-17: "We wanted to get rid of hit + detection anyway... Just roll the dice based on weapon stats, targeting system stats + (new subsystem) and sensor state." +- **R2. Targeting systems are a new subsystem**, interior `Tool` gear. +- **R3. A shot resolves on arrival**, hit chance reduced by how far the target deviated + from the predicted intercept. Manoeuvring is evasion. +- **R4. The outcome commits a short fixed time before impact.** Operator, 2026-09-18: + "That hybrid is the way." Deviation feeds the roll up to that horizon; after it the + result stands and the rest of the flight is choreography. The horizon is one authored + fire-control setting. A commit is authoritative; presentations perform it and never + predict. +- **R5. Subsystems reveal between `TargetArmorInfoThreshold` and + `TargetGearInfoThreshold`**, and both the player and the AI may aim at a revealed + subsystem. +- **R6. Per-hardpoint firing arcs.** Mount direction from the item's `ItemRotation`, as + reaction thrusters do; default 120 degrees from `GameplaySettings`; an optional + per-hardpoint override; turrets 360. +- **R7. The simulation is 2D.** The 3D is set dressing. +- **R8. Unity weapon effects and `HullCollider` become presentation only.** Their + `Physics.*` queries stop deciding damage. +- **R9. Every weapon type in the catalog's history stays** + (`docs/headless-playground-cut.md` fork E). Nothing is deleted for being unused. + +From `docs/stats-and-power-cut.md:996-1030`, the seam the stats campaign left: + +- **R10. Fire control must snapshot what it rolled over.** The resolver hands out a + current value; it does not promise that value is still current a second later. +- **R11. A starved weapon does not fire** (Cut 4 of that campaign: no fraction of a + shot). A degraded shot instead is a ruling against that map, not a thing to work + around here. See Q6. + +--- + +## 0. Substrate + +### 0.1 What decides a hit today, end to end + +Nothing in `ServerShared` does. The whole chain is Unity **(read)**: + +1. `InstantWeapon.Execute` (`Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs:216-237`) + walks the burst and raises `OnFire` (`:228`), a parameterless `event Action` declared + at `:73`. It carries no outcome and nothing in `ServerShared` subscribes. +2. `Assets/Scripts/Gameplay/EntityInstance.cs:203-204` subscribes and calls + `InstantWeaponEffectManager.Fire(weapon, item, source, target)` + (`Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs:7`). The constant + family goes the same way at `:232-235`. +3. The manager spawns an effect and copies the weapon's resolved numbers onto it. + `ProjectileManager.cs:18-25` jitters the barrel forward vector by + `Random.Range(±Spread/2)` on all three Euler axes — that is the entire aiming model. +4. The effect raycasts. `Projectile.cs:58`, `GuidedProjectile.cs:153`, `Laser.cs:40`, + `Lightning.cs:34`, `ConstantLaser.cs:74`, `ConstantLightning.cs:54`, + `HitscanEffect.cs:31`, `Mine.cs:68,92`, plus `Projectile.cs:114` for airburst. +5. On a hull collider it calls `HullCollider.SendHit` + (`Assets/Scripts/Gameplay/HullCollider.cs:18-30`), or `SendSplash` (`:32-41`). +6. `EntityInstance.cs:316-396` subscribes to those subjects, converts a UV texture + coordinate into schematic cells, expands by `DamageSpread`, marches a penetration ray, + and calls the local function `DamageSchematic` (`:277-314`), which is the only writer + of `Armor`, item `Durability` and `Hull.Durability` from combat. +7. Death: `EntityInstance.cs:405-439` subscribes to `HullDamage`, rolls loot with + `UnityEngine.Random`, and calls `entity.Zone.Entities.Remove(entity)` (`:437`). + +**Authority versus presentation in that chain.** Everything from step 3 down is +authority wearing a presentation costume. The shield branch repeated in all seven +effects (`Projectile.cs:60-80` and its copies) is the absorb rule, duplicated seven +times. `DamageSchematic` is the damage rule. `EntityInstance.cs:437` is the death rule. +Genuine presentation is narrow: trail rendering, line renderers, the lightning compute +animation, hit effect prefabs, `ShieldManager.ShowHit`, and the destroy effect. + +**The 2D/3D violation.** `EntityInstance.cs:323` and `:380` call +`transform.InverseTransformDirection` to get a hit direction into the ship's frame. That +is a 3D rotation standing in for the planar rotation R7 says the simulation is made of. + +### 0.2 What already exists of targeting + +- `Entity.Target` — `Assets/Scripts/ServerShared/Entity.cs:46`, a + `ReactiveProperty`. Cleared when the target leaves `VisibleEnemies` (`:196`, + `:222`). `TargetRange` is recomputed each tick at `:920`. +- `Entity.EntityInfoGathered` — `Entity.cs:57`, a `ReactiveDictionary` in + [0,1], accumulated with decay by `Sensor.Execute` + (`Assets/Scripts/ServerShared/Behaviors/Sensor.cs:152-184`). +- `TargetDetectionInfoThreshold` gates `VisibleEnemies` at `Entity.cs:232-246`. +- **`TargetArmorInfoThreshold` and `TargetGearInfoThreshold` are authored and read by no + code.** They are declared at `Assets/Scripts/ServerShared/Settings.cs:212-213` and + appear nowhere else in `Assets/Scripts` **(read)**. R5's reveal tiers are unwired + settings. +- `LockWeapon` — `Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs`. Integrates a + 0..1 `_lock` scaled by `pow(EntityInfoGathered[target], SensorImpact)` (`:92`), gated by + a `LockAngle` cone off `Entity.LookDirection` (`:88-89`). `CanFire` (`:56`) requires + `_lock > .99f` and range within `[MinRange, Range]`. This is the closest thing in the + codebase to a to-hit probability, and it is per-weapon rather than per-ship. +- `TurretController` — `Assets/Scripts/ServerShared/Behaviors/TurretController.cs`. + `TurretControllerData` has no fields (`:15-25`). It writes `Entity.LookDirection` to a + first-order intercept (`:67-72`) and fires each weapon when + `dot(x.Direction, Entity.LookDirection) > .99f` (`:81-83`). +- `Sensor` — sensitivity, ping boost/energy/visibility/range/cooldown, all + `PerformanceStat` (`Sensor.cs:16-34`). +- Player target selection: `Assets/Scripts/Gameplay/ActionGameManager.cs:368-403` + (reticle, nearest, next, previous). Player fire is not arc-gated at all. +- **No targeting-system item, behavior or stat exists anywhere in code or catalog.** A + repo-wide grep for `Targeting|FireControl|Accuracy|Aim` finds one `#region Targeting` + label at `ActionGameManager.cs:368` and nothing else; the same grep over the catalog + returns zero **(probe)**. + +### 0.3 Where hardpoint rotation and arcs live + +- `HardpointData` — `Assets/Scripts/ServerShared/ItemData.cs:538-545`: `Type`, + `Position`, `Shape`, `Transform` (a prefab node name), `Rotation` (`ItemRotation`), + `Armor`. **There is no arc field on `HardpointData`, `WeaponData`, or any effect.** + A per-hardpoint override attaches here, as catalog data already authored per hull. +- `ItemRotation` is four-way (`Assets/Scripts/ServerShared/Enums.cs:7`); + `Extensions.cs:135-166` gives `None` = forward, `Reversed` = aft, `Clockwise` = right, + `CounterClockwise` = left. `Agent.cs:72` reads `Clockwise` as right and `Thruster` uses + the same convention for mount direction, which is the precedent R6 names. +- `Behavior.Direction` (`Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:24-44`) has + two paths. The first (`:30-34`) reads `Entity.HardpointTransforms`; the second + (`:37-38`) is the item-rotation fallback R6 wants. +- **`Entity.HardpointTransforms` (`Entity.cs:58-59`) is written only by Unity**, at + `EntityInstance.cs:514-517`, from the first `WeaponHardpoint.FiringPoint` barrel's + world forward. Headless it is empty. That single dictionary is why + `Combat.cs:100-102` throws `KeyNotFoundException` on the first combat tick outside + Unity, and why the simulation's own notion of where a gun points is a readback from a + renderer. +- Barrel swing is `ArticulationPoint`, yawing and pitching toward `LookAtPoint` + (`EntityInstance.cs:400-403`, `:519-520`). Authored limits on the Longinus energy + barrels are yaw [-1, 1] — effectively fixed forward — while the Turret prefab's yaw + pivot is [-720, 720] (`docs/headless-playground-cut.md:533-545`, prefab probe, not + re-measured here). That is presentation and stays. +- `AgentFiringMinDot` (`Settings.cs:218`) is **declared and read by nothing** **(read)**. + +### 0.4 What the catalog ships in weapons + +**(probe)**, `tools/AetherDb census` and `hardpoint-fit` over `GameData/Aetheria.cc`: +51 designs, 37 products, of which 18 are weapons. + +| Hardpoint kind | Designs | Sellers | +|---|---|---| +| Ballistic | 6 | Zhestokost 2, Lightsail 1, AU 1, DME 1, unsold 1 | +| Energy | 7 | NiteLife 3, Lucent 1, Adrasteia 1, Alakrita 1, unsold 1 | +| Launcher | 5 | DME 1, R&D 1, unsold 3 | + +Unsold, therefore unspawnable: `Autocannon`, `SRMM72`, `LRMM72`, `plight`, `pswarm`. +The earlier behaviour-class breakdown (8 `ProjectileManager`, 3 `LaserManager` at +velocity 0, 5 `GuidedProjectileManager`, 1 `LightningGunManager`, and **no design using +`ConstantWeaponData`, `HitscanEffect`, `Mine`, airburst or MIRV**) is carried from +`docs/headless-playground-cut.md:500-506` and was not re-measured; the census agent +could not reproduce it because `census` buckets by `HardpointType` and the one command +that prints behaviour fields was blocked. **Cut 4's safety argument depends on that +claim, so Hands re-measures it before Cut 4** (§4, First). + +**Stats a roll can read today** (`Weapon.cs:22-68`, all `PerformanceStat` unless noted): +`Damage`, `Penetration`, `DamageSpread`, `MinRange`, `Range`, `Energy`, `Heat`, +`Visibility`, `Spread` (an angular cone in degrees), `Velocity`; plus `DamageCurve` +(a `BezierCurve`, damage against normalized range), `MagazineSize`, `ReloadTime`, +`AmmoType`. `InstantWeaponData` adds `Count`, `BurstTime`, `Cooldown` +(`InstantWeapon.cs:13-22`). `LockWeaponData` adds `LockSpeed`, `SensorImpact`, +`LockAngle`, `DirectionImpact`, `Decay` (`LockWeapon.cs:13-25`). The item carries +`WeaponRange`, `WeaponCaliber`, `WeaponType`, `WeaponFireTypes`, `WeaponModifiers` +(`ItemData.cs:476-492`). + +Note against a misreading: **`DamageSpread` is not damage variance.** It is the +footprint, in schematic cells: `EntityInstance.cs:372-375` expands the hit shape +`round(hit.Spread)` times, where `hit.Spread` is the weapon's `DamageSpread`. + +**What is missing from the authored data for a roll to be meaningful:** + +- **No accuracy stat on anything.** No weapon, hull, cockpit or sensor contributes a + to-hit number. This is what R2's targeting system exists to supply, and it is the + whole reason the subsystem is required content rather than a nice-to-have. +- **No target-size term.** A roll that ignores how big the target is will feel wrong at + every range. The hull `Shape` and a cell size give an angular radius; `SchematicCellSize` + does not exist and must be authored (Q4). +- **No tracking or angular-velocity stat**, so nothing yet reads R3's deviation. The + targeting system supplies it. +- **Beams have nothing beam-specific.** `ConstantWeaponData` adds only `AmmoInterval` + (`ConstantWeapon.cs:15`); `Spread` is inherited and unread by any beam code, and + `Velocity` is meaningless for one. There is no dwell or wander stat. Unused today, so + Cut 4 gives beams the plainest possible rule and authors nothing new. +- **`ChargedWeaponData.ChargeFiringSpreadMultiplier` is a plain `float`** + (`ChargedWeapon.cs:34`), not a `PerformanceStat`, so unlike every other combat number + it cannot vary with quality or wear. Noted, not fixed here. +- **Lock is binary at 0.99** (`LockWeapon.cs:56`). There is no stat turning partial lock + into hit chance, and `GuidedWeaponData` (`Launcher.cs:37-55`) has no lock stats at all. + +### 0.5 Can the headless sim step a full engagement today + +No, and two specific things stop it **(read)**: + +1. `Combat.cs:100-102` indexes `HardpointTransforms`, which only Unity writes (§0.3). + `Minion` enters `CombatState` as soon as `VisibleEnemies` is non-empty, so any NPC + that sees an enemy throws on the tick it sees them. +2. There is no owner between `InstantWeapon.OnFire` and `Entity.HullDamage`. The only + `ServerShared` writers of durability are the repair consumable + (`Behaviors.cs:67,72-73`). + +Everything else is already there and already exercised. +`tests/Aetheria.Shared.Tests/IffAndCombatTests.cs:52` (`BuildWorld`) opens the real +catalog, builds an `ItemManager` and a `Zone`, and `:73` (`NewShip`) equips a gun on a +hardpoint and activates the ship — two ships, positions, look directions, sensor info, +targets and IFF, headless. `:241` and `:255` assert on `weapon.Progress` after +`Activate()`, because a cooldown starting is the only observable consequence of firing +that exists. That fixture is this campaign's harness; it does not need to be built. + +`Zone.Update` (`Zone.cs:126-151`) steps orbits, belts, agents, then entities. +`ItemManager.Random` is still clock-seeded (`ItemManager.cs:19`), so a seeded-roll test +needs a seed argument — see Q5. + +The xunit project is `tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj` +(net10.0, xunit), referencing `Aetheria.Shared/Aetheria.Shared.csproj` +(netstandard2.1, `noEngineReferences` mirrored by +`Assets/Scripts/ServerShared/Aetheria.Shared.Unity.asmdef`). The mutation harnesses are +`tests/mutation_tests_*.py`, 17 of them, each patching a named source anchor and +asserting a named test goes red with a no-op control. + +--- + +## Rulings (operator and Self, 2026-09-19) + +- **Q4, unaided accuracy: really, really bad.** Operator: "Targeting with the mark one eyeball + should be really, really bad." A ship with no targeting system can hit something close, + slow and unaware, and little else. That makes the targeting subsystem **required equipment** + rather than an upgrade: every hull needs somewhere to put one, the catalog needs them at + several price points, and a stripped hull is a genuine handicap. Hands picks the other two + balance numbers; this one is authored to make unaided fire a last resort, and the operator + tunes it in play. +- **Q1, turrets: author `FiringArc: 360`.** No new type for what a number already says. +- **Q2, player fire is arc-gated** like everyone else's. Manual and programmatic must not be + two truths. +- **Q3, death removes the ship**, and loot stays as it is until the pickup cut. +- **Q5, seed the rolls in this campaign.** Two lines here beats waiting on an unstarted cut. +- **Q6, snapshot at fire, not at commit** (Self, accepting Imagination's counter-proposal to + the stats map's §8 seam). A shot carries the gun that fired it: its parameters freeze when + the trigger is pulled, and nothing about the shooter's heat, power or wear changes them + mid-flight. Evasion still decides the outcome, because deviation is measured at arrival + against the predicted intercept. `docs/stats-and-power-cut.md` §8 said "snapshot at commit"; + that is superseded, and the resolver's promise (a current value, not a stable one) is + honoured either way. +- **Q7, mine proximity physics: leave it.** + +## 0b. Identity, lifecycle, authority + +One row per kind this campaign introduces. No cut below is mapped with a cell empty. + +| Kind | What names it | What happens to it over time | Who decides | +|---|---|---|---| +| **A shot in flight** | `PendingShot`, a struct in `Zone.PendingShots` (a `List`), identified by a monotonic `int ShotId` from `Zone`. Nothing outside `Zone` holds a reference. | Born in `FireControl.Fire` when a weapon's burst step fires, carrying a payload snapshot frozen at that instant. Ages by `dt` in `FireControl.Step`, called from `Zone.Update` after entities. Reaches its commit horizon, then its arrival time, then is removed the same tick it resolves. A shot whose source or target entity leaves the zone resolves as a miss and is removed. Never serialised: a zone saved mid-flight loses its shots, which is correct — a save is a scene boundary. | `Zone` owns the collection and the identity. `FireControl` owns the transitions. Nothing else may add, remove or mutate one. | +| **A roll** | Nothing. It is not a thing; it is one draw from `ItemManager.Random` inside `FireControl.Commit`, and only its result is named. | Exists for the duration of one call. Its inputs are the payload snapshot plus deviation measured at that instant; its output is written into the shot's `Outcome` and never recomputed. | `FireControl` alone. No behaviour, agent, presentation or UI may draw for a hit. | +| **A commit** | `ShotOutcome { int ShotId; Entity Source; Entity Target; EquippedItem Weapon; bool Hit; bool Shielded; EquippedItem Aimed; int2 Cell; float ArrivalIn; DamageType DamageType; }`, published on `Zone.ShotCommitted` (a `Subject`). | Created once, at `ArrivalTime - CommitHorizon`, or at fire time when the flight is shorter than the horizon. Immutable from then on. Published once. Read by presentations and by nothing else. Dies with the shot. | `FireControl` creates it and is the only writer. Presentations are the only readers. **Nothing outside presentation may read a commit to change state** (`docs/shield-presentation-contract.md:57`). | +| **A targeting solution** | Not a stored object. `FireControl.HitProbability(weapon, source, target)` is a pure function, recomputed on demand. | Has no lifetime. AI reads it to decide whether to fire; the HUD reads it to draw a number; `FireControl.Fire` reads it once per shot and freezes the result into the payload. Two reads a tick apart may differ; nothing caches one. | `FireControl`. The targeting-system behaviour owns only its own resolved stats; it does not compute probability. | +| **Target data / knowledge of a subsystem** | `Entity.EntityInfoGathered[other]` (existing, `Entity.cs:57`) is the knowledge. `FireControl.IsRevealed(observer, item)` is the derived predicate over it. The current aim point is `Entity.TargetItem`, a `ReactiveProperty`. | Info accumulates and decays continuously under `Sensor` (`Sensor.cs:152-184`); ownership of that does not change. Reveal is **derived on every read**, never stored and never cached, so it falls back the moment info decays. `TargetItem` is written only by `Entity.TrySelectTargetItem`, nulled when `Target` changes, and **read through a derived accessor that returns null if the item is no longer revealed or no longer belongs to the target** — no loop clears it. | `Sensor` owns info. `FireControl` owns the reveal rule and the thresholds it reads. `Entity` owns the selection slot; `TrySelectTargetItem` is its only writer. Player input, `CombatState` and the test harness are its three callers. | +| **A hardpoint arc** | `HardpointData.FiringArc`, a `float` on catalog data (`ItemData.cs:538-545`), zero meaning "use the default". Resolved through `FireControl.ArcFor(item)`. | Authored once, in the catalog. Immutable at runtime. Read per shot and per AI fire decision; never stored on the entity. Mount direction is likewise derived per read from `Behavior.Direction`'s item-rotation path. | The catalog authors it; `FireControl` resolves and applies it; `GameplaySettings.FiringArc` supplies the default. **No prefab, transform, articulation limit or renderer may influence it.** | + +--- + +## Cut order + +1. **Mount direction and arc become simulation data.** Deletes `HardpointTransforms` + and the Unity readback; adds the arc gate; unbreaks headless combat. +2. **Targeting system and reveal.** The new subsystem, the reveal rule, the selection + slot, and the two dead settings wired up. +3. **Hit authority moves.** The Physics path in the four effects the catalog actually + uses is deleted and `FireControl` plus the shot lifecycle replaces it, in one cut, so + damage is never applied twice. +4. **The unused kinds join the same path.** Beams, hitscan, mines, splash and airburst + stop querying Physics. They keep existing (R9). + +Cuts 3 and 4 cannot be swapped, and 3 cannot be split into "roll" and "delete the old +path": a cut boundary between them is a cut boundary with two damage owners. + +--- + +## Cut 1. Mount direction and arc become simulation data + +- **Repo/branch:** `Aetheria`, branch `codex/fire-control` from `codex/item-provenance` + `8a00fc92`. Depends on nothing. +- **First:** update `F:\Projects\Aetheria-stats` to HEAD (it sits 11 commits behind at + `c4f6029e`); capture `AetherDb census`, `loadout 1`, `dangling` as the before side. + +**Deletes first:** + +- `Assets/Scripts/ServerShared/Entity.cs:58-59` — `HardpointTransforms` (2). +- `Assets/Scripts/Gameplay/EntityInstance.cs:514-517` — its only writer (4). +- `Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:30-35` — the readback branch in + `Behavior.Direction` (6). The item-rotation path at `:37-38` becomes the whole body. +- `Assets/Scripts/ServerShared/Agents/States/Combat.cs:100-102` — the + `HardpointTransforms` dot test (3). +- `Assets/Scripts/ServerShared/Behaviors/TurretController.cs:81-83` — the `.99f` dot + test (3). +- `Assets/Scripts/ServerShared/Settings.cs:218` — `AgentFiringMinDot`, read by nothing + (1). + +**Adds:** + +- `Assets/Scripts/ServerShared/FireControl.cs` — a new static class, this cut holding + only geometry: `MountDirection(EquippedItem)` (the planar unit vector from + `Behavior.Direction`), `ArcFor(EquippedItem)` and + `bool InArc(EquippedItem weapon, float3 toTarget)`, which tests + `dot(normalize(weapon.Direction.xz), normalize(toTarget.xz)) >= cos(radians(arc/2))`. + The test is planar (R7); target height never enters it. An arc of 360 or more passes + unconditionally. +- `HardpointData.FiringArc` (`ItemData.cs:545`, next `Key`) — full width in degrees, + `0` meaning "use `GameplaySettings.FiringArc`". +- `GameplaySettings.FiringArc = 120` (`Settings.cs`, after `:214`). +- `GameplaySettings.AgentMinHitProbability` is **not** added here; Cut 1's AI gate is + `InArc` alone, which is strictly closer to today's behaviour than a probability the + roll does not yet produce. + +**Per-file changes (against `8a00fc92`):** + +- `Combat.cs:100-102`: `shouldFire = FireControl.InArc(testWeapon.Item, toTarget)`. The + intercept prediction at `:84-98` stays — it still aims `LookDirection`, and under R3 it + becomes the predicted intercept a deviation is measured against. +- `TurretController.cs:81-83`: the same call. Under the default arc a turret hull, whose + `Direction` never rotates (`TurretController` writes only `LookDirection`), would + engage only within 60 degrees of its spawn facing. R6 says turrets are 360; the turret + hull's hardpoints author `FiringArc: 360`. See Q1. +- Catalog: author `FiringArc` on the `Turret` hull's two Ballistic hardpoints. Follows + the Cut C precedent — a Hands scratch console upserts, Unity and Studio closed. + +**Authority map:** + +- **Owner:** `FireControl` owns whether a weapon bears on a point. `HardpointData` + authors the arc; `ItemRotation` authors the mount direction. +- **Inputs:** the equipped item's hardpoint rotation, `HardpointData.FiringArc`, + `GameplaySettings.FiringArc`, the planar vector to the target. +- **Outputs:** one bool, consumed by `CombatState`, `TurretController`, and from Cut 3 + by `FireControl.Fire`. +- **Derived state:** `Behavior.Direction` becomes purely derived from item rotation. +- **Forbidden writers:** no Unity transform, `ArticulationPoint`, `WeaponHardpoint` or + prefab node may influence aim. `Barrels`, `GetBarrel` and `ArticulationPoint` survive + as presentation and must never be read by `ServerShared`. +- **Shared paths:** player fire, AI fire and turret fire all go through `InArc`. Player + fire is arc-gated for the first time; see Q2. +- **Deletion line:** `HardpointTransforms` and its writer and readers are gone before + `FireControl.cs` is written. + +**Verification:** + +- builds: `Aetheria.Shared/Aetheria.Shared.csproj`, `tests/Aetheria.Shared.Tests`, + `tools/AetherDb`. +- tests, each named with the rule it pins: + - `ArcFollowsMountRotation` pins *mount direction is the item's rotation, not the + hull's facing*: a fixture hull with one weapon on a `None` hardpoint and one on a + `Clockwise` hardpoint; a target 90 degrees to starboard passes only for the + `Clockwise` weapon, a target dead ahead only for the `None` weapon. Mutation: make + `InArc` use `Entity.Direction`; swap `Rotate`'s `Clockwise`/`CounterClockwise` cases. + - `ArcBoundaryIsHalfWidth` pins *`FiringArc` is full width*: at 120, a target 59 + degrees off passes and one at 61 does not. Mutation: compare against + `cos(radians(arc))`. + - `ArcIsPlanar` pins R7: a target inside the arc horizontally but far above the firer + still passes. Mutation: use the 3D vector. + - `HardpointOverrideBeatsDefault` pins *the per-hardpoint override wins*: the same + weapon on a hardpoint with `FiringArc: 360` passes at 170 degrees, and fails there on + a hardpoint with `0`. Mutation: ignore the field; treat `0` as an arc of zero. + - `CombatStateStepsHeadless` pins *the simulation decides aim without Unity*: a + `Minion` with a target in range steps through `Zone.Update` without throwing. + Mutation: restore the `HardpointTransforms` read. +- negative: `rg -n "HardpointTransforms" Assets/Scripts` empty; + `rg -n "ArticulationPoint|FiringPoint|WeaponHardpoint" Assets/Scripts/ServerShared` + empty; `rg -n "AgentFiringMinDot" Assets/Scripts` empty. (`\.99f` is **not** a safe + negative grep — it occurs legitimately in `LockWeapon.cs:56`. Grep the two files by + name instead.) +- operator: in Unity, side-mounted weapons still fire at targets abeam; a turret still + tracks all the way around; nothing fires through the hull. + +**Risk:** this cut changes what the AI fires at before the roll exists, so combat feel +shifts once and shifts again in Cut 3. That is the price of deleting the obsolete +authority first, and it is the right price. + +--- + +## Cut 2. Targeting system and reveal + +- **Repo/branch:** continues Cut 1. Depends on Cut 1 (uses `FireControl.cs`), and on + provenance Cut C if that has not landed, because Cut C re-upserts every `ItemData`. +- **First:** `AetherDb census` and `loadout 1` over several seeds, as the before side. + +**Deletes first:** nothing. This cut is entirely additive, which needs its account +(CODE IS A LIABILITY): the invariant it buys is R5, which cannot be purchased by moving +authority, because no authority for it exists — two authored settings are read by +nothing and there is no subsystem to carry accuracy. Cut 3 is where the subtraction is. + +**Adds:** + +- `Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs` — + `TargetingSystemData : BehaviorData` (next free `Union` index on `BehaviorData`; Hands + reads the current list in `Behaviors.cs` rather than trusting the number 39 quoted in + the superseded draft) plus a `TargetingSystem : Behavior` that resolves and exposes: + - `Accuracy` (0..1) — the ceiling on hit probability. + - `Resolution` (detection threshold..1) — the info level at which sensor state stops + limiting hits. + - `Precision` (0..1) — the chance a hit lands on the aimed item rather than a random + cell. + - `Tracking` — how much deviation it forgives (consumed in Cut 3). + + All four are `PerformanceStat`, so lot quality, wear, heat and `PowerSupply` reach them + through `EquippedItem.Evaluate` — that is the brownout ruling applied to accuracy, and + it is why a starved targeting system rolls worse instead of switching off. Energy and + heat come from the existing `EnergyDraw` and `Heat` behaviours on the item; the + subsystem adds no power machinery of its own. +- `GameplaySettings.UnaidedAccuracy` — what an entity with no working targeting system + fires with. Such an entity uses `Resolution = 1` and `Precision = 0`: it can still + select a revealed item, and its hits scatter. +- `FireControl.IsRevealed(Entity observer, EquippedItem item)` — derived, never cached: + 1. Rank the target's non-hull equipment: hardpoint-mounted first, then size + descending, then equipment index. + 2. Item `i` of `N` is revealed when + `info >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1))`. + + This is the first and only reader of `Settings.cs:212-213`. +- `Entity.TargetItem` (`ReactiveProperty`, beside `Target` at + `Entity.cs:46`) and `bool Entity.TrySelectTargetItem(EquippedItem)`, the only writer. + It accepts null, or an item of `Target.Value` that `IsRevealed`. A `Target` change + nulls it. The read accessor returns null when the item is no longer revealed, so decay + drops the aim point without a loop. +- Player input: a select/cycle command beside `ActionGameManager.cs:368-403`, calling + `TrySelectTargetItem`. The target schematic + (`Assets/Scripts/UI/HUD/SchematicDisplay.cs:109-119`, which today lists only weapon + items) shows revealed items and marks the selection. That is presentation; it decides + nothing. +- AI: `CombatState` selects the revealed target `Weapon` item with the highest + `RangeDamagePerSecond(range)`, or null. +- Catalog: two targeting-system designs, 1-cell and 2-cell, each with products from at + least the manufacturers that sell the capacitor, so `IsAvailable` finds one in every + galaxy. `LoadoutGenerator.FillInterior` picks one with `required: true` for every + entity with `Weapons` — the starting LonginusX, NPC ships and turrets. Zenith, with no + weapon hardpoints, gets none. + +**Authority map:** + +- **Owner:** `FireControl` owns the reveal rule. `Entity` owns the selection slot. + `TargetingSystem` owns nothing but its own resolved stats. +- **Inputs:** `EntityInfoGathered`, the two threshold settings, the target's equipment + list and item sizes. +- **Outputs:** a bool per (observer, item); a nullable `EquippedItem` per entity. +- **Derived state:** reveal is derived on read. `TargetItem`'s *validity* is derived; + only the slot is stored. +- **Forbidden writers:** the HUD may not write `TargetItem` except through + `TrySelectTargetItem`; no code may store a reveal result; no code may read the two + thresholds except `IsRevealed`. +- **Shared paths:** player selection, AI selection and the test harness use one writer. +- **Deletion line:** none, and that is stated rather than hidden. + +**Verification:** + +- builds: as Cut 1. +- tests: + - `SelectionNeedsReveal` pins *you cannot aim at what you have not resolved*: below + the item's tier `TrySelectTargetItem` returns false; above it, true; a target change + nulls it; info decaying below the tier makes the read null. Mutation: `IsRevealed` + returns true; the `Target` change skips the null; the accessor returns the stored + value without re-checking. + - `RevealOrder` pins *the big obvious things resolve first*: hardpoint items reveal + before interior items, larger before smaller. Mutation: drop the ordering. + - `RevealSpansBothThresholds` pins *the tiers are the authored ones*: with N items, + the first reveals at `TargetArmorInfoThreshold` and the last at + `TargetGearInfoThreshold`. Mutation: swap them; use the detection threshold. + - `UnaidedFiresWorse` pins *the subsystem is what supplies accuracy*: an entity with + no targeting system resolves `UnaidedAccuracy` and `Precision` 0; one with a + destroyed or offline system resolves the same. Mutation: fall back to the system's + stats when offline. + - `StarvedTargetingRollsWorse` pins the brownout ruling on accuracy: with an + `Accuracy` stat carrying a `PowerSupply` term, a half-grant halves it rather than + switching the item off. Mutation: gate on `PowerSupply <= 1e-4`. + - `EveryArmedLoadoutGetsATargetingSystem` pins the required-item rule across seeds. + Mutation: drop `required: true`. +- negative: `rg -n "TargetArmorInfoThreshold|TargetGearInfoThreshold" Assets/Scripts` + returns `Settings.cs` and `FireControl.cs` **only**. +- operator: `AetherDb census` shows 2 more designs; `loadout 1` shows one more interior + item per armed entity; `dangling` is 0; reopen reports 0 schema issues; CultCache + Studio renders the new behaviour union (an operator check, because Studio's handling of + a new union index is not something this map can promise). + +**Risk:** `LoadoutGenerator.EquipHardpoints` can already fit the Tractor Beam into +LonginusX's only Sensors slot, leaving an NPC that gathers no info and therefore reveals +nothing and hits nothing (`docs/headless-playground-cut.md:520-523`). Check `loadout` +over several seeds. The fix is data or `EquipHardpoints`, not fire control. + +--- + +## Cut 3. Hit authority moves + +The rebuild. The old path is deleted and the new owner added in the same cut, because +any boundary between them is a boundary with two damage owners. + +- **Repo/branch:** continues Cut 2. Depends on Cuts 1 and 2. +- **First:** capture a Unity play smoke of current combat feel (kill time against one + NPC, roughly) as the before side, since this cut changes feel more than anything in + the campaign. + +**Deletes first** (paths and line counts against `8a00fc92`): + +- `Assets/Scripts/Gameplay/EntityInstance.cs:277-396` — `DamageSchematic` and both + `HullCollider` subscriptions (120). +- `Assets/Scripts/Gameplay/HullCollider.cs:13-14,18-41,48-65` — `Hit`, `Splash`, + `SendHit`, `SendSplash` and both argument classes (~45 of 65). `OnCollisionEnter` + (`:43-46`) stays; ship collision is a deferred Unity-physics surface. +- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:57-103` — the raycast, shield branch and + `SendHit` (47). `:109-122` (airburst) is Cut 4's. +- `Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs:152-189` — the same (38). +- `Assets/Scripts/Gameplay/Weapons/Laser.cs:40-75` — the same; the `LineRenderer` + endpoint writes at `:59,70,77` are presentation and are rebuilt from the outcome (36). +- `Assets/Scripts/Gameplay/Weapons/Lightning.cs:34-72` — the same; the endpoint capture + at `:67-71` stays as a visual (39). +- `Assets/Scripts/Gameplay/EntityInstance.cs:411-431` — the `UnityEngine.Random` loot + roll, **only if Q3 is answered (a)**; otherwise this cut leaves it and the seam is + named in §6. + +**Adds:** + +- `Entity.DamageSchematic(float damage, Shape hitShape)` — the rule moved verbatim from + `EntityInstance.cs:277-314`, now a method on `Entity` in `ServerShared`, writing + `Armor`, item `Durability`, `Hull.Durability` and raising the existing `ArmorDamage`, + `ItemDamage` and `HullDamage` subjects. Not a redesign: the same arithmetic, the same + order, the same thresholds. +- `Entity.ApplyHit(...)` — the shape construction moved from + `EntityInstance.cs:334-394`, with two changes forced by R7: the hit cell comes from the + rolled `Cell` rather than a UV texture coordinate, and the penetration march rotates + the firer-to-target vector into the target's frame with a planar rotation by + `-target.Direction` instead of `transform.InverseTransformDirection`. +- `FireControl.HitProbability(weapon, source, target)` — pure, no draw: + - Zero, with no draw consumed, when there is no target; the target is not in + `VisibleEntities`; range is outside `[MinRange, Range]`; a `LockWeapon` is not + locked; or `InArc` fails. + - Otherwise `p = Accuracy * pSensor * pSpread`, where + `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info))` and + `pSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1`, with + `angularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range))`. + - `SchematicCellSize` is the one new tuning constant (Q4). +- `FireControl.Fire(weapon, item, source)` — called once per burst step from + `InstantWeapon.Execute` (replacing the bare `OnFire?.Invoke()` at + `InstantWeapon.cs:228`). It computes the predicted intercept, the flight time + (`range / Velocity`, or zero when `Velocity` is at or near zero), **freezes the payload + snapshot** (R10) and appends a `PendingShot` to `Zone.PendingShots`. The snapshot holds + `Damage`, `Penetration`, `DamageSpread`, `DamageType`, `Aimed`, the resolved targeting + stats, and the intercept point. Nothing downstream re-evaluates a stat. +- `FireControl.Step(Zone, dt)` — called from `Zone.Update` after the entity loop + (`Zone.cs:150`). For each pending shot: age it; at `ArrivalTime - CommitHorizon` + compute deviation, roll, write `Outcome`, publish `Zone.ShotCommitted`; at + `ArrivalTime` apply the outcome and remove the shot. A shot whose flight is shorter + than the horizon commits at fire time, in the same call as `Fire` — that is every + velocity-0 weapon, which is three of the catalog's eighteen. +- `GameplaySettings.CommitHorizon` — R4's authored horizon, one number, in seconds. +- The deviation term: `pDeviation` folds in how far the target is from the predicted + intercept at commit time, forgiven by the targeting system's `Tracking`. This is R3's + evasion, and the commit horizon is what bounds how late it counts. +- The roll: one draw against `p`. On a hit, a second draw against `Precision` decides + where — on `Aimed`'s footprint cells on success, otherwise on one cell drawn uniformly + over the hull `Shape`. An active shield that `CanTakeHit` takes the hit instead, and + the outcome is marked `Shielded`; that rule now exists once, in `FireControl`, rather + than seven times in the effects. +- `Zone.ShotCommitted` and `Zone.ShotResolved` (`Subject`), the presentation + feed. +- `InstantWeapon.OnFire` becomes `event Action` carrying the `ShotId`, so the + Unity manager can bind its effect to the shot it belongs to. + +**Per-file changes:** + +- `InstantWeapon.cs:228`: `OnFire?.Invoke(FireControl.Fire(this, Item, Entity))`. + Charge multipliers already fold into `Damage` and `Spread` before this point. +- `Combat.cs:100-102`: + `shouldFire = FireControl.HitProbability(...) >= Settings.AgentMinHitProbability`, + replacing Cut 1's bare `InArc` (which the probability already subsumes, returning zero + out of arc). `AgentMinHitProbability` is added here, not in Cut 1. +- `TurretController.cs:81-83`: the same gate and threshold. +- `EntityInstance.cs:203-204,232-235`: the manager call takes the `ShotId` and looks the + outcome up, or subscribes to `ShotCommitted`. Effects fly to the rolled cell's world + point or to a miss offset and play the impact on arrival. **They apply nothing.** +- `Zone.cs:150`: `FireControl.Step(this, deltaTime)` after the entity loop. + +**Authority map:** + +- **Owner:** `FireControl` owns the engage gate, hit probability, the roll, the cell, the + shield branch, and when each of those happens. `Entity` owns the application of damage + to its own schematic. `Zone` owns the pending-shot collection. +- **Inputs:** weapon stats through `EquippedItem.Evaluate`; targeting-system stats; + `EntityInfoGathered`; `Target` and `TargetItem`; both entities' positions, velocities + and planar directions; mount direction and arc; `ItemManager.Random`. +- **Outputs:** `ShotOutcome` on two subjects; `Entity.DamageSchematic`; shield + `TakeHit`; `Entity.IncomingHit`. +- **Derived state:** `Behavior.Direction`, reveal, hit probability. Everything a + presentation shows about a shot is derived from the outcome. +- **Forbidden writers:** after this cut no `Physics.*` query in + `Assets/Scripts/Gameplay/Weapons` (except the unused four Cut 4 handles) may reach + durability; `HullCollider` may not raise a hit; `EntityInstance` may not damage a + schematic; no effect may call `TakeHit`; nothing outside `FireControl` may draw for a + hit; nothing outside presentation may read a commit to change state. +- **Shared paths:** player fire, AI fire, turret fire, instant, charged, guided and + locked weapons all reach damage through `FireControl.Fire` and `FireControl.Step`. + There is exactly one commit primitive. +- **Deletion line:** the six deletions above land before `FireControl.Fire` exists. + +**Verification:** + +- builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`; and Unity + batchmode with the editor closed (`docs/item-provenance-cut.md` §6) reporting no + `error CS`. +- tests, each with the mutation that must kill it: + + | Test | Pins | Mutation | + |---|---|---| + | `RollsAreSeeded` | *a fight is reproducible* — two zones, same seed, 50 shots, identical outcome sequences | `FireControl` draws from `new Random()` | + | `ProbabilityFollowsInputs` | *the roll reads what the ruling says it reads* — zero below detection and outside range; rising with info to `Resolution`; falling with `Spread`; capped at `UnaidedAccuracy` with no system | drop `pSensor`; drop the range gate; drop the cap | + | `OutOfArcConsumesNoDraw` | *the gate is a gate, not a penalty* — the RNG sequence is unchanged by an out-of-arc trigger | roll first and multiply by zero | + | `ShotResolvesOnArrival` | R3 — no durability change before `range / Velocity`; a target removed mid-flight takes none | apply at fire time | + | `OutcomeCommitsBeforeImpact` | R4 — `ShotCommitted` fires exactly `CommitHorizon` before `ShotResolved`, once per shot | publish at arrival; publish twice | + | `EvasionCountsUntilCommitAndNotAfter` | R4's second half — a target that jinks before the horizon changes the outcome distribution; the identical jink after the horizon does not | measure deviation at arrival; measure it at fire | + | `ShortFlightCommitsAtFire` | *the horizon degrades gracefully* — a velocity-0 weapon commits and resolves in one tick, still publishing both events in order | skip the commit for short flights | + | `OutcomeIsSnapshotNotReread` | R10, the seam the stats campaign named — a weapon whose `Damage` collapses (power cut, heat spike) between fire and arrival still lands the damage it rolled | re-evaluate `Damage` at arrival | + | `AimedHitLandsOnSelectedItem` | R5's payoff — `Precision` 1 and `p` 1: only the aimed item's durability falls | the roll ignores `Aimed` | + | `HardpointHitDamagesItemThenHull` | the damage rule survived the move verbatim — damage above armor plus item durability zeroes the item and the remainder hits the hull | armor not subtracted; hull skipped | + | `PenetrationMarchIsPlanar` | R7 — the march rotates by the target's planar direction and gives the same cells whatever the height difference | use a 3D rotation | + | `ShieldTakesHit` | *the absorb rule has one owner* — an active shield absorbs and the schematic is untouched | remove the shield branch | + | `DeadEntityStopsTakingShots` | *a shot cannot damage a corpse* — a target destroyed before arrival takes nothing and the shot is removed | resolve against a removed entity | + +- negative greps, each checked against legitimate names before publishing: + - `rg -n "Physics\." Assets/Scripts/Gameplay/Weapons/Projectile.cs Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs Assets/Scripts/Gameplay/Weapons/Laser.cs Assets/Scripts/Gameplay/Weapons/Lightning.cs Assets/Scripts/Gameplay/HullCollider.cs` + — empty. (A repo-wide `Physics\.` grep is **not** valid until Cut 4, and never for + `TractorBeam.cs`, which stays.) + - `rg -n "SendHit|SendSplash|DamageSchematic" Assets/Scripts --glob '!**/ServerShared/**'` + — empty. + - `rg -n "TakeHit\(" Assets/Scripts --glob '!**/ServerShared/**'` — empty. Note + `CanTakeHit` shares the substring; the trailing `(` on `TakeHit` distinguishes them, + and the `Can` prefix must be checked by eye in the first run. +- operator play smoke: shots show rolled impacts and misses; a miss reads as a + deliberate near-miss rather than a bug; damage matches the HUD; aiming at a revealed + subsystem visibly concentrates damage there; kill time is in the same order as before. + +**Risks.** `UnaidedAccuracy`, `SchematicCellSize`, `CommitHorizon` and the targeting stat +ranges are first guesses. The headless fixture is the tuning harness, and the operator +smoke is the arbiter. Second: `Combat.cs:84-98` aims `LookDirection` at a first-order +intercept, and R3's deviation is measured against `FireControl`'s own predicted +intercept; if the two predictions differ, an AI will systematically miss. They must be +one function. Hands makes `FireControl` own the prediction and `CombatState` call it. + +--- + +## Cut 4. The unused kinds join the same path + +R9 keeps every weapon type. This cut makes the ones no design currently ships obey the +same owner, so that authoring one later does not resurrect Unity hit detection. + +- **Repo/branch:** continues Cut 3. Depends on Cut 3. +- **First:** **re-measure the claim this cut's safety rests on** — that no live design + uses `ConstantWeaponData`, `HitscanEffect`, `Mine`, airburst or MIRV (§0.4, carried + from a superseded map and not reproduced by this campaign's probe). A behaviour-class + census needs `AetherDb roles-migrate` as a dry run, which the permission classifier + blocked; the operator can unblock it or Hands can add a read-only `behaviors` command. + **If any of these is live, it moves into Cut 3 instead.** + +**Deletes first:** + +- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:109-122` — airburst `OverlapSphere` and + `SendSplash` (14). +- `Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs:74-110` (~37), + `ConstantLightning.cs:54-70` (~17), `HitscanEffect.cs:31-55` (~25), + `Mine.cs:68-75,92-117` (~34), `ConstantParticleWeapon.cs:72` and its `_hull` plumbing + (~8) — every remaining `Physics.*` hit query and `SendHit`/`SendSplash` call in + `Assets/Scripts/Gameplay/Weapons`. + +**Adds:** + +- `ConstantWeapon` rolls once per `GameplaySettings.BeamResolveInterval` for + `Damage * interval`, through the same `FireControl.Fire`/`Step` pair with a flight time + of zero, raising `OnBeamShot(int shotId)`. About ten lines; no design uses it yet, and + it authors no new stat. +- Splash — airburst and mine blast — as `FireControl.Splash`, applying the existing + `DamageSchematic` over the directional half-shape the deleted + `EntityInstance.cs:318-329` computed, against every entity in radius. This is the one + genuinely new rule in the cut, and it exists because R9 keeps the flak cannon and the + mine. +- Nothing else. `Mine`, `TractorBeam` and ship collision stay Unity-physics surfaces for + *placement and contact*, which is not hit detection; only their damage application + moves. + +**Authority map:** unchanged from Cut 3 except that the forbidden-writer list now covers +every file in `Assets/Scripts/Gameplay/Weapons`, and `FireControl` gains splash. + +**Verification:** + +- builds: as Cut 3. +- tests: + - `BeamRollsPerInterval` pins *a beam is a sequence of rolls, not a continuous truth*: + over one second a beam produces `1 / BeamResolveInterval` outcomes totalling + `Damage`. Mutation: roll per tick; roll once. + - `SplashHitsEveryEntityInRadius` pins *splash is one rule, not per-effect*: two + targets in radius both take damage, one outside takes none. Mutation: damage only + the nearest. + - `SplashIsDirectional` pins that the moved half-shape rule survived: cells facing the + blast take it, cells facing away do not. Mutation: damage the whole shape. +- negative: `rg -n "Physics\." Assets/Scripts/Gameplay/Weapons` returns **only** + `Mine.cs`'s placement query, if Q7 keeps it — otherwise empty. `TractorBeam.cs:21` is + outside this directory and stays. + `rg -n "SendHit|SendSplash" Assets/Scripts` empty. + `rg -n "class HullHitEventArgs|class HullSplashEventArgs" Assets/Scripts` empty. +- operator: nothing to check in play, because nothing in the catalog spawns these. The + check is a Studio authoring pass: author one beam product and one flak product onto a + test hull and confirm they damage. + +--- + +## 5. Subtraction ledger + +Estimates, to be compared with reality in the postmortem. + +| Cut | Removed | Added | Deps / targets / formats | +|---|---|---|---| +| 1 | 19 production (Entity 2, EntityInstance 4, Behaviors 6, Combat 3, TurretController 3, Settings 1) | ~60 production (`FireControl.cs` geometry ~45, `HardpointData.FiringArc` 1, `GameplaySettings.FiringArc` 1, call sites ~13), ~90 test | +1 catalog field, authored on one hull. No new project, package or dependency. | +| 2 | 0 | ~140 production (`TargetingSystem.cs` ~55, reveal ~25, `Entity.TargetItem` ~25, AI selection ~10, player input ~15, `LoadoutGenerator` ~12), ~160 test | +1 behaviour union index, +2 designs and their products, +1 setting. | +| 3 | ~285 production (EntityInstance 120, HullCollider 45, Projectile 47, GuidedProjectile 38, Laser 36, Lightning 39) | ~330 production (`Entity.DamageSchematic`/`ApplyHit` ~130 moved, `FireControl` roll and lifecycle ~140, `Zone` pending shots ~35, call sites ~25), ~280 test | +2 settings. **Seven duplicated copies of the shield-absorb rule collapse to one.** | +| 4 | ~135 production | ~70 production, ~70 test | +1 setting. | +| **Total** | **~439** | **~600 production, ~600 test** | | + +Net production about +160. The account: it buys the invariant that the simulation +resolves combat without Unity — `docs/three-gates-scope.md:101-110` calls today's state +a velocity compromise — and it collapses seven copies of the absorb rule and one copy of +the damage rule into single owners. It does **not** buy that with a registry, an adapter, +a mode flag or a compatibility layer: the old path is deleted in the cut that replaces +it, and no cut leaves two writers of damage. + +The line count is the wrong thing to stare at here. The structural delta is: one new +owner (`FireControl`), one new subsystem (`TargetingSystem`), one field on catalog data, +four settings, and **minus one whole category of authority** — Unity physics deciding +gameplay. + +--- + +## 6. Build budget + +- **Packages and targets.** `Aetheria.Shared/Aetheria.Shared.csproj` (netstandard2.1), + `tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj` (net10.0, xunit), + `tools/AetherDb/AetherDb.csproj`. Three projects, no new ones. Debug only; no release + profile, no feature flags, no code generation, no new target platform. +- **Build host and target are the same machine**: a Windows workstation producing + managed assemblies for the same runtime. Nothing here is cross-platform, so no claim + about a Linux artifact is made or needed. +- **Where.** `F:\Projects\Aetheria-stats` (the free worktree; it must first be advanced + from `c4f6029e` to HEAD). Never the main tree — the operator plays there. Never + `F:\Projects\Aetheria-tiers`. CultLib via + `-p:CultLibRoot=\cultlib-45c2f40`, the shared pinned worktree, which is + never removed. `--artifacts-path` into scratch so no `bin`/`obj` lands in a working + tree. +- **Footprint.** Three managed projects plus their test host; on the order of a few + hundred MB of artifacts, comparable to the stats campaign. No expected growth: this + campaign adds no project, package reference or native component. +- **Unity.** Batchmode compile check with the editor closed, per + `docs/item-provenance-cut.md` §6, once per cut that touches `Assets/Scripts/Gameplay` + (Cuts 1, 3, 4). The operator's play smoke is the only Unity *run*. Imagination did not + run Unity and did not build. +- **Mutation harnesses.** One per cut, in the established shape + (`tests/mutation_tests_fire_control_cut1..4.py`): patch a named anchor, run + `dotnet test tests/Aetheria.Shared.Tests --cultlib-root `, assert the named test + goes red, with a no-op control. Note the scar from `8dddda4d`: later cuts move earlier + anchors, so Cut 4 re-anchors Cuts 1-3's harnesses before it lands. + +--- + +## 7. Seams this campaign names and stops at + +- **Presentation of shots.** `Zone.ShotCommitted` and `Zone.ShotResolved` are the whole + contract. What a projectile, panel, whip or near-miss *does* with a commit belongs to + `docs/shield-presentation-contract.md` and the panel and Lariat maps. This campaign + guarantees only that the commit exists, is authoritative, arrives `CommitHorizon` + before impact, and carries what the contract says it carries: what happens, where, + when it arrives, and which capability answers it — not who fired, not damage numbers. + The contract's open question "where the commit window lives" is answered here: + `FireControl`, with the horizon in `GameplaySettings`. +- **Death, removal and loot.** `Entity.Death` is already composed in `ServerShared` + (`Entity.cs:363-366`), but `Zone.Entities.Remove` and the loot roll live in Unity + (`EntityInstance.cs:411-437`) and use `UnityEngine.Random`. Fire control makes a + headless kill *happen*; it does not make the corpse go away. Q3. +- **Pickup.** `ShieldManager.cs:40-51` stores loot on trigger contact. Untouched. +- **Ship collision, `TractorBeam`, mine placement.** Deferred Unity-physics surfaces + (`docs/three-gates-scope.md:98-99`). Untouched. +- **The navigation planner.** R3 makes manoeuvring evasion, which changes what a good + flight path is. The planner is a separate item in the shipping scope and this map does + not anticipate it. +- **Stance and IFF.** Ruled and partly built (`Weapon.StanceAllowsFire`, + `Weapon.cs:101`). Fire control reads the gate and does not redesign it. +- **Faction territory.** The campaign after this. + +--- + +## 8. Operator questions + +Most blocking first. + +**Q1. What makes a turret 360?** R6 says turrets are 360 degrees, but a turret hull's +`Direction` never rotates — `TurretController` writes only `LookDirection` — so under a +120-degree default a turret would only engage within 60 degrees of its spawn facing. +**A:** author `FiringArc: 360` on the `Turret` hull's hardpoints; the override R6 already +grants does the whole job and no code knows what a turret is. **B:** add +`HardpointType.Turret` and give it an implicit 360. **C:** give the arc to the hull +rather than the hardpoint. **Recommended: A**, because it buys the invariant with +authored data instead of a new type, and because a future partially-traversing turret +(a 270-degree sponson) is then already expressible. B makes the arc a property of a +category and loses that. + +**Q2. Is player fire arc-gated?** Today it is not gated at all; the player fires whatever +is equipped, wherever they point. Cut 1 makes the arc a shared rule. **A:** yes, gate the +player exactly as the AI — a side-mounted gun simply does not fire forward, and the HUD +shows which groups bear. **B:** gate the AI only, leaving the player's rear gun able to +shoot forward. **Recommended: A.** Under R1 aiming is gone, so what the player does with +a weapon group *is* the decision, and arcs are what make that decision interesting. B +also splits the shared path the doctrine cares about: manual and programmatic fire would +be two truths. It is a real difficulty change and should be felt in the smoke before the +campaign closes. + +**Q3. Does this campaign move death removal and loot, or leave them in Unity?** A +headless kill currently leaves the corpse in `Zone.Entities`, because `Entities.Remove` +is at `EntityInstance.cs:437`. **A:** move removal and the loot roll into `Zone` in +Cut 3, using `ItemManager.Random` — about 50 lines, and it makes a headless kill a +complete event. **B:** move only the removal; leave loot to the run-structure work. +**C:** leave both; fire control's tests assert on durability, not on removal. +**Recommended: B.** Removal is the half without which the simulation is lying about its +own contents, and a seeded loot roll pulls in drop velocity, bay capacity and pickup — +the run-structure campaign's material. A is more complete but widens this campaign; C +leaves `ServerShared` unable to say who is alive. + +**Q4. Three balance numbers need first values:** `CommitHorizon` (R4's "short fixed +time"), `SchematicCellSize` (metres per schematic cell, which sets how much target size +matters), and `UnaidedAccuracy`. **A:** Hands picks defaults (0.3s, measured off the +LonginusX prefab, 0.35) and the operator tunes them in the smoke. **B:** the operator +states them now. **Recommended: A** for the first two and **B for `UnaidedAccuracy`**, +because it is not a tuning number — it decides whether a targeting system is a +requirement or an upgrade, which is a design ruling. If it is low, Cut 2's required-item +rule is load-bearing; if it is high, the subsystem is a nice-to-have and Cut 2 shrinks. + +**Q5. What seeds the rolls?** `ItemManager.Random` is clock-seeded +(`ItemManager.cs:19`), so `RollsAreSeeded` cannot pass without a seed argument. +**A:** add the seed parameter here, as a two-line change. **B:** wait for the `Run` +lifecycle work that owns it (`docs/headless-playground-cut.md` Cut 1, unlanded). +**Recommended: A.** A reproducible fight is how this campaign is tuned and how a +regression is ever found again; waiting on an unstarted cut for two lines is the +compensator pattern in miniature. + +**Q6. When is the snapshot taken — fire, or commit?** `docs/stats-and-power-cut.md:1020` +says "at commit time". **A:** one snapshot, at fire: the shot carries what left the +barrel, and only deviation is measured up to the commit. **B:** two snapshots — payload +at fire, targeting stats at commit — so a targeting system knocked out mid-flight +degrades the shot already in the air. **Recommended: A.** One rule, one freeze point, and +over a horizon measured in tenths of a second B's extra fidelity is invisible while its +extra state is permanent. B is the more simulationist answer and is worth having if the +operator wants killing the gunnery computer to spoil shots already fired. + +**Q7. Mines.** Cut 4 moves mine *damage* to `FireControl.Splash` but leaves +`Mine.cs:68`'s `OverlapSphere` for proximity triggering, which is contact detection +rather than hit detection. **A:** leave it, alongside `TractorBeam` and ship collision, +as a named deferred physics surface. **B:** move proximity into `Zone` too, so the last +`Physics.*` in the weapons directory goes. **Recommended: A.** No design ships a mine, +and B buys a clean grep rather than an invariant. Revisit when a mine is authored. + +--- + +## Cut 5: the rules that landed in one path and not its twin + +Date: 2026-09-19. Branch `codex/fire-control-5` off `ca57819d`. + +Soul's pass over the whole campaign (2026-09-19) confirmed eleven findings. Most +of them are the same shape: a rule this map declared, implemented on one path, +and left off the path beside it. Splash breaks a shield the discrete roll does +not. `InstantWeapon` gates on arc and `ConstantWeapon` does not. Unity removes a +corpse from the zone and the simulation does not. This cut closes the twins. +Catalog authoring (Soul's findings 1 and 2) is Cut 6; the shared-RNG stream +(finding 6) and airburst (finding 5) are Cut 6 as well; the missing mutation +harnesses (finding 8) are Cut 7. + +### 5.1 Tracking is a floor, not a cliff (Soul finding 3) + +`FireControl.cs:102-106` falls back to `Tracking = 0` with no targeting system, +and `:273-275` turns that zero into `deviation < .01f ? 1f : 0f`. A probe fired +334 shots at a target drifting at 5 u/s with `Accuracy` forced to 1 and landed +none. Q4 asked for unaided fire to be really, really bad; this is a wall. + +- `Settings.cs`, beside `UnaidedAccuracy`: add `public float UnaidedTracking = 10f;` + with the same authoring note. Ten world units of deviation forgiveness is the + first guess and the operator's knob: a target that has strayed five units from + its fire-time projection halves an already-5% chance. +- `FireControl.Tracking` falls back to `settings.UnaidedTracking`, exactly as + `Accuracy` falls back to `UnaidedAccuracy`. One shape for both. +- `Commit` then deletes the zero branch outright: `p *= saturate(1f - deviation / shot.Tracking)`. + Tracking is now always positive, so the branch protects nothing. + +**Authority:** `Settings` owns the unaided figures; `FireControl.Tracking` is the +only reader; `Commit` no longer carries a special case for a value that cannot occur. + +### 5.2 A shot the shield cannot absorb breaks it (Soul finding 4) + +`Commit:299-302` sets `Shielded` only when `CanTakeHit` passes, and `Apply:311-326` +routes the rest to the hull -- neither calls `Break()`. `Splash:365` does. The +deleted Unity path did (`git show b7743789^:Assets/Scripts/Gameplay/Weapons/Projectile.cs`). +So the F4/F5 shield-break mechanic is unreachable for every weapon in the catalog: +an unabsorbable 5000-damage hit leaves `Broken == false` and the shield absorbs the +very next shot, refilling at `RefillDuration` instead of the punitive `RestoreDuration`. + +- `ShotOutcome` gains `public bool ShieldBroken;`. +- `Commit` decides it where it already asks `CanTakeHit`: shield present, active, + and `CanTakeHit` false means `ShieldBroken = true`. The decision is frozen with + the rest of the outcome (R4) -- a shield that recharges during the flight does + not retroactively survive. +- `Apply` performs it: `if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();` + before the hull damage. `CanTakeHit` is still called exactly once per shot. +- Presentation gains the signal for free; nothing is required to read it yet. + +### 5.3 Continuous weapons obey their arc (Soul finding 9) + +`ConstantWeapon.cs:91,99` gate on `StanceAllowsFire` alone. A side-mounted beam +fires forward. Q2 says manual and programmatic must not be two truths; instant and +continuous currently are. + +- Both gates become `StanceAllowsFire && ArcAllowsFire`, matching `InstantWeapon.cs:105`. + +### 5.4 One bearing test, including at zero range (Soul finding 11) + +`Weapon.cs:117` allows a zero-length bearing; `FireControl.InArc:47` normalizes the +zero vector into NaN and `HitProbability` returns 0. The player's trigger says fire +and the probability says impossible, so AI and turrets refuse to fire at a +co-located target. + +- `InArc` takes the rule: a planar bearing shorter than `1e-6` returns true -- + point-blank cannot fail a bearing test. +- `Weapon.ArcAllowsFire` drops its own `lengthsq` special case and simply calls + `InArc`. One owner, one answer. + +### 5.5 A destroyed subsystem stops being the aim point (Soul finding 10) + +A destroyed item is never removed from `Equipment` (`Entity.cs:475` only observes +it), so `IsRevealed` still ranks it and `ResolvedTargetItem` still returns it -- +`Precision` keeps concentrating hits onto a dead item's footprint. + +- `FireControl.IsRevealed` returns false for an item whose + `EquippableItem.Durability < .01f`, and the ranking excludes destroyed items so + the reveal tiers of the survivors close up rather than leaving a gap. +- `Entity.ResolvedTargetItem` therefore drops a destroyed aim point on its next + read. Nothing caches it, so no clearing pass is needed. + +### 5.6 Death removes the ship, in the simulation (Soul finding 7) + +Operator ruling Q3 says death removes the ship. `Zone.Entities.Remove` for death +exists only at `EntityInstance.cs:324`, inside Unity's loot-drop subscription. So +headless, `Step`'s `targetGone` guard never fires, shots land on corpses, `Splash` +keeps hitting them, the AI keeps engaging them, and the tuning harness this map +calls its arbiter cannot end a fight. `DeadEntityStopsTakingShots` passes only +because it calls `Entities.Remove` by hand. + +- `Zone` subscribes each entity's `Death` observable when the entity joins, and + removes it from `Entities` on death. The simulation owns the corpse. +- `EntityInstance.cs:324` deletes its `Entities.Remove` call and keeps the loot + drop and destroy effect, which are its own business. Forbidden writer: no + presentation removes an entity from the zone. + +### 5.7 Delete the two carried-and-unread fields (Soul finding 12) + +`PendingShot.PredictedIntercept` and `PendingShot.FlightTime` are written at +`FireControl.cs:204-205` and read nowhere. The intercept in particular is +carry-weight from Cut 3's named risk: `Combat.cs:109` and `Fire` do share +`FireControl.PredictedIntercept`, but the roll judges deviation against a +straight-line projection from fire position, so the stored copy decides nothing. + +- Both fields are deleted from the struct. The local `flightTime` stays; it still + computes `ArrivalTime` and `CommitTime`. + +### Verification + +Each of 5.1-5.7 gets a test that fails under its own mutation, and the mutation +harness is committed as `tests/mutation_tests_fire_control_cut5.sh` with a no-op +control. In addition this cut pins four of the five mutations that survived 197 +green tests in Soul's pass, because they are rules 5.1-5.6 touch: + +- `FireControl.cs:365` -- delete `shield.Break()` from `Splash`. Must die. +- `Weapon.cs:117` -- `ArcAllowsFire => true`. Must die. +- `InstantWeapon.cs:105` -- delete the `if (!ArcAllowsFire) return;` player arc gate + (Q2, the ruling Cut 1 deferred to Cut 3 and nothing verifies). Must die. +- `FireControl.cs:270` -- `elapsed = shot.FlightTime` in place of `now - shot.FireTime`. + This is the map's own declared mutation for `EvasionCountsUntilCommitAndNotAfter`, + and it survives because that test's fixture gives the target zero velocity, so + `FireTargetVelocity * elapsed` is identically zero and the mutation is a no-op + inside it. Give the fixture a moving target. (After 5.7 deletes `FlightTime` the + mutation's spelling becomes `elapsed = shot.ArrivalTime - shot.FireTime`; the rule + it attacks is the same.) + +The fifth survivor -- freezing `source.TargetItem.Value` instead of +`ResolvedTargetItem` at `FireControl.cs:193`, bypassing the reveal re-check -- is +Cut 7's, alongside the missing Cut 1-3 harnesses. + +Negative checks: no `Entities.Remove` for death outside `Zone`; no `Tracking` read +that can return zero; no `PredictedIntercept` or `FlightTime` field on `PendingShot`. + +--- + +## Cut 6: the dice stop being shared, and the flak cannon works again + +Date: 2026-09-19. Branch `codex/fire-control-6` off Cut 5. + +Cut 6a (catalog authoring, worktree `Aetheria-catalog`, branch +`codex/fire-control-6-catalog`) runs in parallel and is specified by Soul's +findings 1 and 2 plus the Cut 2 authoring spec above. This section is Cut 6b, the +two remaining code findings. + +### 6.1 A shot's dice belong to the shot (Soul finding 6) + +`Commit:266,304` draws from `ItemManager.Random`, a single stream shared with +`ActionGameManager.cs:552,558,567,686,787` (wormhole exit velocity, loadout +generation, travel) and `Combat.cs:144` (`SampleDps`, drawn per AI per +evaluation). Q5's reproducible fight therefore holds only inside a hermetic +fixture: in play, adding an NPC or opening a shop changes every subsequent die. +Soul's determinism probes passed for exactly that reason, which makes them a +demonstration of the fixture rather than of the rule. + +The fix is a deletion, not a second stream. A stream makes the roll depend on +*how many* draws came before it; what this campaign actually promised is that a +shot's outcome is a function of the shot. So make it one. + +- `Zone` exposes `public uint CombatSeed { get; }`, set in the constructor from + the same expression that already seeds `_random`: + `galaxyZone?.Name.StableHash() ?? 1337u`. One zone, one stable identity — a + galaxy seed reproduces it, and nothing else in the zone can perturb it. +- `Commit` stops touching `ItemManager.Random` entirely. It builds its own local + `CultMath.Random` from the zone seed and the shot's own id: + `var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);` + The `| 1u` guards the degenerate zero seed. Every draw the commit makes — the + hit roll, the precision roll, the cell pick — comes from that one local + generator, and it dies with the call. +- The write-back at `:304` (`shot.Source.ItemManager.Random = random;`) is + deleted with it. + +**Authority:** the roll's randomness is a pure function of `(zone identity, shot +id)`. **Forbidden writers:** nothing outside `Commit` may seed or advance a +combat draw, and `Commit` may not read a shared stream. **Consequence worth +stating:** two shots that happen to share a `ShotId` across two zones roll +differently, because the zone seed differs; two runs of the same fight from the +same galaxy seed roll identically no matter what the UI did in between, which is +the promise Q5 actually made. + +### 6.2 Airburst resolves in the simulation (Soul finding 5) + +Cut 4 deleted `Projectile.cs`'s `OverlapSphere`/`SendSplash` and added +`FireControl.Splash` "because R9 keeps the flak cannon and the mine". Only the +mine was wired (`Mine.cs:99`). `Projectile.cs:16-17` still carries +`AirburstDistance`/`AirburstRange`, still written by `ProjectileManager.cs:32`, +read by nothing — so a flak round flies and does nothing. The stale comment at +`Projectile.cs:7-8` calls airburst "Cut 4's — untouched here". + +The wiring does **not** go back into `Projectile`. R8 says presentation does not +decide damage, and a projectile MonoBehaviour calling `Splash` would be exactly +the authority Cut 3 spent itself deleting. Airburst is a property of the weapon, +so the simulation resolves it: + +- `PendingShot` regains a burst point — `public float3 BurstPosition;` — frozen + at fire like every other payload field, from `PredictedIntercept` (which Cut 5 + deletes as unread; this is its live consumer, and it comes back named for what + it is rather than for what computed it). +- `PendingShot` gains `public float BurstRadius;`, frozen from the weapon's + authored airburst range, and zero for a weapon without the `Airburst` flag + (`Enums.cs:101`). +- `FireControl.Fire` sets both when the weapon's `WeaponData` carries `Airburst`. +- `FireControl.Step`, at arrival, resolves an airburst shot by calling + `Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType)` + **instead of** `Apply`. An airburst round does not also roll a discrete hit: + it is an area effect, which is the whole point of the flag, and applying both + would be the double-application Soul was told to hunt for. +- `Projectile.cs` deletes `AirburstDistance` and `AirburstRange` and the stale + comment; `ProjectileManager.cs:32` deletes the line that wrote one. The + projectile's remaining job is to fly and disappear, which is all presentation + should have been doing. + +**Open tuning question, recorded not asked:** the authored airburst radius has no +sibling in the catalog to argue from, because no product has ever used it. Cut 6a +or a later authoring pass sets it; until then the flag is wired and unexercised, +and the report must say so rather than implying a working flak cannon. + +### Verification + +Committed harness `tests/mutation_tests_fire_control_cut6.sh`, no-op control +required, same byte-exact I/O rules as Cut 5. + +- `SameFightRollsSameThroughUnrelatedDraws` pins 6.1: run a fixed fight twice from + one zone seed, drawing an arbitrary number of values from `ItemManager.Random` + between the two runs, and the outcomes must be identical. Mutation: restore the + `ItemManager.Random` draw. Must die — and note that this is the test Soul's own + probe could not be, because a hermetic fixture cannot see the defect. +- `ShotIdDecidesTheDie` pins that the roll is a function of the shot: two shots + with the same frozen payload and different ids may differ; the same id in the + same zone always gives the same result. Mutation: drop `ShotId` from the seed. +- `AirburstSplashesAndDoesNotAlsoRoll` pins 6.2: an airburst shot damages + everything in its radius, and its target takes area damage rather than a + discrete cell hit. Mutation: call `Apply` as well as `Splash`. Must die. +- `NonAirburstNeverSplashes` pins the other side. Mutation: splash unconditionally. +- negative: `ItemManager.Random` appears nowhere in `FireControl.cs`; + `Airburst` appears nowhere in `Assets/Scripts/Gameplay`. + +--- + +## Cut 6c: the formula gets fixed, and the single point of failure + +Date: 2026-09-19. Branch off Cut 6a (`e5f1318a`) once Cut 5 has merged — this +touches `FireControl.cs`, which Cut 5 owns until then. + +Cut 6a landed the catalog data that Cut 2 never shipped: 53 designs, 43 products, +`loadout` green on ten seeds, turret arcs migrated. Reviewing it turned up two +defects it introduced and one it inherited. + +### 6c.1 Resolution becomes reciprocal, so the name stops lying + +`HitProbability` computes `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution(source), info))`. +With the live `TargetDetectionInfoThreshold` of `.1`, a **higher** Resolution +means **more** gathered info is needed before sensor state stops limiting hits. +Resolution is a cost. The field's own comment says so accurately -- "the info +level at which sensor state stops limiting hits" -- and the word "Resolution" +says the opposite, because in every other context a higher resolution is a +better instrument. + +Cut 6a authored the 1-cell `Targeting Computer` at `.3` and the 2-cell +`Fire Control Array` at `.75`. Against a target scanned to info `.3`, that gives +the cheap design `pSensor = 1.0` and the 150000c design `pSensor = 0.31`. The +premium item is strictly worse until the player has scanned hard, which is not a +tradeoff anyone authored -- it is the formula misleading an author who had read +it and said so in the same paragraph. + +Operator ruling, 2026-09-19: "When the formula is unintuitive, we don't make the +handle more opaque for correctness, we fix the formula. Plenty of core gameplay +formulas are reciprocal just for this reason." An earlier draft of this cut +proposed renaming the field to `SensorDemand`; that is superseded. The name +stays `Resolution`, higher stays better, and the formula takes the reciprocal: + +- `HitProbability` derives the ceiling rather than reading it directly: + `var demandCeiling = detection + (1f - detection) / max(Resolution(source), 1e-3f);` + then `pSensor = saturate(unlerp(detection, demandCeiling, info))`. +- Resolution `1` therefore means "needs complete information before sensor data + stops penalising this system" -- which is precisely what the unaided fallback + of `1f` should mean. That fallback stays as it is and is now the floor by + construction instead of by coincidence. The guard against a zero or negative + authored Resolution is the `max`, and no authored value should ever reach it. +- Re-author both designs on the new scale: 1-cell `2` (sensor-limited until info + reaches `.55`), 2-cell `4` (until `.325`). Both comfortably better than unaided, + the premium one better than the cheap one, and the numbers now read the way a + designer reaching for the knob expects. +- `TargetingSystemData.Resolution`'s comment gains one sentence naming the + direction and the reciprocal, so the next author does not have to derive it. + +### 6c.2 One seller is a die roll against ship generation + +Cut 6a sold both targeting designs from NiteLife Energy alone, satisfying the +Cut 2 spec's letter ("at least the manufacturers that sell the capacitor") while +missing what that clause was for. `Galaxy.cs:109` selects +`factions.OrderBy(random).Take(settings.MegaCount)` — a **random subset** of the +twelve factions — plus the quest faction and the authored neutrals. A galaxy that +does not draw NiteLife has no targeting product, `IsAvailable` +(`LoadoutGenerator.cs:154`) filters every candidate out, and +`FillInterior`'s `required: true` throws `InvalidLoadoutException` again. Cut 6a's +ten green seeds do not disprove this; they sample the draw. + +Two changes, because content coverage and structural fragility are different +problems and only one of them is fixed by authoring: + +- **The generator degrades instead of throwing.** A `required: true` interior + item with no available product leaves the entity without one rather than + failing generation outright. This is only safe *because* Cut 5.1 gave unaided + fire a floor: such a ship now fires badly instead of not at all. The generator + logs the gap so it surfaces as content debt rather than silence, and `AetherDb + loadout` reports it per seed. +- **Thematic sellers, which also buy coverage.** NiteLife Energy is the roster's + power-generation faction; targeting belongs to Finch Cybernetics (passive + sensors) and Lucent Media (active sensors), both of which the roster names for + exactly this. Each design gains products from both. That is three sellers per + design, and it puts the item in the hands of the factions whose brand explains + why they make it. + +### 6c.3 Recorded, not fixed + +`Large Drive`'s new product declares no roles because the design declares none. +That is honest, and it is also a design with no role axes in a game where roles +are how products differentiate. It belongs to the content pass, not here. + +### Verification + +- A test pins 6c.1's direction: two entities, identical but for `Resolution`, at + the same info level -- the **higher** Resolution has the higher + `HitProbability`, at every info level between detection and full. Mutation: + drop the reciprocal and read Resolution as the ceiling directly (the shipped + behaviour). Must die. This is the assertion whose absence let a premium item + ship strictly worse than a cheap one. +- A test pins 6c.2: a galaxy containing no seller of any targeting design still + generates every hull, and those entities resolve `UnaidedAccuracy`. Mutation: + restore the throw. Must die. +- `AetherDb loadout` across at least 50 seeds, plus a run with the faction set + forced to exclude all three sellers. Report the gap count, not just "no throw". +- Census unchanged at 53 designs; products rise by 4 (two more sellers per design). + +--- + +## Cut 7: the harnesses that were prose + +Date: 2026-09-19. Branch `codex/fire-control-7` off Cut 6. + +`git log --all -- tests/mutation_tests_fire_control*` returns exactly one commit, +`034e8d3e`, adding `cut4` alone. §6 of this map declares `cut1..4`. So the Cut 3 +mutation table above (seeding, the commit horizon, the frozen snapshot, the +shield branch, the penetration march, the roll itself) is prose: it names the +mutations that should die and nothing has ever run them. + +That is not a paperwork gap. Soul wrote five mutations against rules this map +claims to pin and **all five survived 197 green tests**, including +`InstantWeapon.cs:105`, which is operator ruling Q2 — the one thing Cut 1 +explicitly deferred to Cut 3. Cut 5 pins four of them because it touches their +rules. This cut pins the fifth and builds the harnesses that should have existed +since Cut 1, so that every verdict this campaign has recorded becomes reproducible +rather than remembered. + +### 7.1 The fifth survivor: the reveal re-check + +`FireControl.cs:193` freezes `Aimed = source.ResolvedTargetItem`. Mutating it to +`source.TargetItem.Value` bypasses the reveal re-check — you could aim at a +subsystem you have lost resolution on — and 197 tests stayed green. + +- A test pins that an aim point whose reveal has decayed below its tier is **not** + frozen into the shot: the shot still fires, at a random cell, with no `Aimed`. + Mutation: read the raw `TargetItem.Value`. Must die. +- Note the interaction with Cut 5.5: a *destroyed* aimed item must fall out by the + same path, through `IsRevealed`, not through a second check. If pinning this + needs two tests, the rule has two owners and that is itself the finding. + +### 7.2 Harnesses for Cuts 1, 2 and 3 + +Three committed scripts — `tests/mutation_tests_fire_control_cut1.sh`, `cut2.sh`, +`cut3.sh` — each with a no-op control, byte-exact I/O, anchors matching exactly +once, and a reverse-write restore. Each runs the mutation table its cut already +declares in this document; do not invent a new table, and do not quietly +substitute an easier mutation for one that will not die. + +**Every survivor is a finding to report, never a test to weaken.** The expected +cause, seen twice in this campaign already, is a fixture too weak to distinguish +the mutant: `EvasionCountsUntilCommitAndNotAfter` survived its own declared +mutation because its target had zero velocity, so the sabotage was arithmetically +a no-op. When a mutation survives, first ask whether the fixture differs from the +rule's actual precondition, fix the fixture, and say so in the report. + +Where a declared mutation genuinely cannot be reached by the current suite, write +it up as **not yet reached**, never as unreachable, and name what reaching it +would take. Both times that call was made elsewhere in this project it was wrong, +and a fifteen-line probe killed the mutant. + +### 7.3 What the harnesses are allowed to assume + +Cut 6a authors the first targeting-system designs into the catalog. A harness that +depends on catalog content is a harness that breaks when content changes, so +these suites build their own fixtures through the same synthetic-upsert path +`TargetingSystemTests.cs:368-403` already uses. That file is also a cautionary +example: it passes by upserting a synthetic design into a throwaway cache, which +pins the generator and says nothing about whether the shipped catalog can +generate a ship. Both are worth having; only one of them was, and the map claimed +the other. + +So: **one test in this cut asserts against the live catalog** — that +`AetheriaStores.Open` over `GameData/Aetheria.cc` yields a targeting system the +player's own hull can be generated with. That is the assertion whose absence let +Cut 2 ship code without data. + +### Verification + +- All four harnesses (cut1-3 plus the existing cut4) run clean: every declared + mutation dies, every no-op control leaves the suite green. +- The report carries the full table — cut, mutation, died/survived, and for any + survivor the fixture defect behind it. +- `dotnet test` green, with the count. + +--- + +## Cut 6d: where a hit lands is a dart throw, not a coin flip + +Date: 2026-09-19. Operator direction, 2026-09-19. + +### The defect + +`FireControl.Commit` places a hit like this: + +```csharp +if (aimedCells != null && aimedCells.Length > 0 && random.NextFloat() < shot.Precision) + cell = aimedCells[random.NextInt(aimedCells.Length)]; +else + cell = coords[random.NextInt(coords.Length)]; +``` + +`Precision` is a coin flip between *exactly on the aimed item* and *uniformly +anywhere on the hull*. Aim at a stern thruster with Precision `.35` and the 65% +of hits that miss it are spread evenly over the bow, the cockpit and everything +else — no nearer the thruster than if the player had never aimed at all. There is +no spatial term anywhere in it. + +This is the same shape as 5.1's Tracking cliff: a quantity that should be a +gradient implemented as a binary, so the failure case is maximally wrong rather +than slightly wrong. + +Operator, 2026-09-19: "I would presume that aiming for an item in the stern would +still tend hit in that general area even if it misses the item. Like throwing a +dart at the grid." + +### The rule + +One Gaussian kernel over the target's hull schematic, in cell units: + +- **Aim point.** The centroid of the aimed item's cells, or the hull's + `CenterOfMass` when nothing is aimed at. One path — the uniform-random branch + is deleted, not demoted. +- **Sigma.** Derived from the frozen `shot.Precision`, higher Precision giving a + tighter group. Authored so that the unaided floor (`UnaidedPrecision`, joining + `UnaidedAccuracy` and `UnaidedTracking` in `GameplaySettings`) is a sigma wide + enough to read as spraying at the silhouette. +- **Weights.** `w(cell) = exp(-d² / 2σ²)` for each occupied hull cell, `d` the + distance from the aim point. + +### The tradeoff, and why it cannot be a second miss stage + +Operator, 2026-09-19: "It would also mean that aiming can be a tradeoff; if +you're not precise enough, you're better off aiming center mass than risking +misses on a thin limb." + +That tradeoff only exists if a stray dart can miss the ship. An earlier draft of +this section clamped a stray to the nearest occupied cell, which destroys it: +aiming at a wingtip would cost nothing and merely redistribute damage inward. + +But resolving the stray as a *second* miss stage would break R3 — one roll +decides damage — and make `HitProbability` a lie, since a shot could pass the +roll and then fizzle on placement. So the off-hull mass is priced into the single +roll instead: + +``` +pOnHull = Σ w(cell) / (2πσ²) +``` + +the share of the kernel's total mass that lands on metal. + +- `HitProbability` gains `pOnHull` as a factor. Aiming at a thin limb with a + loose group drops the number **on the HUD, before the trigger** — the tradeoff + is legible at decision time rather than discovered afterwards. +- `Commit` draws the cell as a weighted pick over the same `w(cell)`. A hit + always lands on metal, because the chance of not landing on metal was already + charged at the roll. + +One kernel, computed by one function, used for both the probability and the +placement. Cut 3's named risk was two functions answering "where will this shot +go" and eventually disagreeing; this does not reintroduce it. + +`pOnHull` depends only on the aim point, the hull shape and the frozen +`Precision`, so it folds into `PBase` at fire time and the freeze discipline +(R10, Q6) is unchanged. + +### Consequences, named rather than discovered + +- **Every hit in the game moves**, not only aimed ones: unaimed fire becomes + "aim at the centre of mass with the worst sigma", so big central sections soak + more fire than extremities. That is the intended change, and it is a balance + change. +- **A fat hull is inherently easier to hit than a needle**, for free. +- **The two targeting products' authored `Precision` values (`.05-.2` and + `.35-.55`) become meaningless as written**, because they are probabilities and + this makes Precision a grouping tightness. They are re-authored in the same + pass as Cut 6c's reciprocal `Resolution`. +- **Double-charging risk.** `HitProbability` already carries `pSpread`, which + penalises a small target against a wide weapon spread. `pOnHull` also + penalises small targets. Check whether the two charge the same thing twice + before authoring numbers; if they do, one of them owns it and the other goes. +- **AI willingness shifts.** `Combat.cs` and `TurretController.cs` gate on + `AgentMinHitProbability` against this number, so NPCs will hold fire more than + they used to the moment `pOnHull` lands. That is a tuning pass, not a bug, but + it will show up in the first smoke and should not be mistaken for broken AI. + +### Verification + +- `AimingAtTheSternHitsTheStern`: with a mid-range Precision, hits aimed at a + stern item land in the stern half far more often than the bow half. Mutation: + restore the uniform-random branch. Must die. This is the assertion the shipped + coin flip would fail. +- `ThinLimbCostsHitChance`: `HitProbability` against an aim point on a + single-cell extremity is strictly lower than against the centre of mass, at the + same Precision — and the gap widens as Precision falls. Mutation: drop + `pOnHull` from the probability. Must die. +- `PlacementAndProbabilityShareOneKernel`: the empirical on-hull rate over many + seeded draws matches the `pOnHull` the probability reported, within tolerance. + Mutation: perturb sigma in one of the two call sites only. Must die — this is + the test that would catch the two functions drifting apart. +- `EveryHitLandsOnMetal`: no committed hit ever resolves to an unoccupied cell, + across seeds and aim points including extremities. + +### 7.4 Amendment (2026-09-19): the live-catalog test has a second job + +7.3 above asks for one test that opens the real `GameData/Aetheria.cc` through +`AetheriaStores.Open`, because Cut 2 shipped a `required: true` rule with no data +to satisfy it and every green suite in the campaign missed it. + +Merging Cuts 6b and 6c gave that test a second and larger job. 6b added +`WeaponItemData.AirburstRange` as a non-nullable `float` at a new MessagePack +key. Every weapon record in the shipped catalog was serialized before that key +existed, so the reader was handed `nil` for the absent slot, a `float` cannot +take nil, and `AetheriaStores.Open` threw outright — the game could not read its +own catalog. Fixed at `c896489b` by making the field `float?`. + +Neither cut could have caught it. 6b verified with unit tests over synthetic +fixtures and never opened the shipped catalog; 6c opened the catalog constantly +but on a branch without the field. The defect existed only in the merge, and +only when something read real data. + +So the live-catalog test is not a content check that happens to touch a file. It +is **the campaign's only assertion that the shipped data is readable by the +shipped code at all**, and it is the one test whose absence let a branch that +bricks the catalog pass every gate. Write it so it fails loudly on a +deserialization throw, not only on a missing design: + +- `ShippedCatalogOpensAndGeneratesAnArmedHull`: open the real catalog read-only + through `AetheriaStores.Open`, enumerate every `EquippableItemData` so every + document type is actually deserialized (a lazy read that never touches + `WeaponItemData` would have passed on the broken merge), and generate a + loadout for `LonginusX`. Any exception is a failure. +- Mutation: none needed for the throw path — the shipped catalog either + deserializes or it does not. For the generation half, the Cut 2 mutation + applies: drop the targeting designs' products and the test must go red. + +**The standing rule this cut records**, because it will recur every time a field +is added: a new non-nullable value-type field on a persisted CultCache document +cannot read records written before it existed. Nullable, or a migration that +rewrites every affected record — and the migration cannot run while the read +still throws, so tolerant deserialization comes first either way. + +--- + +## Cut 8: a removed entity is a dead entity, and the anchors the diagnostics moved + +Date: 2026-09-22. Branch `codex/fire-control-8` off `72c0109c`. + +Operator play smoke, 2026-09-20: heat spike on entry with the ship unable to +move, a nearby ship likewise glowing hot and motionless, shots that registered no +hits, and finally a NullReferenceException out of `LockWeapon` reaching +`Entity.EntityInfoGathered` as the other ship vanished. Diagnosis (Codex, relayed +by the operator): the other ship's behaviors updated after the entity had been +cleaned out. Not reproducible afterwards, which makes it a hidden bug rather than +a fixed one -- the diagnostics commit `72c0109c` changed timing and allocation, +not the cause. + +### 8.1 Removal and deactivation are one transition + +`Entity.Deactivate` (`Entity.cs:445`) disposes every subscription, clears +`EntityInfoGathered`, `VisibleEntities`, `VisibleEnemies` and `VisibleFriendlies`, +and sets `_active = false`. `Zone.TryDock` (`Entity.cs:943`) has always paired the +two: `Zone.Entities.Remove(ship); ship.Deactivate();`. Removal from the zone means +the entity stops living there. + +Cut 5.6 added the death path and did only half of it. `Zone.cs:78`: + + Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => Entities.Remove(add.Value))); + +The entity leaves the collection and stays **active**, holding live subscriptions +and stale references, while every surviving entity's `ObserveRemove` handler +(`Entity.cs:200-213`) nulls its `Target` and drops it from `EntityInfoGathered`, +`EntityHostility` and the visibility sets. Half the world has forgotten it and it +does not know it is dead. + +- The death subscription becomes `Entities.Remove(e); e.Deactivate();`, matching + `TryDock`'s existing pairing exactly. One transition, one owner. +- **Ordering risk to verify, not assume:** `EntityInstance` also subscribes to + `Entity.Death` (its loot drop and destroy effect, `EntityInstance.cs:300-325`). + Subscription order decides whether loot drops before or after `Deactivate`. + `Deactivate` does not touch `Equipment` or `CargoBays`, so loot should survive, + but this must be confirmed in play rather than reasoned about -- if loot stops + dropping, that is this cut's fault and the deactivate moves after it. + +### 8.2 A deactivated entity does not update + +`Zone.Update` (`Zone.cs:172`) iterates a snapshot: + + foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); + +so an entity removed part-way through a frame still receives its `Update` in that +same frame, after the rest of the world has dropped its references. `Entity.Update` +(`Entity.cs:1031`) never consults `_active`. Every behaviour then runs against +torn-down state, and `LockWeapon.cs:98` reaches `Entity.EntityInfoGathered[Entity.Target.Value]` +through a raw indexer. + +- `Entity.Update` returns immediately when `!_active`. + +This is the fix, and the raw indexer is deliberately **not** the fix. Guarding +`LockWeapon` with `TryGetValue` would silence this one call site and leave every +other behaviour running on a corpse -- the compensator the doctrine warns about. +Liveness is the entity's own property and the entity is the one place to enforce +it. `IsActive` already exists at `Entity.cs:116`; nothing was asking it. + +### 8.3 The other symptoms are not explained by this, and are not closed + +The heat spike, the motionless ships and the shots that registered no hits are +**unexplained**. One coherent story fits all three -- an item's heat driving the +targeting system offline makes `FireControl.Accuracy` fall back to +`UnaidedAccuracy` (`.05`), which reads exactly like "I can fire but nothing +lands," and an overheated thruster reads as "I cannot move" -- but that is a +hypothesis, not a finding, and nothing here has reproduced it. + +What can be said from the source: `ActionGameManager.cs:1281` passes +`Time.deltaTime` into `Zone.Update` unclamped, and `Zone.cs:160` divides by it +(`orbit.Value.Velocity = (Position - PreviousPosition) / deltaTime`). Unity caps +`Time.deltaTime` at `Time.maximumDeltaTime`, so a large spike is bounded, but a +**zero or near-zero** first frame is not bounded from below and that division is +unguarded. Worth a probe before it is worth a fix. + +This cut does not chase it. It records the symptom, fixes the defect that is +proven, and leaves the rest open with the evidence that exists -- an unreproducible +heat spike is not something to patch speculatively. + +### 8.4 Re-anchor the four harnesses the diagnostics refactor stranded + +`72c0109c` routed `HitProbability` through a new `FireControl.Inspect`, replacing +five early returns with flags and one gate. That is the campaign's own doctrine +applied correctly -- the operator's comment says it outright, that keeping the +calculation here stops the debug HUD growing a second, subtly different fire +control model -- and it rewrote the exact lines four harnesses anchor into. + +Seven anchors now fail: `cut3` (2), `cut5` (1, its **no-op control**), `cut6c` +(2, including its control), `cut6d` (2). Every one reports `tree-clean: PASS`, so +nothing leaked, but a harness whose control cannot run is defending nothing. + +- Re-anchor all seven onto the current spelling in `Inspect`. The rules they + attack are unchanged; only the text moved. +- A control that cannot find its anchor must stay a hard failure. Do not make + anchors fuzzy to survive refactors -- loudly stale is the property that makes + these harnesses trustworthy, and it is what surfaced this within one run. + +### Verification + +- `DeadEntityDoesNotUpdate`: an entity killed mid-frame receives no further + `Update`, and a `LockWeapon` on a ship whose target dies the same frame does not + throw. Mutation: drop the `_active` guard. Must die. +- `DeathDeactivates`: a dead entity reports `IsActive == false` and holds no live + subscriptions. Mutation: remove the `Deactivate()` call. Must die. +- `LootStillDropsOnDeath` if it can be reached headlessly; otherwise an explicit + operator check, named as such rather than assumed. +- All seven harnesses clean with tree-clean PASS, controls included. +- 222 tests plus the new ones; catalog reads; Unity batchmode exit 0. + +--- + +## Cut 9: the die is not a die + +Date: 2026-09-22. Branch `codex/fire-control-9` off `dcb8c448`. + +Soul pass over `ca57819d..dcb8c448` (2026-09-22). Ten findings; this cut takes the +three that block play. The rest are Cut 10. + +### 9.1 The hit roll has a range of 0.125 (Soul C1). **Blocking.** + +`FireControl.cs:356`: + + var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u); + ... + var hit = p > 0f && random.NextFloat() < p; + +`CultMath.Random` is xorshift32 and `NextFloat()` returns the top 24 bits of the +**first** round (`Random.cs:22-42`). One round does not carry a low-bit seed +difference into the high bits. `ShotId` varies only in the low bits, so the top +three bits of the first draw are fixed by the zone seed alone. + +Measured, 2000 shot ids per zone: **every zone's roll spans 0.1249**. Reproduced +independently with a different hash function — the span is structural, not a +property of `StableHash`. The consequence is that a shot is a step function of +`p` whose threshold is fixed when the zone is created: below the band it never +hits, above it always does. An unaided shooter hit 0 of 18,926 shots against the +shipped catalog. Roughly one zone in eight cannot land a fully aided shot at +p = .74. + +This is the operator's "I can target and fire with no hits registering at all," +and it is stable per zone, which is why it came and went. + +It is also this map's own prescription (Cut 6b, §6.1), written by Self, shipped +through three cuts, and never once measured. + +**The rule:** the roll must be uniform on [0,1). Not merely deterministic — +determinism is what a constant already gives, and is exactly why three +determinism tests passed over it. + +- Mix the seed before the first draw, with a standard 32-bit finalizer + (`fmix32`/`splitmix32`: xor-shift, multiply, xor-shift, multiply, xor-shift). + The per-shot seed derivation is unchanged; only its diffusion into the first + output changes. Determinism, the `(zone, shot id)` purity and the freeze + discipline all survive untouched. +- **This is a caller's obligation, not a CultMath defect.** Seeding xorshift with + a structured value and drawing once is the documented way to get a correlated + first output. Record a follow-up to give `CultMath.Random` a mixed-seed entry + point so no other GameCult consumer re-derives this the hard way — that is a + CultLib cut with its own release, not this one. + +**Verification, and the test whose absence caused this:** + +- `TheDieIsUniform`: fire many shots across several zone seeds at an + intermediate `p` (say .3 and .6) and assert the observed hit fraction is near + `p`, and that the observed rolls span substantially more than 0.2. Mutation: + remove the finalizer. Must die. +- No test anywhere in this campaign measures a distribution. Every behavioural + fixture uses `accuracy: 1` or `accuracy: 0`, which is why a die stuck on one + face satisfied all of them. That gap is the finding, not the line of code. + +### 9.2 `pOnHull` collapses to zero above Precision ~4 (Soul C2). **Blocking.** + +`FireControl.cs:497-516`. `Sigma = 1/Precision`, and once sigma falls below about +half a cell every `exp(-d²/2σ²)` underflows against an aim point that is not +exactly on a cell centre — which is **every shipped hull**, since no centre of +mass is integral (LonginusX 2.5/6.697, Zenith 5.5/5.5, Turret 3.5/3.5). Measured +`pOnHull` on LonginusX: `.516` at Precision .3, `1.000` at 1.19, `.112` at 5, +`.000` at 10 and beyond. + +So a better targeting system makes you miss, and past Precision ≈ 5 the weapon +cannot hit anything. Authored content sits at .5-1.3, just under the cliff, with +a margin of about 4x — and the operator is about to tune this number. + +- Clamp sigma at a floor of about half a cell, or normalize by the discrete sum + rather than the continuous 2πσ². Either makes `pOnHull` monotonic in Precision + over the whole domain instead of only below the cliff. +- `WeightedPick` (`:535-545`) carries a comment claiming it "can never fall + through." It can, whenever `totalWeight == 0`, returning a fixed corner cell. + It is unreachable today only because `pOnHull == 0` forces `PBase == 0` first. + Fix the cause and delete the false comment. +- Cut 6d's map section claims to name its consequences; it does not name this. + Add it. + +### 9.3 The LockWeapon crash is still reachable (Soul C3). **High.** + +Cut 8 fixed a real liveness defect and did **not** fix the operator's crash. +`LockWeapon.cs:98` only runs on an *active* entity, so 8.2's `!_active` guard +cannot be what prevents it — `tests/mutation_tests_fire_control_cut8.sh:17-19` +says as much outright, and I merged it anyway. + +The live precondition is `Target.Value` set but absent from `EntityInfoGathered` +on an active entity, and docking still reaches it: `Deactivate` (`Entity.cs:445`) +disposes the `Entities.ObserveRemove` subscription that is the only thing nulling +a stale `Target`, and nulls nothing itself; `Activate` (`Entity.cs:167`) reseeds +`EntityInfoGathered` and likewise leaves `Target` alone. Dock, let the target die +or leave, undock, and the first frame throws. + +- `Deactivate` nulls `Target` (and `TargetItem`), or `Activate` reconciles it + against the reseeded `EntityInfoGathered`. One of the two owns it; say which. +- Still not a `TryGetValue` in `LockWeapon`. The invariant is that an active + entity's `Target` is always a live entity it has info on. +- Pin it with the dock/die/undock sequence, not with a unit test on the guard. + +### Verification + +- The three tests above, each dying under its own mutation, in a committed + `tests/mutation_tests_fire_control_cut9.sh` with a no-op control, byte-exact + I/O, sha256-verified restore and an explicit tree-clean verdict. +- All nine harnesses sequentially, one at a time, all clean. +- 226 tests plus the new ones; catalog reads; `dangling` 0. +- After 9.1 lands, re-measure the fight: Soul's probe, run with a corrected die, + gave 60.8% hit rate and a 4.3 s median time to kill for an aided shooter, 2.6% + unaided. Confirm the shipped build now reproduces that, because those are the + numbers the operator will tune against. + +### Recorded for Cut 10, not fixed here + +Soul C4 (a committed hit on a target that dies mid-flight publishes as a hit), +C5 (`Inspect` is numerically exact but costs 9.87 µs and 448 B per call on paths +that used to bail — the operator's own comment on the hot path looking like a +diagnostic one has an empirical answer now), C6 (`RollsAreSeeded` is obsolete — +Cut 7 retired one such test and missed this one), C7 (`FireControlCut6cTests` +authors `Precision = 0`, making six of seven checks vacuous), C8 (`Precision = +1000` fixtures make several kernel-shape mutants unkillable, and `pOnHull`'s +deletion is caught by exactly one test), C9 (`EveryHitLandsOnMetal` runs one +configuration, not the range it claims), plus the harness-audit items: the +tree-clean verdict restores before it measures, a red result cannot distinguish a +killed mutant from a compile failure, and the no-op control covers only one file +per harness. + +--- + +## Cut 11: mutation testing moves to Stryker, and the survivors it found + +Date: 2026-09-22. Operator, on the hand-written anchor harnesses: "What is the +point of those mutation anchors? Tests should cover how the code behaves, not how +it is shaped." And on Stryker: "sounds good; we don't need to run with every +mutation active, right? > to >= on float thresholds is indeed petty." + +### Why + +The Eureka principle (2026-09-15) is sound and stays: every rule needs a test that +fails when the rule breaks. The mechanism that accreted on it over the following +week -- literal-text anchors, perl substitution, per-cut bash and Python harnesses +-- coupled mutation testing to code shape. It stranded anchors on every refactor +(`72c0109c`, Cut 9, Cut 10), and at Cut 10 an anchor kept matching exactly once +while silently re-targeting onto `Inspect`, mutating code the tests no longer ran. +Soul's harness audit also found the tree-clean verdict restoring before it +measured, and a compile failure counting as a kill. + +A Stryker spike against `FireControl.cs` alone: 276 mutants, 7m18s, 208 killed, +67 survived. It killed every mutation the hand-written harnesses defended, never +edits a source file, and has nothing to re-anchor. + +### 11.1 Retire the ServerShared harnesses + +Delete the 23 hand-rolled harnesses (5,816 lines) whose targets are all under +`Assets/Scripts/ServerShared` -- every `mutation_tests_fire_control_*`, every +`mutation_tests_stats_power_*`, `mutation_tests_shield_reserve.py`, +`mutation_tests_condition_ratio_cut8.py` and `mutation_tests.py`. Stryker's scope +covers all of their targets; the behavioural tests they measured stay. + +**Kept, recorded, not yet answered:** `mutation_tests_addressables_cut.py` and the +three `mutation_tests_shield_panel_cut*.py`. They target Unity Editor probes +(`EngineAssetCheck.cs`, `ShieldPanelCut*Verify.cs`) that Unity compiles outside +`Aetheria.Shared`, so Stryker cannot reach them. They carry the same anchor +fragility; the right answer for GPU and editor code is probably that the batchmode +probes are themselves the behavioural tests, but that is a separate decision. + +### 11.2 Scope, pinned + +`tests/Aetheria.Shared.Tests/stryker-config.json` scopes mutation to +`Assets/Scripts/ServerShared/**` and excludes plugins. Per cut, run against the +cut's own diff: + + dotnet tool restore + cd tests/Aetheria.Shared.Tests + CULTLIB_ROOT= CULTMATH_ROOT= dotnet stryker --since: + +Each root must sit at its pinned revision in `Directory.Build.props` (see the Cut 12.0 +status note). + +**Float-threshold boundary flips are known-equivalent.** Stryker files `>`→`>=` +under the same Equality mutator as `==`→`!=`, and the latter catches real bugs, so +the mutator stays on. A survivor that only moves a float comparison across its +boundary is triaged as equivalent and never chased. For the same reason the raw +mutation score is not a gate; the survivor list is the signal. + +### 11.3 Degenerate fixtures + +Most of the 67 survivors trace to three constants shared across the fire-control +fixtures, each of which makes a family of mutants arithmetically identical to the +original -- the same defect class as the zero-velocity target and the durability +coincidence, found here as a pattern instead of one at a time: + +- **Shooter at `float3.zero`.** `target - source` equals `target + source`, so four + separate position mutations survive (`FireControl.cs` HitProbability, Inspect, + Fire, Apply). +- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time + mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does + not defend the rule Cut 5 gave it a moving target to defend. +- **Spread zero everywhere.** `PSpread` returns 1 on every fixture, so it is + effectively untested, and `*` versus `/` against it is indistinguishable. + +Move shooters off the origin, fire at nonzero times, and give some fixtures real +spread. Fix the fixture, never weaken the assertion. + +### 11.4 Behavioural tests the survivors point at + +- **Splash handedness.** Flipping the sign in `var right = float2(forward.y, + -forward.x)` passes every test. The operator's "damage on the wrong side of the + ship" was a legacy Unity bug; this line has read the same since `482c47bf`, so + the rewrite never shipped it, but nothing defended against it either. Self said on 2026-09-19 that Cut 6b would add a test pinning + that a port blast damages port cells; it never landed and nothing checked. Pin it. +- **The shield loses energy when it absorbs.** Deleting `Shield.TakeHit` from the + discrete path survives, because tests assert the hull was untouched and never + that the shield paid for it. +- **Splash's shield-absorb branch** has no coverage at all. +- **Zones roll differently.** `CombatSeed *` → `/` makes the seed zone-independent + and survives; `TheDieIsUniform` checks uniformity within a zone only. Tighten it + enough that the `MixSeed` weakenings that survive it (`>>`→`<<`, `^=`→`|=`) die. +- **A shot whose target left the zone resolves as a miss** (Soul C4). Today only + the uncommitted branch rewrites the outcome; a committed hit on a target that + died mid-flight is published as a hit. Fix the code and pin it. + +### Verification + +Stryker over `FireControl.cs` before and after: every non-equivalent survivor +above killed, the remainder triaged by name. 231 tests plus the new ones; Unity +batchmode clean. + +### Status (2026-09-22) + +Landed on `codex/fire-control-10`: `8ef3b34c` (Stryker tool), `b305debd` (map, +config), `724ec5e7` (11.1), `a6543af7` (11.4 and the C4 fix), `1b187db4` (11.3), +`b0fd3cd0` (second round, below). 244 tests. + +Stryker over `FireControl.cs`, 278 mutants per run: + +| Run | Killed | Survived | Score | +|---|---|---|---| +| Spike, before Cut 11 | 208 | 67 | 74.6% | +| After 11.4 | 219 | 58 | 78.6% | +| After 11.3 fixtures | 225 | 51 | 81.1% | +| After the second round | 225 | 43 | 83.3% | + +**The second round** came from reading the 51. Four were real undefended +behaviour and one was a duplicated formula: + +- `Inspect` recomputed `PBase` as its own product of the factors, so the gates and + the product had two owners, and a `*`→`/` mutant of the HUD's copy survived the + agreement test (every fixture had spread 0, so `PSpread` was 1). `PBase` is now + `HitProbability`'s own answer; the test became + `TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not + fill the silhouette. `Fire`'s redundant `target != null ?` guard around + `HitProbability` went with it. +- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed + the expected damage from its own roll count, a tautology, now deleted. + `ADamageOverrideIsWhatTheShotCarries`. +- `Tracking` could ignore the fitted targeting system: no fixture's target ever + moved off its predicted line. `AFittedTargetingSystemForgivesAJink`. +- A shot orphaned before commit could skip `ShotCommitted`, or publish its miss as + shielded. `ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss`. +- `ArrivalIn` (the HUD countdown) was unchecked; asserted in the C4 test. + +**The 43 that remain, triaged:** + +- *Boundary flips, equivalent (23):* L52, 54, 56, 75, 135 (two), 157 (two), 193 + (two), 223, 273, 410, 418 (two), 465 (two), 503, 518, 524, 586, 605, 608. +- *`MixSeed` shift and xor weakenings, measured equivalent (4):* L382, 384, 386 + (two). 11.4 asked for them to die; measured, the die stays uniform and + zone-distinct under each, so there is no behaviour to pin. They change how the + mixer is built, not whether the die is fair. +- *Unreachable through the live callers (8):* L240 and L241 + (`DeviationProbability`'s null-target branch; the engine guards it, only the HUD + can pass null), L283 (three; airburst has no authored radius anywhere, recorded + since Cut 6b), L320 (empty-list early return, an optimisation), L410 `||` (a + nonzero `PBase` implies a target), L158 and L610 (no coverage: an unlocked lock + weapon reaching this line, and the last-cell fallback of the weighted pick). +- *Recorded gaps, not yet reached (8):* L135 ternary (two; no test that the AI in + `Combat.cs` leads a moving target), L435 (three; the aimed flag on an outcome), + L464 and L465 negate (the `Apply` hit direction through the `FireControl` + path), L585 and L587 (the aimed item's centroid as aim point). These are "not + yet reached," not "unreachable." + +## Cut 12: shots arrive edge-on + +Date: 2026-09-22. Imagination pass, revised the same day after the second round of rulings +below. Anchors are against `origin/codex/item-provenance` `cd846916` (Cuts 10 and 11 merged). +Claims marked **(probe)** were measured in a scratch copy of the test project +(`scratchpad/cut12probe`, the Cut 11 fixture plus probe methods) or by a lane-model script +(`scratchpad/exposure.py`), in both cases against a copy of `GameData/Aetheria.cc`. Claims +marked **(read)** come from source at the cited anchor. + +### The defect + +The impact cell is chosen as if every shot drops in from overhead. +`FireControl.HullKernel` (`FireControl.cs:558-578`) throws a 2D Gaussian at the top-down +schematic, centred on the aim point. `POnHull` (`:233-234`), `WeightedPick` (`:601-611`) and +`ResolveAimPoint` (`:583-589`) all read that kernel, and none of them takes the shooter's +bearing. **(probe)** `Probe3_BearingBlind`: pOnHull is `0.8748` at target facings (0,1), +(1,0) and (.6,.8) alike. + +Bearing is only used after the cell has been chosen: `Entity.ApplyHit` (`Entity.cs:381-409`) +walks penetration from that cell, and `Splash` (`FireControl.cs:515-527`) picks the half of +the hull that faces the blast. So armour facing does nothing. A wedge whose bow carries all +the plate gets hit in its soft stern from dead ahead. The game is planar (R7), so a shot +travels in the plane and should arrive edge-on. + +The damage rule makes it worse. `Entity.DamageSchematic` (`Entity.cs:336-374`) splits the +damage evenly over every cell of the hit shape. **(probe)** `Probe1`: 30 damage with +penetration 3 into column 1 of the 5x3 fixture, with 10 armour on the front cell only, puts +10 on each of the three path cells. The plate absorbs its 10, the item behind it still takes +10, and the stern cell passes 10 to the hull. The item receives exactly what it would have +with no plate in front of it. + +### Rulings (operator, 2026-09-22) + +First round: + +- **Model adopted: throw the dart at the silhouette the shooter sees.** Scatter is 1D and + perpendicular to the bearing. Project the hull's occupied cells onto the lateral axis and + throw a 1D Gaussian at that projection, with the same sigma rule, centred on the aim + point's projection. pOnHull is the Gaussian's mass on that shadow. Depth comes from + geometry: walk the ray in from outside along the bearing, and the first occupied cell is + the impact cell. Penetration continues along the same ray. +- Operator: "No, you're right, this is fine. Armor absorbs first. Shields remain + omnidirectional. Map it." + - **Armor absorbs first.** Damage runs down the ray in order. At each cell the armour + absorbs first, then the item, and only the remainder travels on. The even split over + the hit shape goes, for the penetration path and the DamageSpread expansion alike. + - **Shields remain omnidirectional.** The shield stays one whole-ship reserve with no + facings. +- **Rejected:** any fraction of shots arriving top-down to preserve cockpit sniping. The + operator accepted that cockpit shots become positional: the cockpit must be exposed from + your bearing, or you must flank, or penetration must reach it with what the armour did not + absorb. Cockpit destruction is still instant death (`Entity.cs:494`). + +Second round: + +- **Q12-1 = B: the bearing that places a hit is taken at Commit.** Operator: "Of course we + must use the target's facing at arrival, tanking a hit where you have armor is basic + maneuvering." After Self explained the commit horizon: "at commit." The target's facing + therefore stays live until Commit, as deviation already does. See **Bearing timing**. +- **Q12-2 = A: spread is width.** It becomes 2n+1 parallel lanes, and each lane is absorbed + from its own facing cell. Operator: "Spread is width makes sense for shotguns, but what + about explosive penetrators? Burrowing in before splashing radially would be a satisfying + way to damage vulnerable internals." That remark became sub-cut 12.4. + - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at + angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the + shared armour and mirror-image shots did different damage in ~30% of angled spread hits. + Operator: "I would prefer if the overlapping damage cells were diffused sideways to + thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): + **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` + width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly + that wide and half-open, so no two lanes can strike the same cell; the footprint widens at + angles instead of doubling up, and axis-aligned shots are unchanged. +- **Q12-3 = A (direct hits):** a lane's remainder goes into the hull wherever the lane + ends. +- **Q12-4 = A:** delete `ShotOutcome.Aimed`. +- **Penetration retune is a follow-up.** Operator: "Retuning penetration is a follow up." + See F12-2. Until it lands, interior cells are reached only by blasts. +- **The detonation primitive (12.4): a blast is an area, not a bundle of rays.** The first + draft of this section cast rays; the operator cut that. Operator, 2026-09-22: "sorry, why + are we doing blast rays instead of areas? I'd say distribute the damage over the affected + area, but that only works for areas". So a blast at a schematic point P with radius r + covers the cells within r of P, and **damage is distributed over the disc's area**: a cell + takes the share of the disc that lies inside it. The share of the disc that falls outside + the hull's occupied cells is lost. Each covered cell absorbs independently, in the + existing order (armour, then item, then hull). Nothing travels, nothing is occluded, and + no remainder is routed anywhere. (Superseded for items by the 2026-09-25 one-path + ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) + + **A shot has a direction; a blast does not.** That sentence is the rule behind the split: + direct hits keep the lane model of 12.2 and 12.3, and blasts are areas. Where P sits: + - proximity fuse: the intercept point outside the hull (today's airburst); + - contact fuse: the impact cell; + - delayed fuse or penetrator: the end of the penetration walk. + + All three are weapons that carry a blast radius. A weapon without one is not a blast at + any penetration: it keeps 12.3's lane (see **Where 12.3 and 12.4 meet**). + + 12.4 replaces `FireControl.Splash` and its half-hull footprint, which absorbs F12-1. +- **WeaponModifiers are labels, not behaviour.** Operator: "The AP modifier was merely + there as a label. Like the Incendiary modifier which doesn't actually determine that the + weapon does Thermal damage, but exists to mark weapons that do. Modifiers show up in + schematics as a shorthand for the weapon type." Detonation behaviour comes from weapon + data fields only. +- **Not ruled:** a HUD showing which target items are exposed from the current bearing + (Q12-5). + +### What the probes found + +- **Penetration does nothing in the shipped catalog.** **(probe)** The largest authored + Penetration is `0.25` (Autocannon, SRMM72, LRMM72). `ApplyHit` only walks when + `penetration > .5f` (`Entity.cs:392`), and `WeaponData.Penetration` is inspector-ranged + `0..1` (`Behaviors/Weapon.cs:28`). +- **LonginusX's cockpit cannot be reached edge-on until penetration reaches about two + cells.** **(probe)** `exposure.py` walked lanes 0.01 cells apart at every whole degree of + bearing over the real 6x17 schematic. The ControlModule (cockpit) and the AetherDrive are + never the first cell in any lane. They are still never reached at penetration 0.25 or + 1.0, and are first reached at 2.0. The Turret hull's ControlModule is enclosed too. + Until F12-2 lands, the only way to kill a LonginusX through its cockpit is a blast + delivered inside the hull (12.4). +- **DamageSpread is live content.** **(probe)** GT 3K authors 1-3.25, plight 1-3.3, and + pswarm and scorched void policy 0.9-1.5. `Shape.Expand` is an 8-neighbour ring + (`ItemData.cs:164-174`), so spread 3 produces a 7x7 footprint today. +- **No shipped weapon carries a blast, or the Airburst label.** **(probe)** `Probe4`: + every `WeaponItemData.AirburstRange` is null, and no `WeaponModifiers` includes Airburst. + The labels in use are RapidFire, Cluster and Incendiary. All three Thermal weapons carry + Incendiary, and no non-Thermal weapon does. No weapon carries ArmorPenetrating, + including the three with penetration 0.25. So the only live consequence of demoting the + Airburst label is the code path at `FireControl.cs:282`. +- **The handedness history.** `right = float2(forward.y, -forward.x)` has not changed since + `b7743789`/`482c47bf` (`git log -S`). No sign error ever shipped. What survived was the + *mutant* `-forward.x` → `forward.x`, which is invisible when |fy| > |fx|, and Cut 11 pinned + it. The formula is duplicated at `Entity.cs:394-396` and `FireControl.cs:516-520`, and 12.1 + collapses the two copies. +- **The code has two cell conventions.** The kernel, `Shape.CenterOfMass` + (`ItemData.cs:121-123`) and `ResolveAimPoint` put cell *i* at the integer point *i*. + `ApplyHit` instead starts from `cell + .5` and truncates (`Entity.cs:398-404`), which + treats cell *i* as the square [i, i+1). **(probe)** On bearing (.8,-.6) from (1,2), the + 0.5-step march went (1,2)→(2,1) and skipped (2,2), a cell the ray actually crosses. +- **Nothing reads `ShotOutcome.Aimed`.** `rg "\.Aimed\b"` finds only `FireControl.cs:292` + (PendingShot, which stays), `:432`, `:435` and `:481`, plus `FireControlCut7Tests.cs:190` + (which also reads PendingShot). +- **`Apply` reads live positions at arrival** (`FireControl.cs:463-465`), which is an R4 + leak and Stryker's L464/L465. 12.2 removes it. +- **No simulation code places the schematic in the world.** **(read)** `SchematicCellSize` + (`Settings.cs:255`, default 2) is read only by `PSpread`, and ApplyHit and Splash convert + directions only. A blast needs world↔schematic *points*, so 12.4 names that rule. + `HullCollider` is a physics surface only (`Gameplay/HullCollider.cs:4-8`). +- **Renaming slot 29 is compatible drift, not a break.** **(probe + read)** The catalog + embeds each type's schema descriptor, including `{"slot":29,"name":"AirburstRange", + "type":"System.Nullable"}`, and the wire format is positional + (`Probe5_Wire`: `[…,12.5,50,20]`, with no field names on the wire). On a content-hash + mismatch, `CultCache.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` + (`:672-752`), which compares slot type, reference, cardinality, target schema, name + semantics and index alias, **not the member name**. So renaming slot 29 opens as + `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` + warning. The warnings persist until the catalog is next written with the new descriptor. + Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, + 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs + (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: + no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as + `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. + +### Frame, geometry and the bearing (the rules every sub-cut reads) + +- **Schematic frame, one owner.** `Entity.ToSchematic(float2 worldPlanar) → float2` maps + world to schematic with x = starboard and y = bow: `forward = normalize(Direction)`, + `right = float2(forward.y, -forward.x)`, returning `float2(dot(v, right), dot(v, forward))`. + This is the transform ApplyHit and Splash already use, with the same sign. The Cut 11 + fixture comment and the LonginusX schematic (nose at y 11-16, reactor and radiators at + y 0-3) agree with it. 12.4 adds the point forms `ToSchematicPoint(float2 worldPlanar) = + ToSchematic(worldPlanar - Position.xz) / SchematicCellSize + Shape.CenterOfMass` and its + inverse `ToWorldPoint`, so the entity's position is the schematic's centre of mass. + Operator confirmed 2026-09-22, from the schematic images: "bow is indeed +y on the + schematic images." Still on the operator's check list after 12.4 lands, because it is the + half that only a running blast can show: that blasts land where the model shows them, + which is what confirms the centre-of-mass anchor. +- **A cell is the unit square centred on its integer coordinate.** This matches + `CenterOfMass`, the aim point and the old kernel. The `+ .5` convention dies with ApplyHit. +- **Travel direction.** `FireControl.TravelDirection(Weapon, Entity source, Entity target) → + float2` is the planar world direction of `PredictedIntercept(weapon, source, target) - + source.Position`. That intercept is Cut 3's one function, and for a beam it reduces to the + target position. Below a length of 1e-6 it falls back to world +z, which is today's + `Apply` fallback (`:463`). It belongs to the shot: Fire freezes it into `PendingShot`. +- **Bearing.** `bearing = normalize(target.ToSchematic(travelDirection))`. This is the only + place a world direction meets a target's facing. +- **Lateral axis** `ℓ = float2(-b.y, b.x)`. The sign is fixed once, here. +- **Shadow.** Each occupied cell *c* projects to `[dot(c,ℓ) - h, dot(c,ℓ) + h)`, with + `h = (|ℓ.x| + |ℓ.y|) / 2`. The shadow is the sorted and merged union of those intervals; + merging matters because off-axis intervals overlap. Squares are used rather than points + because a line at lateral offset *s* crosses square *c* exactly when *s* falls in *c*'s + interval. "s lies in the shadow" is therefore the same event as "this lane has metal", so + pOnHull is exactly that event's probability, and placement drawn from the shadow always + lands on metal. +- **`Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float + precision)`** replaces `HullKernel` and is the one function. It returns the merged + intervals, `a = dot(aimPoint, ℓ)`, sigma, `Span = hi_max - lo_min`, and + `POnHull = Σ_k Φ((hi_k - a)/σ) - Φ((lo_k - a)/σ)`, where `Φ(z) = ½(1 + erf(z/√2))`. + `aimPoint` comes from `ResolveAimPoint` unchanged; only its lateral projection matters. + `Sigma` keeps its rule (`FireControl.cs:549-550`). The 9.2 rationale in its comment is + gone, because an exact 1D integral does not undershoot, so rewrite it: the floor is now a + design minimum on group tightness (F12-3). The function allocates only on the ungated + path: one interval array of length N (≤128 on shipped hulls) plus a sort. +- **PSpread** keeps its formula, but takes values instead of a `Weapon`: + `PSpread(float spread, float span, float range, float cellSize)`. The half-extent becomes + `.5f * span * cellSize` instead of `max(Width, Height)` (`:224`). PSpread and pOnHull read + one silhouette, so they cannot disagree about the target's size. +- **Lateral draw.** One `NextFloat` u, taken after the hit roll (the draw `WeightedPick` used + to take). Walk the cumulative interval mass to `u · POnHull` to select interval k, then + invert inside it with `s = a + σ√2 · erfinv(2p - 1)`. Clamp into [lo_k, hi_k) to absorb + rounding. The clamp is a guard, not an invariant. +- **Lane walk** `Lane(HullData hull, float2 b, float s, buffer)` collects the occupied cells + whose interval contains s, each with slab entry and exit parameters `t` along b, where + `t = dot(point, b)`. Cells are ordered by entry, with ties broken by `dot(c, b)` and then + cell index. It is exact slab traversal and replaces the 0.5-step sampling. The result goes + into a caller-supplied pooled buffer (`ArrayPool.Shared`; netstandard2.1 has + `System.Buffers`). A direct hit starts from t → -∞ (outside), so its first element is the + impact cell. It is the walker's only caller: blasts are areas and do not traverse. + + Metal is continuous while `next.entry ≤ current.exit + 1e-4`, and the first gap ends the + walk. That keeps today's rule at `Entity.cs:401`. +- **Penetration along a direct-hit lane.** A cell is reached when + `entry - impact.entry < penetration`, in cells. The impact cell is always reached. The + `> .5f` threshold (`Entity.cs:392`) is deleted. + +**Bearing timing (Q12-1 = B).** A shot is split into what the shooter decided and what the +target is doing: + +- **Frozen at Fire:** everything the shooter decided (R10). `PendingShot.PBase` is renamed + `PFire` because its meaning changes: it is `Accuracy × PSensor` behind the same gates, and + it is 0 when any gate fails. Also frozen: `TravelDirection`, `Precision`, `Aimed`, the + weapon's `Spread`, and `FireRange` (the range at Fire, which PSpread reads). +- **Live until Commit:** everything the target is doing. That was already its position + (deviation); now it is also its facing. `Commit` computes, in this order: + 1. `pDeviation`, as today. + 2. `bearing = normalize(shot.Target.ToSchematic(shot.TravelDirection))`, from the + target's facing at the commit tick. + 3. `sil = Silhouette(target, hull, shot.Aimed, bearing, shot.Precision)`. + 4. `p = PFire × pDeviation × PSpread(shot.Spread, sil.Span, shot.FireRange, cellSize) × + sil.POnHull`. + 5. The one roll against p. On a hit, the lateral draw from `sil`, and the impact cell + from `Lane`. + + Steps 2-5 are one function, `FireControl.CommitProbability(PendingShot, float now, + out Silhouette)`. The debug HUD calls it, which retires the inline estimate at + `ActionGameManager.cs:1317-1318` (`shot.PBase * pDeviation`, which would otherwise be a + second, wrong copy of the model). +- **The outcome freezes the committed geometry** (R4). `ShotOutcome` gains `float2 Bearing` + (schematic frame, as committed) and `float Lateral`, and keeps `Cell`. `Apply` walks + `Lane(hull, outcome.Bearing, outcome.Lateral)`. After Commit, turning the target changes + nothing, and Apply reads no position or facing. +- **HitProbability and Inspect** report `PFire × PSpread × pOnHull` at the *current* + travel direction and facing. That is a forecast, in the same way the HUD already treats + deviation. `Inspect.PBase == HitProbability` still holds, because both are the forecast. + The hot path still gates first (`:150-159`) and builds the silhouette only after the gates. + +What this does to the earlier rules: + +- **R3 (one roll, and a hit always lands on metal) still holds.** The factor the roll is + charged with, pOnHull at the commit bearing, comes from the same `sil` that placement + draws from, in the same call. Placement is exactly consistent with the pOnHull applied, + and it always lands on metal. What changes is that HitProbability's number is no longer + the exact roll price when the target turns during flight. It is the price only if the + target holds its facing, just as it already is only if the target holds its course. +- **Cut 5's deviation factor keeps its ordering and meaning.** It is still measured at the + commit tick against the fire-time projection, and it is still multiplied into p before + the single roll. The two silhouette factors join it in the same product. The Cut 7 guard + (`p > 0f && shot.Target != null`, `:410`) extends to cover them: when `PFire × pDeviation` + is 0, Commit builds no silhouette. Multiplication order has no numerical effect. The guard + exists only to skip work. +- **6d's premise ("pOnHull depends only on the aim point, the hull shape and the frozen + Precision, so it folds into PBase at fire time") is retired.** pOnHull now depends on the + target's facing, which is live target state like position, so it is priced at Commit. + R10 is untouched: every *shooter* stat is still frozen at Fire, and the target's facing + was never a shooter stat. +- **Cut 10 still holds:** gated HitProbability allocates nothing. Commit is not on the + hot path. + +### Sub-cut order + +| Sub-cut | Repo | Nature | Depends on | +|---|---|---|---| +| 12.0 | CultLib | CultMath gains `erf` and `erfinv` (a gap filled in its owner) | none | +| 12.1 | Aetheria | subtraction and frame owner; no behaviour change | none | +| 12.2 | Aetheria | where a hit lands: silhouette at Commit, Apply stops reading positions | 12.0, 12.1 | +| 12.3 | Aetheria | how a direct hit travels: exact lane walk, sequential absorption, spread lanes | 12.2 | +| 12.4 | Aetheria | the detonation primitive (areas), fuse data, the Airburst label demoted; Splash deleted | 12.3 | + +The five are kept apart so that Soul falsifies each question on its own: where a shot +lands, what a direct hit does, and what a blast does. In 12.2, `ApplyHit` survives for one +more cut with a changed signature: it takes the schematic bearing and loses its own +rotation, while its march and its even split are left untouched until 12.3. + +--- + +### Cut 12.0. CultMath gains `erf` and `erfinv` + +- **Repo/branch:** `F:\Projects\CultLib`, off current `main`. +- **Adds:** `math.erf(float)` and `math.erfinv(float)` in + `packages/cultmath/src/CultMath/math.cs`, as standard single-precision approximations (for + example Abramowitz-Stegun 7.1.26 or a minimax fit; Giles 2010 for erfinv), with tests in + `packages/cultmath/tests/CultMath.Tests`. Neither is an HLSL intrinsic, so there is no + shader-parity obligation. +- **Release:** run `packages/cultmath/scripts/build-unity-package.ps1`, bump + `unity/org.gamecult.cultmath/package.json` from 0.2.3 to 0.2.4, and tag + `cultmath-unity-v0.2.4`. Aetheria then bumps `Packages/manifest.json:56` and the headless + `CultLibRoot` pin. +- **Verification:** + - `ErfMatchesReferenceValues`: erf at 0, ±0.5, ±1, ±2 and ±3 matches tabulated values + within 2e-6; erf is odd; the limits are ±1. + - `ErfinvInvertsErf`: over y ∈ [-0.999, 0.999]. + - The CultLib mutation run, with every survivor triaged by name. + +**Status: the code closes, the release does not.** Landed on CultLib +`hands/cultmath-erf`: `87b95ae`, `b688cb2` (erf, erfinv, tests), then the fix batch +`fc9ce97`, `4cdc02b`, `6e8362b`, `cc71f17`. 102 tests. + +Two verification lines above were stale when written and are corrected here. CultLib had +no mutation tooling at all, so there was no "CultLib mutation run"; Stryker.NET was adopted +in its own cut (`hands/adopt-stryker`) and its CultMath baseline is 2243 mutants, 350 +survived, triaged in that cut's report. The headless pin needed more than a bump. `Directory.Build.props:5` pins one CultLib revision +for every package, and that revision (`45c2f40`) is exactly Unity's `caching-unity-v1.4.0` / +`cultlib-unity-v1.0.60`; moving it to the release would have put six CultNet commits of newer +CultCache, including a breaking registration change, under the headless tests while Unity +stayed on 1.4.0. So the headless build now pins per package, mirroring the manifest: +`CultMathRoot`/`CultMathRevision` at the 0.2.4 tag's merge (`6d5e2096`), `CultLibRoot`/ +`CultLibRevision` still at `45c2f40` for CultCache. A package's pin moves only when its +manifest tag moves. (Self first repeated Soul's claim that there was no pin, from a read of +lines 3-4; line 5 is the pin, and the build refused the first attempt to use the release.) The +Unity manifest lives at `packages/cultmath/unity/org.gamecult.cultmath/package.json`, not +at the repo root. + +Soul's first pass found the defect this cut would otherwise have shipped: `erfinv(±1)` +returned the **wrong-signed** infinity, so a sample drawn for the far edge of an interval +was clamped onto the near edge. Reachable in ordinary play — `erf` saturates to exactly 1 +at 5.543σ, so any interval edge past that yields `p = 1`. Fixed with two guards; outside +the domain is NaN, now documented and pinned. Soul also measured the reported accuracy +figures 1.5× optimistic (true worst error: erf 6.621e-7, erfinv 5.066e-7, round trip +6.109e-7, all inside the 2e-6 bar), found `erfinv` had no accuracy test at all (the round +trip was blind by 300-400×, proven with a coefficient mutant), and found +`ErfinvIsMonotonicallyIncreasing` asserting a property neither function has (9662 backward +steps in `erfinv` at float granularity; the fixture's 0.001 step hid every one). The second +pass re-derived all 19 new reference values by a different method and closed the cut. + +**Cost, for 12.2 to answer rather than assume:** `erf` is 16.3 ns per call and +allocation-free, but a silhouette costs one `erf` per interval edge — ~4.2 µs per +evaluation on a 128-interval hull, 1.06 µs on 64, and worse under IL2CPP. The AI evaluates +`HitProbability` constantly. 12.2 must measure the new placement against the old kernel per +gated-in evaluation and report both, not assume 1D beats 2D. + +**Released 2026-09-24:** CultLib `20e6da5` (release) and `8c86bc9` (the changelog meta), merged to +main at `6d5e2096`, tagged `cultmath-unity-v0.2.4`. Release Soul pass: the DLL rebuilt byte- +identical from source (sha256 `2470cd48…`), public surface +2 (`erf`, `erfinv`), shipped +`erfinv(±1)` correctly signed; its one finding, `CHANGELOG.md` without a Unity `.meta`, was fixed +before tagging. Aetheria repinned in `2a68b4c0` (manifest and Unity-written lock, hash +`6d5e2096`); batchmode resolve and compile clean, no missing-meta warning. Follow-up for CultLib: +`build-unity-package.ps1:89-90` checks metas only for code and binary files, so it could not see +the missing `.md` meta. + +**Was owed (kept for the record):** Unity +consumes a *precompiled* `CultMath.dll` pinned at `cultmath-unity-v0.2.3` +(`Packages/manifest.json:56`), so no Unity-side fire-control code can call `erf` until the +package is rebuilt, bumped to 0.2.4, tagged and repinned. The headless tests see the +sources directly, which is why nothing failed. 12.2 must not land in Unity before that. + +**Recorded, not done:** `erf`/`erfinv` are scalar-only while every other scalar in +`math.cs` ships float2/3/4 overloads. The consumer is scalar; vector overloads are a +separate decision. + +### Cut 12.1. One frame owner, and the dead outcome flag + +- **Repo/branch:** Aetheria, off the Cut 11 merge. No behaviour change. +- **Deletes first:** + - The two inline frame copies: + - `FireControl.cs:516-520` becomes `target.ToSchematic(toTarget)`, normalized, keeping + the zero-length fallback. The whole of Splash dies in 12.4, but the frame owner lands + first. + - `Entity.cs:394-396` becomes `ToSchematic(hitDirection)`. + - `ShotOutcome.Aimed` and its plumbing (Q12-4 = A): + - the field, `FireControl.cs:702`; + - the `aimed` parameter of `MakeOutcome` (`:470`, `:481`); + - `Commit`'s `aimed` local and flag (`:420`, `:435`); + - the `AimedCells` half of `ResolveAimPoint`'s tuple (`:583-589`), which now returns + `float2`. +- **Adds:** `Entity.ToSchematic(float2)`. +- **Verification:** + - Builds: headless `Aetheria.Shared`, plus a Unity batchmode compile by Self. + - All 244 tests pass unchanged. `SplashDamagesTheSideTheBlastCameFrom` and + `PenetrationMarchIsPlanar` pin the frame through its new owner. + - Negative greps: + - `rg -n "forward\.y, -forward\.x" Assets/Scripts` returns exactly one hit, in + `Entity.cs`. + - `rg -n "Outcome\.Aimed|outcome\.Aimed|o\.Aimed" Assets/Scripts tests` returns + nothing. + + Both were checked against the current tree: the first finds today's two copies, and the + second finds nothing. + +**Status (2026-09-24): landed** at `b851b0d1` (+21/-18 across `Entity.cs` and +`FireControl.cs`). 244 tests pass, both greps as specified (the one frame copy is +`Entity.cs:383`, inside `ToSchematic`), Unity batchmode compile clean, and nothing outside +`FireControl.cs` read `ShotOutcome.Aimed` (the HUD holds outcomes but never touched it). +Stryker on the changed lines: the frame's sign mutant dies; the only survivors near the diff +are `ResolveAimPoint`'s pre-existing centroid arithmetic (`aimedCells.Length > 0` → `>= 0`, +`/` → `*`), which 12.2's `AimingAtAnItemCentresTheScatterOnItsLane` is specified to close. +`--since` does not work in this repo (Stryker's own diff comes back empty), so runs use +`--mutate` on the touched files and filter the report to changed lines. No separate Soul pass: +a no-behaviour cut this size folds into 12.2's. + +### Cut 12.2. Where a hit lands + +- **Repo/branch:** Aetheria, on top of 12.1, with the 12.0 pin in place. +- **Deletes first:** + - `HullKernel` and its comment block (`FireControl.cs:531-578`). + - `WeightedPick` (`:591-611`). + - `POnHull` (`:229-234`). + - Apply's live direction (`:463-465`). + - The bounding half-extent (`:224`). + - The 9.2 SigmaFloor rationale (`:542-548`). Rewrite it as described above. + - The ActionGameManager inline estimate (`:1317-1318`). +- **Keeps:** `Sigma`, `SigmaFloor`, `ResolveAimPoint`, `CellsOf`, the gate order + (`:150-159`) and `Inspect.PBase = HitProbability(...)` (`:204`). +- **Adds:** `TravelDirection`, `Silhouette`, `CommitProbability`, the lateral draw, `Lane` + (only its first element is used in 12.2), and these fields: + - `PendingShot`: `TravelDirection`, `Spread` and `FireRange`; `PBase` is renamed to + `PFire`. + - `ShotOutcome`: `Bearing` and `Lateral`. +- **Per-file changes:** + - `FireControl.cs:161-167`: after the gates, `PFire(...) × PSpread(...) × + Silhouette(...).POnHull`. The bearing comes from `TravelDirection` and the target's + current facing. Nothing new runs before `:159`. + - `:197-202`: Inspect uses the same functions. It gains nothing new beyond + `diagnostic.PFire` if the HUD wants it (optional; presentation). + - `:263`, `:286-308`: Fire freezes `PFire`, `TravelDirection`, `Spread = weapon.Spread` + and `FireRange`. + - `:409-435`: Commit takes steps 1-5 of **Bearing timing** through `CommitProbability`. + - `:463-467`: Apply passes `shot.Outcome.Bearing` (schematic frame) to `ApplyHit`. For + this one cut, ApplyHit's parameter becomes a schematic bearing and the `ToSchematic` + call 12.1 put inside it is removed. + - `ActionGameManager.cs:1317-1318`: the pending line prints + `FireControl.CommitProbability(shot, now, out _)` instead of `shot.PBase * pDeviation`. + - `Combat.cs:117` and `TurretController.cs:84` need no change. They read HitProbability, + which is now a facing-aware forecast. Expect the AI to prefer broadside shots on long + hulls, and targets that turn to lose fewer hits. That is tuning, not a defect. +- **Authority map:** + - Owner: + - `FireControl.Silhouette` owns scatter. + - `FireControl.CommitProbability` owns the commit-time roll price and placement + source. + - `FireControl.TravelDirection` owns the shot's world direction. + - `Entity.ToSchematic` owns the frame. + - Inputs: + - Frozen: `PFire`, `TravelDirection`, `Precision`, `Aimed`, `Spread`, `FireRange`. + - Live at Commit: target position (deviation) and target facing (bearing). + - Outputs: p for the one roll, then `Bearing`, `Lateral` and `Cell` in the immutable + outcome. + - Derived state: + - HitProbability and Inspect are a forecast of the commit price and decide nothing. + - `ShotOutcome.Cell` is display-only, for the HUD (`ActionGameManager.cs:1323`) and + the tests. + - Forbidden writers: + - Nothing else computes a sigma, a projection or a bearing. + - Nothing after Commit reads a target's facing or anyone's position for placement or + damage direction. + - The HUD computes no probability of its own. + - Shared paths: InstantWeapon (`InstantWeapon.cs:248`), beam rolls + (`ConstantWeapon.cs:181`, where zero flight time makes the fire tick the commit tick), + the AI, turrets and the HUD. + - Deletion line: `HullKernel`, `WeightedPick`, `POnHull` and the HUD estimate are gone + before `Silhouette` has a caller. +- **Verification.** Fixtures follow 11.3: the shooter is off the origin, fire times are + nonzero, facings include ones where |fx| > |fy|, and weapon spread is nonzero wherever + PSpread is asserted. + - `BroadsideIsEasierThanHeadOn`: on a 2x12 hull, pOnHull and PSpread head-on are strictly + below broadside, at facings (0,1) and (1,0). It kills the bearing being ignored and the + old `max(W,H)`. + - `TurningArmourIntoTheShotTakesItOnTheArmour` (the ruling's own case): + - Setup: a slow shot (flight time well above `CommitHorizon`) at the soft stern of a + wedge with bow-only plate. The target turns its bow to face the shot's + `TravelDirection` before commit. + - Pass: every hit's `Cell` is on the bow row. + - Kills: freezing the bearing at Fire; reading the facing at Fire. + - `TurningAfterCommitChangesNothing` (R4, and closes L464/L465): + - Setup: through `Fire`. After commit and before arrival, the target turns 90° and the + shooter moves. + - Pass: the damaged cells equal those implied by the committed `Bearing`/`Lateral`. + - Kills: Apply reading live positions or facing. + - `PlacementAndProbabilityShareOneSilhouette` replaces 6d's + `PlacementAndProbabilityShareOneKernel` (`FireControlCut6dTests.cs:318`). + - Setup: a static target at a diagonal bearing, so the intervals overlap and the + forecast equals the commit price. + - Pass: `CommitProbability` equals a pOnHull that the test computes independently, by + numerically integrating the Gaussian over a union of intervals the test builds itself. + The empirical `Lateral` distribution matches the per-interval shares, and the + empirical hit rate matches p. + - Kills: sigma or bearing perturbed at one site; the union merge skipped; ℓ swapped + with b. + - `TheHudEstimateIsTheCommitPrice`: for an in-flight shot, the value the HUD prints and + the p Commit rolls against are the same number at the commit tick. It kills a second + formula. + - `HitsLandOnTheFacingEdge` supersedes `EveryHitLandsOnMetal` (`:357`): on the concave + fixture, each hit's `Cell` is occupied, and nothing occupied precedes it in its own lane. + The test recomputes the lane from `Bearing` and `Lateral`. + - `AimingAtAnItemCentresTheScatterOnItsLane` closes L585/L587 and replaces + `AimingAtTheSternHitsTheStern` (`:213`), whose fixture fires from the target's stern + (`FireControlCut6dTests.cs:163-164`) and would pass for the wrong reason. + - From broadside, hits aimed at the stern land mostly in the stern half, and hits aimed + at the bow land mostly in the bow half. + - From dead astern, hits aimed at the bow land on the stern edge. + - `TheSigmaFloorHoldsAtHalfACell`: pOnHull at Precision 1000 equals pOnHull at Precision 2 + on a one-cell lane. The exact integral no longer collapses, so this test, not the cliff + test, is now what kills "delete the floor". Keep the cliff test and rewrite its + mutation comment. + - `ShieldIsOmnidirectional`: an active shield absorbs the same shot identically from bow, + stern and beam. + - Kept green: + - `GatedOutHitProbabilityAllocatesNothing`. + - `TheHudShowsTheFactorsTheSimulationMultiplies`, re-read as forecast factors. + - `PSpreadIsTheSilhouettesShareOfTheSpreadCone`, which fires from astern at a 5-wide + hull. There the span is 5, equal to the old `max(5,3)`, so its numbers stand. + - `ThinLimbCostsHitChance`, with its comment rewritten in 1D terms. + - Every test that reads `shot.PBase`, updated to `PFire` with its expectation + unchanged. + - Stryker: `--mutate "**/FireControl.cs" --since:<12.1 head>`. Survivors are triaged by + name, and float boundary flips count as equivalent (11.2). +- **Operator:** + - A smoke against a LonginusX-class AI from the bow and then the beam. The HUD `hull` + factor should read higher from the beam. + - Turn your armoured face into a slow missile and watch where it lands. + +**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: +`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` +(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. + +What the Soul passes found, in order, because two of them are lessons for 12.3: +- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, + and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so + every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause + found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; + `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration + could follow a turn made after commit; multi-cell aimed items were untested. +- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. + The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result + into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root + cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, + `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the + two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an + `IComparer` in a delegate per sort). +- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the + slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all + cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and + float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration + found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact + arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. + The `Commit` invariant throw is unreachable. + +Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX +from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside +fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an +item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per +`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. + +**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at +b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant +survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of +their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in +`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make +`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through +visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` +import. + +### Cut 12.3. How a direct hit travels + +- **Repo/branch:** Aetheria, on top of 12.2. +- **Deletes first:** `Entity.DamageSchematic` (`Entity.cs:333-374`) and `Entity.ApplyHit` + (`Entity.cs:376-409`). Going with them: + - the 0.5-step march; + - the `+ .5` cell convention; + - the `> .5f` threshold; + - the Expand spread footprint. +- **Adds:** + - `Entity.Absorb(int2 cell, float damage) → float`, moved verbatim from + `Entity.cs:348-366`. Armour absorbs up to its value, then the item when `d > .1f`, and + the remainder is returned. It emits `ArmorDamage((cell, incoming))` and + `ItemDamage((item, incoming))` with today's meaning, and only for incoming > 0. + - `Entity.DamageHull(float)`, moved from `Entity.cs:369-373` (`> .1f`, one `HullDamage` + event). +- **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches + unchanged: + 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. + 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's + rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and + **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per + cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two + derivations disagreed at cell edges and struck a cell twice). + 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. + 4. Each lane walks from its own facing cell, clipped to the penetration depth, with + `rem = target.Absorb(cell, rem)` at each cell. + 5. Each lane's final remainder goes to the hull, where the lane ends: penetration + exhausted, a gap, or the far side (Q12-3 = A). The remainders are summed and passed to + `DamageHull` once. + 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead + (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. + + **Where 12.3 and 12.4 meet.** Which model a shot uses is decided by the blast radius, not + by its penetration: + - **No blast radius — a dumb AP round.** Steps 1-5 are the whole model, whatever the + penetration. The shot travels its lane and is absorbed cell by cell in order, with the + remainder carrying on until it is spent or the lane ends (Q12-3). Damage in a line. More + penetration spreads the same damage over more cells; it never concentrates it at depth. + Q12-8 says nothing about this case. + - **With a blast radius — an explosive penetrator.** The shot reaches its fuse depth + without spending damage on the way, and then detonates as an area (12.4). Q12-8 governs + only this case, because the payload is the explosion, not the dart. + + The two roles that follow are the design intent: the AP round is the pinpoint component + sniper, a thin line of damage through whatever it crosses, and the explosive penetrator + is the interior wrecker, a disc across a whole section. + + Splash is untouched in 12.3 and deleted in 12.4. Until then it calls `Absorb` per + footprint cell and passes the summed remainder to `DamageHull`, which is arithmetically + identical to today. +- **Authority map:** + - Owner: `Entity.Absorb` owns what one cell does with incoming damage. + `FireControl.Apply` owns the order and the share per lane. + - Inputs: the committed `Bearing`, `Lateral` and `Cell`, plus the frozen `Penetration`, + `DamageSpread` and `Damage`. + - Outputs: `Armor[]`, item `Durability` and `Hull.Durability`, plus the events + `IncomingHit`, `ArmorDamage`, `ItemDamage` and `HullDamage`. Their consumers: + `ActionGameManager.cs:1070`, `EntityInstance.cs:292`, `InventoryPanel.cs:470-482`, + and `Entity.cs:488-494` (ItemDestroyed, HullArmorDepleted, Death). + - Forbidden writers: no weapon hit writes armour or item durability except through + `Absorb`. `Behaviors.CauseDamage` (`Behaviors/Behaviors.cs:62-73`) is self-damage and + out of scope. + - Deletion line: both Entity methods are deleted before `Absorb` gets a second caller. +- **Verification:** + - `DamageIsAbsorbedInOrderAlongTheRay` replaces `HardpointHitDamagesItemThenHull` and + `PenetrationMarchIsPlanar` (`FireAuthorityTests.cs:481-526`, which call the deleted + `ApplyHit`). + - Setup: through `Fire` at facing (1,0). The lane runs through a 10-armour front cell, + then a marker, then a soft cell, with damage 30 and penetration 3. + - Pass: armour −10, marker −20, soft cell untouched, hull −0. Today it would be + 10/10/10 **(probe)**. + - Kills: an even split; items before armour; no carry-forward. + - `ArmourFacesTheShot`: + - Setup: a wedge with bow-only plate, penetration 0, many shots. Facings (0,1), (.8,.6) + and one where |fx| > |fy|. + - Pass: from ahead only bow cells lose armour, and from astern the reverse. + - `BuriedCockpitNeedsPenetration`: a cockpit enclosed by one ring of cells. + - At penetration 0 it is never damaged from four bearings. + - At a penetration that clears the ring, it takes exactly `damage/lanes` minus what the + ring absorbed. + - Enough damage fires `Death` with `CockpitDestroyed`. + - `SpreadWidensTheImpactAcrossLanes`: spread 1 at penetration 0 puts damage/3 on each of + three facing-edge cells and nothing behind them. + - `ARayThroughACornerCrossesTheCornerCell`: the diagonal case from `Probe1` reaches (2,2). + - `LaneRemainderReachesTheHull`: a ray that exits a thin hull delivers its remainder to + `Hull.Durability` (Q12-3 = A). + - Kept green: every Splash test, and `AirburstSplashes…` (Cut 6). + - Negative: `rg -n "DamageSchematic|ApplyHit\(" Assets/Scripts tests` returns nothing. + Today it matches `Entity.cs`, `FireControl.cs`, `FireAuthorityTests.cs`, and two comment + lines at `Gameplay/EntityInstance.cs:280-281`. Rewrite those comments and + `FireControl.cs:492`. + - Stryker: `--since:<12.2 head>` over `FireControl.cs` and `Entity.cs`. +- **Operator:** a smoke with a GT 3K or pswarm launcher into a LonginusX bow, then from a + flank. The schematic display should pulse the facing edge only. + +**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: +- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. +- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. +- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. +- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. +- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. +- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. + +297 tests. Unity batchmode is clean at `cccf3a2a`. + +Proportional absorption, passes 4 and 5: +- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` + was already ruled out by the operator. Soul found three more defects: + - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. + - The pooled resolve skipped the item's `.1f` threshold. + - The model sweep still passed with proportionality deleted. +- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. + `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is + Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. +- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled + bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. + Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and + stale comments. All are fixed, and each mutant dies to a committed test. + +Recorded, not fixed: +- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, + which only a private seam offers. The operator rule forbids reflection, and no test seam is added + for them. + - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. + - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). + - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). +- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. + +What the three Soul passes found: +- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of + 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an + angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. + It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on + (Q12-2 amendment: no doubling up, the line thickens). +- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits + struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first + structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the + centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit + with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float + drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two + derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, + the `ceil` empty centre); the cure each time was one owner. +- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two + lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by + construction. Residual float effects (a side lane starting one cell deeper, a column falling + between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage + is never lost. + +**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike +exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% +of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing +ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since +it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. + +**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the +first-processed lane drains its durability and the second carries the leftover into its own deeper +cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on +a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs +from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. +Alternative: keep the processing order and pin it. + +**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell +item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane +carries on with a leftover in proportion to what it brought. The result must not depend on the order +lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. + +**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an +exception, this should be one code path." The first implementation (`56757913`) surveyed each shot +for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split +authority, and the second reachability derivation it needed is the same failure as the 12.2 empty +lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of +the lanes that reach it, a pool of one lane included. The survey and the sequential path die. + +**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. + +### Cut 12.4. The detonation primitive + +**Status (2026-09-29, Self): closed.** 12.4 landed at `2625768f`, `7c99909d` and `7e63d744`. + +Soul pass 1 (Opus) found three defects: +- **F1:** a tangent disc created damage, 127 of 100. +- **F2:** a candidate cull measured from the centre of mass missed hull corners. +- **F3:** a lethal blast threw mid-loop and detonated again. + +It also found four rules pinned only by tests that could not fail. + +The fixes landed at `8b4bf01e`, `76e98598` and `fd792013`: +- the tangent comparisons now include equality; +- the cull and the `radius <= 0` guard are deleted, so `RectDiskOverlap` is the only decider; +- the blast loop iterates a snapshot. + +Soul pass 2 closed it. Conservation held over 1,944 exact-lattice discs, with a worst error +of 2e-5. A blast costs 0.3-0.7 ms in a zone of 40-100 entities. + +Pins landed at `4629bc67` and `c7e20f08`. `Death` fires once per entity (`Entity.cs`, `.Take(1)`), +which also fixed a direct hit on a corpse raising `Death` again. A three-kill blast test was added. +362 tests pass. + +**Operator ruling needed:** a fused weapon fired with no target bursts at the shooter's own +position (`FireControl.cs:391`). This predates 12.4. Self recommends a max-range burst along the +aim line. Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship +still takes blast damage. + +Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path +rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. +Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see +Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. + +- **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: + - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no + weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has + not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). + - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. + +#### Deletes first + +In (a): + +- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), + `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. + `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. +- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The + initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a + proximity fuse. +- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. + +In (b): + +- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. +- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. + `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). +- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell + separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. + **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` + the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. + 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. +- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended + answer. It is not deleted: it is gated on `shot.Fuse == null`. +- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one + reach function (see **The penetrator**). +- Stale comments that name a deleted owner as live: + - `Entity.cs:391` and `:417`; + - `FireControl.cs:1201`; + - `Gameplay/EntityInstance.cs:280-283`; + - `Gameplay/HullCollider.cs:4-6`; + - `Gameplay/Weapons/Projectile.cs:8-9`; + - `Gameplay/Weapons/Mine.cs:90-95`. + +#### Data + +The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). + +- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name + `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache + opens the catalog as compatible drift (see 0b). +- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, + Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: + null means no blast, and every shipped record reads as null. +- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when + `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing + re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius + without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is + not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on + `BlastRadius` alone (Soul, `2625768f`). +- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That + write is content and changes no data. It belongs to F12-2. +- **Readers to update:** + - `FireControl.cs:386-388`. + - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` + parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, + because Q12-6 ruled that nothing checks labels against behaviour. + +#### Which model a shot uses + +`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: + +- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. +- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which + `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, + `AirburstSplashesEvenOnAGuaranteedMiss`. +- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P + is**). On a miss nothing happens. + +Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse +into Apply's switch, so `Step` has no damage branch left. + +`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and +only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one +consumer is the player's hit marker (`ActionGameManager.cs:1070`). + +This is a default, not a ruling. + +#### Where P is + +The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as +`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). + +**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: + +- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 + from its centre; +- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. + +So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. + +- **Proximity:** `BurstPosition.xz`, a world point. +- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and + `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. +- **Delayed:** the schematic point `point(t_fuse)`, with + `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that + 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, + whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last + reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. +- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already + proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not + read. The disc's radius is its width. This is a default, not a ruling. +- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the + host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like + every other entity in the radius: its cells are found again by `ToSchematicPoint`. + - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for + it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. + - Bystanders are judged against where the host actually is at arrival. That is the same live rule a + proximity burst already applies to everyone. + + There is exactly one input shape, a world point. There is no host-only schematic entry. + +#### The penetrator (Q12-8) + +A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` +the shot's whole `Damage`**. + +That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the +explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. + +The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at +`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → +int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while +`entry - impact.entry < penetration`. + +Two callers read it: + +- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; +- the delayed fuse point, which calls it on the centre lane. + +No second "lane damage with a blast" path exists, because no shot does both: + +- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; +- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. + +A shot takes one branch of Apply's switch or the other, never both. + +The mutant this must kill is a second reach derivation: for example, a delayed P computed as +`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. + +#### Area, per entity: `FireControl.Detonate` + +The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, +public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). + +- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes + `position.ToCultMath().xz`). +- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / + SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the + frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and + `SchematicCellSize` is `Settings.cs:255`. + +For each entity in the zone: + +1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. + Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a + long hull whose end lies inside the blast. +2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For + every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × + overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a + cell being the unit square centred on its integer coordinate. + - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing + computes an overlap twice. + - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. +3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, + under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact + or delayed blast, under Q12-9's recommended answer. + - If the shield absorbs, no cell of that entity takes anything. + - If it cannot, it breaks, and the cells take their shares. +4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects + its own cell's share and nothing else. +5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in + hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has + deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join + the hull total. +6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). + +Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the +other (F12-8). + +**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, +and no operator question is needed: + +- "Armor absorbs first", from the first round of rulings; +- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; +- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where + incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after + `ArmorAbsorb`; +- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." + +The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: +an item's cells deposit into one pool, and they do not absorb independently. + +**One owner, and why there is no walk.** + +- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on + the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. +- `ArmorAbsorb` is the one owner of what armour does. +- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, + `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the + item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no + cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the + reachability test and the cycle rule have nothing to decide. + +The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull +with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell +with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 +discs. Its hull total differed only by float summation order, at most 3.8e-6. + +`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a +starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. + +**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same +per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only +order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. + +**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the +disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` +between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it +conserves exactly up to float rounding. + +- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at + radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double + precision is not needed. +- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. +- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc + smaller than a cell, or for one straddling a cell boundary. + +**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than +128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not +the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. + +**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an +area is occluded. A bulkhead protects its own cell's share and no other. Falloff toward the rim is F12-7, not +this cut. + +**HitProbability and Inspect are unchanged.** A contact or delayed blast still needs the shot to hit, so the +forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. +That is pre-existing, and it is F12-5. + +#### Authority map + +**Owners:** + +- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. +- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the + per-entity armour→pool→hull pass. +- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with + 12.3's walk. +- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. +- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. +- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. + +**Inputs:** + +- P, the radius, the damage and the type; +- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; +- the fuse and the radius, frozen at Fire; +- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. + +**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, +`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. + +**Derived state:** + +- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, + `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. +- `BurstPosition` is read only for a proximity fuse. +- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. + Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` + finds only `FireControl.cs`). + +**Forbidden writers:** + +- No label decides behaviour. +- Nothing but `Detonate` applies area damage. +- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. +- Nothing but `Reach` decides how deep a lane reaches. +- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). +- `Step` has no damage branch. +- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they + decide no damage themselves. + +**Shared paths:** + +- the proximity burst: Apply at arrival, with no roll; +- the contact and delayed blasts: Apply on a committed hit; +- `Mine.Explode`; +- the tests, which call `Detonate` directly (see **Test seam**). + +**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a +caller. + +#### Test seam + +`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen +intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production +interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from +`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is +committable with no seam and no reflection. + +It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at +an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes +a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and +exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: + +- angled direct-hit correctness is covered by the model sweep and + `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; +- P1 and P2 stay uncommitted. + +#### Verification + +These fixture rules apply to every blast test that touches the frame: + +- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a + deleted `/ SchematicCellSize` survives. +- The target is off the origin. +- The facings include one with |fx| > |fy|. +- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. + +Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 +absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. + +**Geometry.** + +- `TheDiscIsConservedOverTheGrid`. + - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius + under one cell, one centred on a cell corner, and one off the grid. + - Pass: the total equals `damage`. + - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box + that clips the rim cells; a dropped chord breakpoint. +- `EachCellTakesItsShareOfTheDisc`. + - Setup: a solid hull with no armour and no items. + - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. + - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the + cell's centre. +- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). + - Setup: a blast straddling the hull's edge. + - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The + shares sum to the blast's damage minus what falls off the hull. + - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. +- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. + - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. + - Pass: the near end takes damage. + - Kills: Splash's centre-distance cull. + +**Absorption order.** + +- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` + (`FireControlCut123Tests.cs:750`). + - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage + levels of the old test: below the armour, between the armour and armour plus durability, and above both. + - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. + - Kills: the item absorbing before the armour, and armour being skipped. +- `ArmourProtectsOnlyItsOwnCellsShare`. + - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. + - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. + - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat + cell B's share. +- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. + - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but + more than `.1` on the two together (the `ProbeC2` geometry). + - Pass: the item absorbs the sum. + - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or + a pool that resolves before every covered cell has deposited. + + This test is also what pins that the result does not depend on which cell is considered first. As shown + above, the threshold is the only order-sensitive rule a blast has left. +- `ItemDamageFiresOncePerCoveredCell`. + - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive + post-armour deposit, and each event carries that cell's deposit. + - Kills: one event per item, and events for zero-deposit cells. + +**Symmetry and frame.** + +- `MirroredBlastsDoMirroredDamage`. + - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of + world points mirrored across the target's bow axis, with the mirror computed by the test from the facing + and not through `ToSchematic`. + - Pass: per-cell armour, item durability and hull damage mirror. + - Kills: + - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); + - a missing or wrong `CenterOfMass` anchor; + - any scheduling rule that depends on enumeration order. + + It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test + does. +- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` + (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). + - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's + width. + - Pass: port cells are damaged, and starboard cells are untouched. + - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. + +**Shields** (under Q12-9 = A). + +- `ShieldPaysForWhatReachesIt`. + - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. + - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` + (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the + covered share, never loosened. + - Kills: charging the full damage. +- `ABlastShotsShieldIsDecidedAtDetonation`. + - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at + arrival. + - Pass: the shield is charged the covered share. + - Second setup: a shield that could absorb the whole shot at commit. + - Pass: the shot still detonates, and the shield pays its covered share. + - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the + host. + +**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). + +- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit + behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. + - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour + of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. + - A contact fuse with the same stats leaves the cockpit untouched. + - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in + order, and the cockpit takes only what the ring left (12.3). Damage in a line. + - Kills: + - absorbing along the burrow; + - a contact fuse reading penetration; + - routing every penetrating shot through the fuse path; + - a fuse without a radius detonating. +- `TheFusePointStaysOnMetal`. + - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap + behind the first run. + - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the + gap's near edge). + - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. +- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. + - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. + - Pass: the host's per-cell damage equals that of the same shot with no turn. + - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. +- `LabelsDoNotDecideBehaviour`. + - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with + `Fuse = Proximity` and no label detonates. +- `AFuseWithoutARadiusIsADirectHit`. + - Pass: `Fuse = Contact` with a null or zero radius does lane damage. + - Kills: Fire freezing `Fuse` without the radius check. +- Re-fixtured to `Fuse`, with their behaviour unchanged: + - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes + `ProximityDetonatesEvenOnAGuaranteedMiss`; + - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); + - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; + - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes + `ABlastHitsEveryEntityItCovers`. +- `AContactBlastReportsTheHit`. + - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. + - Kills: `IncomingHit` moved inside the direct-hit branch. + +**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: + +- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes + `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with + a remainder of 0. +- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool + of `{ .1f }`. + +**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the +clip rule are otherwise untouched. + +**Negative greps:** + +- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` + returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and + `FireControlCut4/5/6/11/123Tests.cs`. + - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are + unrelated. + - Exclude `StrykerOutput`, `bin` and `obj`. +- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the + stale-comment list above. + +**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. +`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). + +#### Operator + +- Author one proximity weapon and one delayed-fuse weapon in a scratch catalog. +- Watch an airburst beside a LonginusX and a penetrator into its nose, and check that the damage lands where + the model shows the blast. That verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of + the frame that only a running blast can show. + +#### History (superseded 2026-09-25, kept for the record) + +The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: + +- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** + Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour + stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. +- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with + `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. +- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** + Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then + `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit + had judged unshielded: two authorities over one shield. +- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and + `Step` has no damage branch. +- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one + `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. +- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was + `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. + +### 0b. Identity, lifecycle, authority + +Two persisted changes, both on `aetheria.weaponitemdata` v1 and both in 12.4: + +| Field | Slot | Change | Lifecycle | Reader risk | +|---|---|---|---|---| +| `BlastRadius` (was `AirburstRange`) | 29 | rename, same `float?` type | authored catalog | compatible drift: `CompareSchemaShapes` ignores member names (`CultCache.cs:672-752`, CultLib `45c2f40`); drift warnings until the next catalog write | +| `Fuse` | 32 | new, `WeaponFuse?` | authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule | + +Runtime-only and never serialised (0b table, `FireControl.cs:626-629`, `:686-689`): + +- `PendingShot`: `PFire`, `TravelDirection`, `Spread`, `FireRange`, `Fuse`, `BlastRadius`. +- `ShotOutcome`: `Bearing`, `Lateral`. + +Nothing in saves changes shape. + +### Operator questions + +Ruled 2026-09-22 (the words are recorded under **Rulings**): + +- **Q12-1: B**, the bearing is taken at Commit. +- **Q12-2: A**, spread is width. +- **Q12-3: A**, the lane remainder goes into the hull where the lane ends (direct hits). +- **Q12-4: A**, `ShotOutcome.Aimed` is deleted. +- **Q12-6: no.** Operator: "12-6, nope". No catalog test ties a label to behaviour. + `WeaponModifiers` stays authored metadata that nothing checks and nothing reads in the + simulation, and a weapon whose label disagrees with its data is authoring's business. +- **Q12-7: dissolved, not answered.** It asked where a blast ray's remainder goes. With + blasts as areas there are no rays and no remainder to route, so the question has no + subject. The ray model's shadowing goes with it (see **Accepted loss** in 12.4). +- **Q12-8: A, and it is scoped to weapons that carry a blast.** Nothing is absorbed along + an *explosive penetrator's* burrow: it reaches its fuse depth without spending damage, + and the explosion is the payload. Penetration is a reach decision there — how deep the + fuse point can sit — not a damage budget. + + The first wording ("the burrow is travel, not damage") read as if it covered every + penetrating shot. It does not. Operator, 2026-09-22: "Q12-8 is odd. Like, penetration is + still a thing even if it's not a warhead. For a dumb AP round, you'd expect it to do + damage in a line, not dump all its damage at max depth, that would make AP rounds even + better at component sniping than AP explosives". Agreed, and the map never meant + otherwise: a weapon with no blast radius takes 12.3's lane, absorbed cell by cell in order + with the remainder carrying on, and Q12-8 does not reach it. See **Where 12.3 and 12.4 + meet**. + + So "armour absorbs first" holds everywhere it was ruled: down the lane for a direct hit, + and per covered cell for a blast. Against an explosive penetrator, armour protects by the + reach gate and by absorbing its own cell's share of the disc. The consequence to watch + when F12-2 tunes penetration is that an explosive penetrator with reach pays nothing for + the plate it crosses, while an AP round pays for every plate it passes. + +Open: + +**Q12-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. + +**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a +blast that needs a hit?** + +The current map lets two places decide it: + +- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. +- `Detonate` then decides every shield in the radius live, and that includes the host. + +**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so +the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can +absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split +this campaign has cut three times. + +- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield + for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the + radius, live at detonation. + - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no + direction", applied to shields. + - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that + reaches the hull. For a contact detonation on the hull's face, that is roughly half. + - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads + them. +- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole + and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" + parameter: a compensator that keeps two owners alive, one per entity. + +What depends on it: + +- the gate on Commit's shield block; +- whether `Detonate` takes a host parameter; +- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. + +Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half +of (b) can proceed while this waits. + +**Defaults taken, not rulings.** The operator may overrule any of these: + +- A blast shot ignores `DamageSpread`, and P sits on the centre lane. +- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. +- The delayed fuse point is clamped to the last reached cell's exit. +- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. + +- **Q12-5 (not ruled). Should the HUD show which target items are exposed from the current + bearing?** + - **Recommended:** a follow-up after 12.3. It would be a presentation read of `Silhouette` + and `Lane`, the way Inspect is: for each revealed item, is it first in some lane of the + current shadow. It belongs on the target-item cycling UI. + +### Follow-ups recorded, not in this cut + +- **F12-2. Penetration retune** (operator: "Retuning penetration is a follow up."). + - Shipped values are ≤ 0.25 against an inspector range of 0..1, and enclosed cockpits + need about 2 cells. + - The same pass decides which weapons get fuses and blast radii (none have one today), + and writes the catalog once to clear the slot-29/30 drift warnings. + - Until it lands, interior cells are reached only by blasts. +- **F12-3. SigmaFloor's purpose changed.** It was a numerical guard and is now a design + minimum on group tightness. Revisit it when Precision is next tuned. +- **F12-4. Double charging.** 6d's note that pSpread and pOnHull both price target size + still stands. Both now read one Span and shadow, so a later merge is mechanical. +- **F12-5. AI and proximity blasts.** `Combat.cs:117` gates a proximity weapon on a + direct-hit forecast that does not decide its damage. This is pre-existing. +- **F12-6. Mine's blast inputs.** The prefab `BlastRange` and the arming OverlapSphere + (`Mine.cs:69-78`, `:99`) are presentation-side (Cut 4 Q7). Mine has no catalog weapon + record carrying `Fuse`/`BlastRadius`. It can move when it ships (R9). +- **F12-7. Blast falloff toward the rim.** Every cell takes its area share flat, so a cell + at the rim is hurt as hard per unit of area as the one under the fuse. A radial falloff is + a tuning decision for the same pass that authors radii, and it changes only the weight + inside the integral, not the owner. +- **F12-8. Blasts do not shadow.** Nothing inside an area is occluded, so a bulkhead + protects only its own cell, and two ships caught by one blast do not shade each other. + This is the accepted cost of areas over rays. Reopen it only if play shows interiors are + too soft. +- **Behaviors.CauseDamage** (`Behaviors/Behaviors.cs:62-73`) writes durability and + `HullDamage` without `Absorb`. It is self-damage, not a hit, and is left alone. +- **Orphaned `Airburst*` YAML** in nine weapon prefabs. It is harmless, and Unity drops it + on the next save of each prefab. + +### Subtraction ledger (estimates, with actuals) + +| Sub-cut | Removed | Added | Notes | +|---|---|---|---| +| 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | +| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | +| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | +| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | +| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | +| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | + +Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: + +- 12.1: `b851b0d1^..b851b0d1`; +- 12.2: `b851b0d1..c7b6e80c`; +- 12.3: `c7b6e80c..d402283c`. + +The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: + +- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; +- 12.3: sequential, proportional, order-free absorption; +- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, + `Absorb` and `Step`'s branch. + +12.4's own delta is ~+60, and that is the estimate to hold it to. + +**Section history:** Cut 6d's "The rule" (the 2D kernel) and its premise that pOnHull folds +into PBase at Fire, 9.2's SigmaFloor rationale, and Cut 4's Splash rule (`FireControl.cs:946-959`) +describe models this cut replaces. Self should mark them as history in the status header +as 12.2 and 12.4 land. From 8c00263740fa371f773daf7cc0d5e06b679ca5f0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 12:28:10 +0200 Subject: [PATCH 077/116] A fused shot with no target bursts at max range along the aim line Operator ruling 2026-09-30: fused weapons without a lock explode at max range. Fire froze BurstPosition at the shooter's own position with flight time 0, so a proximity shot detonated on its own shooter and contact and delayed shots did nothing. With no target and a fuse, Fire now freezes the burst point as the mount direction times the weapon's Range stat from the source, and flight time as Range over Velocity. Apply sends every such shot through Detonate at that point whatever its fuse. Targeted shots and unfused shots are untouched. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/FireControl.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 8e8cc53c4..f3fcf6b8c 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -389,6 +389,15 @@ public static class FireControl var blastRadius = weaponItemData?.BlastRadius > 0f ? weaponItemData.BlastRadius.Value : 0f; var fuse = blastRadius > 0f ? weaponItemData.Fuse : null; var burstPosition = blastRadius > 0f && target != null ? PredictedIntercept(weapon, source, target) : targetPosition; + // Operator ruling 2026-09-30 ("fused weapons without a lock explode at max range"): a fused shot with no + // target bursts where its own aim line ends -- the mount direction at the weapon's Range stat, the same + // stat PFire's range gate reads -- and flies there at the weapon's Velocity, like a targeted shot flies + // to its intercept. Only the burst point and flight time differ; Apply detonates it through Detonate. + if (fuse != null && target == null) + { + burstPosition = source.Position + MountDirection(item) * weapon.Range; + flightTime = weapon.Velocity > .01f ? weapon.Range / weapon.Velocity : 0f; + } var shot = new PendingShot { @@ -650,6 +659,14 @@ public static class FireControl // centre lane (see the fuse-point comment on the Contact/Delayed branch below). private static void Apply(PendingShot shot) { + // A fused shot with no target has nothing to hit or miss: whatever its fuse, it bursts at the point Fire + // froze (max range along the aim line). Detonate is still the one blast path. + if (shot.Fuse != null && shot.Target == null) + { + Detonate(shot.Source.Zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); + return; + } + switch (shot.Fuse) { case null: From 3e8ee022767f62a3ccfc14f9d9003a62e5686a90 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 12:28:11 +0200 Subject: [PATCH 078/116] Pin the no-lock fused shot: burst at max range, shooter unharmed Proximity, contact and delayed shots fired with no target burst at Range along a non-axis aim (2,1) and leave the shooter undamaged; the shot flies there at the weapon's Velocity. A targeted proximity shot keeps the burst point and the damage value measured at b66ba524 (5.65884256). Co-Authored-By: Claude Sonnet 5 --- .../FireControlCut124Tests.cs | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index f3225336e..3f5e28e97 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -91,7 +91,7 @@ public sealed class FireControlCut124Tests : IDisposable bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true, int2? cockpitCell = null, float cockpitDurability = 1000f, (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null, - float accuracy = 1f, int2? gunCell = null) + float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f) { var hullData = new HullData { @@ -116,7 +116,7 @@ public sealed class FireControlCut124Tests : IDisposable Fuse = fuse, BlastRadius = blastRadius, WeaponModifiers = modifiers, Behaviors = { new InstantWeaponData { - Damage = Constant(damage), Range = Constant(100000), MinRange = Constant(0), + Damage = Constant(damage), Range = Constant(weaponRange), MinRange = Constant(0), Velocity = Constant(velocity), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } @@ -1132,6 +1132,84 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(before, e.Target.Hull.Durability); } + // Operator ruling 2026-09-30: "fused weapons without a lock explode at max range." A fused shot with no + // target bursts at the weapon's Range along its aim (the mount direction), whatever its fuse. The fixture + // aims the shooter along (2,1), a non-axis facing; witnesses sit at the burst point and beside it. Radius 4 + // with the witness hull 5x4 cells of 2 world units: a witness centred on the burst point is covered, and + // one 30 units off the aim line is not. + private const float NoLockRange = 60f; + + private static float3 AimLine(Engagement e) + { + var aim = normalize(float2(2, 1)); + return e.Shooter.Position + float3(aim.x, 0, aim.y) * NoLockRange; + } + + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AFusedShotWithNoTargetBurstsAtMaxRangeAlongTheAimLine(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Direction = normalize(float2(2, 1)); + e.Shooter.Target.Value = null; + var burst = AimLine(e); + var onLine = AddShip(e, burst, 1f, 300); + var offLine = AddShip(e, burst + float3(-30, 0, 30), 1f, 302); + var shooterHit = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => shooterHit += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => shooterHit += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => shooterHit += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(0f, shooterHit); + Assert.Empty(e.Zone.PendingShots); + Assert.False(e.Zone.Entities.Contains(onLine), "the blast must reach the burst point (fixture: 1 durability, so any damage kills it)"); + Assert.True(e.Zone.Entities.Contains(offLine), "the blast must not reach 42 units off the aim line"); + } + + // The no-lock shot flies to max range at the weapon's Velocity, like a targeted shot flies to its intercept. + [Fact] + public void ANoLockFusedShotFliesToMaxRangeAtItsVelocity() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Direction = normalize(float2(2, 1)); + e.Shooter.Target.Value = null; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = Assert.Single(e.Zone.PendingShots); + Assert.Equal(NoLockRange / 20f, shot.ArrivalTime - shot.FireTime, 4); + var burst = AimLine(e); + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + } + + // A targeted shot is unchanged. Expected values computed at the base commit (b66ba524, before the no-lock + // rule), not read back from this code: the target's total damage from a proximity blast at the intercept. + [Fact] + public void ATargetedFusedShotIsUnchangedByTheNoLockRule() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = Assert.Single(e.Zone.PendingShots); + Assert.Equal(e.Target.Position.x, shot.BurstPosition.x, 3); + Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.Equal(5.65884256f, damage, 3); + } + // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. From 187caa7d81efe1f3aa77cfca21e2913a6600f5ba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:11:18 +0200 Subject: [PATCH 079/116] Record the no-lock fuse rulings and their Soul follow-ups (2026-09-30) Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 915c755dd..5028487ea 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2622,9 +2622,26 @@ Pins landed at `4629bc67` and `c7e20f08`. `Death` fires once per entity (`Entity which also fixed a direct hit on a corpse raising `Death` again. A three-kill blast test was added. 362 tests pass. -**Operator ruling needed:** a fused weapon fired with no target bursts at the shooter's own -position (`FireControl.cs:391`). This predates 12.4. Self recommends a max-range burst along the -aim line. Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship +**Ruled (operator, 2026-09-30): "fused weapons without a lock explode at max range."** This is no longer +open. Landed at `8c002637` (fix) and `3e8ee022` (tests), and Soul confirmed it. That Soul pass raised four +follow-up rulings, all given on 2026-09-30: +- **The aim is `LookDirection`, restricted by the mount's arc.** The operator notes this was already the case; + the fix had used the hull-fixed mount. +- **An invalid target counts as no lock.** A target that is selected but out of range, out of arc, not + visible, or not locked takes the no-lock path. Nothing bursts beyond max range. +- **A no-lock contact fuse stops on the first hull on its aim line**, computed at commit. Mining adds chunks + to this through a named seam. +- **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go + nowhere. + +Soul also found two more defects: +- A fused weapon with `Range <= BlastRadius` bursts on its shooter. The fix is to refuse it when the catalog + opens. +- A burst without a target reads as MISS in the debug HUD. + +Fix batch 2 covers all six, in Hands on `hands/fuse-fix2`. The stale lines about "`BurstPosition` is read +only for a proximity fuse" and the Apply rules below it are superseded by these rulings. +Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship still takes blast damage. Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path From e3c9cbb2821ce37b00b7e07ed734548b7b2e5bf4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:27:42 +0200 Subject: [PATCH 080/116] Refuse a fused weapon whose Range does not exceed its BlastRadius Soul: a no-lock round bursts at Range, so Range <= BlastRadius (or a zero Range) lands the blast on the shooter. Made structurally impossible at the catalog rather than policed at runtime: StatValidation.ValidateFuse joins the checks CultRecordRefs.Validate runs on every Upsert, at the weakest value the Range stat can take. Only a weapon that will detonate (a Fuse and a BlastRadius above zero) is held to it. AetheriaStores.Open now calls that same Validate for every equippable and consumable document instead of carrying its own copy of the three checks, so Upsert and Open can no longer disagree. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 21 ++------ Assets/Scripts/ServerShared/ItemData.cs | 15 ++++++ .../AetheriaStoresTests.cs | 54 +++++++++++++++++++ 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index cd2589dc0..b16a33af8 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -29,22 +29,10 @@ public static class AetheriaStores cache.AllStoredDocuments.All(stored => stored.Descriptor != descriptor)); if (missing != null) throw new InvalidOperationException($"Catalog {catalogPath} has no {missing.SchemaName} record; catalog globals are authored, never invented."); - // R-heat (docs/stats-and-power-cut.md): every equippable design's heat response must describe a - // coherent range before anything reads it. Fails loudly, naming the item. - foreach (var data in cache.GetAll()) - { - StatValidation.ValidateHeatResponse(data); - StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - // Cut 7 (docs/stats-and-power-cut.md): a stat naming a role its design lacks is refused. - StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); - } - // Cut 2 (docs/stats-and-power-cut.md): a consumable's own StatModifierData needs the same check; - // consumables carry no heat response, so this is not folded into the loop above. - foreach (var data in cache.GetAll()) - { - StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); - } + // Every catalog document is held to the one check Upsert applies on write: R-heat (docs/stats-and-power- + // cut.md), stat modifiers, role usage (Cut 7), and the fuse range rule. Fails loudly, naming the item. + foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); + foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); return cache; } catch @@ -85,6 +73,7 @@ public static class CultRecordRefs StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); + StatValidation.ValidateFuse(data); } if (document is ConsumableItemData consumable) { diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 59d93c1ba..40e2beb83 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -752,6 +752,21 @@ public static class StatValidation "not exceed them"); } + // Operator ruling 2026-09-30 (Soul): a round with a blast that could burst on its own shooter is refused at + // the catalog rather than policed at runtime. A weapon that detonates (a fuse and a blast radius above zero, + // the two FireControl.Fire freezes together) must reach farther than its blast, at the weakest value its Range + // stat can take: a no-lock round bursts at Range, so Range <= BlastRadius would put the shooter inside the + // burst, and Range <= 0 is a burst on the muzzle. A fuse without a radius is inert and needs no range. + public static void ValidateFuse(EquippableItemData data) + { + if (!(data is WeaponItemData weapon) || weapon.Fuse == null || !(weapon.BlastRadius > 0f)) return; + foreach (var behavior in weapon.Behaviors) + if (behavior is WeaponData weaponData && weaponData.Range.Min <= weapon.BlastRadius.Value) + throw new InvalidOperationException( + $"{weapon.Name}: fused weapon's Range {weaponData.Range.Min} does not exceed its BlastRadius " + + $"{weapon.BlastRadius.Value} -- a burst at max range would land on the shooter"); + } + // Cut 2 (docs/stats-and-power-cut.md): a StatReference names its target by (type name, field name) so a // modifier can point at any design or behaviour without a hard type reference -- but before this cut an // unresolvable reference failed silently: StatModifier.Initialize left `_stats` null and the first diff --git a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs index e0fec193d..e23953192 100644 --- a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs +++ b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs @@ -190,6 +190,60 @@ public sealed class AetheriaStoresTests : IDisposable Assert.Null(record); } + // Operator ruling 2026-09-30 (Soul): a weapon that detonates must reach farther than its blast, at the + // weakest value its Range stat can take, or a max-range burst lands on the shooter. Refused where every + // catalog write and every open already checks, not policed at runtime. + private static WeaponItemData FusedWeapon(string name, float rangeMin, float rangeMax, float? blastRadius, WeaponFuse? fuse) => new WeaponItemData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blastRadius, + Behaviors = { new InstantWeaponData { Range = new PerformanceStat { Min = rangeMin, Max = rangeMax } } } + }; + + // Kills: a comparison that admits equality (Range == BlastRadius bursts on the shooter's own edge); reading + // Range.Max instead of Min (the second row: Max 100 clears the blast, the weakest value does not); a + // zero range with a positive blast. + [Theory] + [InlineData(3f, 3f, 3f)] + [InlineData(2f, 2f, 3f)] + [InlineData(2f, 100f, 3f)] + [InlineData(0f, 0f, .5f)] + public void ValidateRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast(float rangeMin, float rangeMax, float radius) + { + var error = Assert.Throws(() => + CultRecordRefs.Validate(FusedWeapon("ShortFuse", rangeMin, rangeMax, radius, WeaponFuse.Proximity))); + Assert.Contains("ShortFuse", error.Message); + } + + // A fuse without a radius, a radius without a fuse, and a range that clears the blast are all fine: only a + // weapon that will actually detonate is held to the rule. + [Theory] + [InlineData(1f, 1f, null, WeaponFuse.Contact)] + [InlineData(1f, 1f, 0f, WeaponFuse.Contact)] + [InlineData(1f, 1f, 30f, null)] + [InlineData(60f, 60f, 4f, WeaponFuse.Delayed)] + public void ValidateAcceptsAWeaponThatCannotBurstOnItsShooter(float rangeMin, float rangeMax, float? radius, WeaponFuse? fuse) + { + var record = Record.Exception(() => CultRecordRefs.Validate(FusedWeapon("Fine", rangeMin, rangeMax, radius, fuse))); + Assert.Null(record); + } + + // Open holds a catalog already on disk to the same rule Upsert applies on write. The bad record is written + // past Upsert, straight through the cache, the way a hand-edited or migrated catalog would carry one. + [Fact] + public void OpenRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast() + { + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.UpsertAsync(FusedWeapon("OnDisk", 2f, 2f, 3f, WeaponFuse.Contact)).Wait(); + cache.FlushAsync().Wait(); + } + + var error = Assert.Throws(() => AetheriaStores.Open(Catalog)); + Assert.Contains("OnDisk", error.Message); + } + // A document type Validate has no opinion about (neither EquippableItemData nor ConsumableItemData) must be // a pure no-op, not a crash -- the three tools' change lists carry `object`, and not every migration touches // an equippable design (e.g. a future repair over FactionProductData). From f5c6729e7a4cde321c4ac865883aac0a0c29a0e9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:27:43 +0200 Subject: [PATCH 081/116] No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 11 +- Assets/Scripts/ServerShared/FireControl.cs | 262 +++++++++++++---- .../FireControlCut124Tests.cs | 278 +++++++++++++++++- 3 files changed, 483 insertions(+), 68 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 6d3c0491c..bc9bce8a2 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1282,6 +1282,13 @@ public class ActionGameManager : MonoBehaviour } } + private static string ResultLabel(ShotResult result) => result switch + { + ShotResult.Hit => "HIT", + ShotResult.Burst => "BURST", + _ => "MISS" + }; + private void UpdateFireControlDebug(Entity target) { if (DebugInfoText == null) return; @@ -1310,7 +1317,7 @@ public class ActionGameManager : MonoBehaviour var shot = CurrentEntity.Zone.PendingShots[i]; if (shot.Source != CurrentEntity || shot.Weapon != selectedItem) continue; if (shot.Committed) - pendingLine = $"shot {shot.ShotId}: committed {(shot.Outcome.Hit ? "HIT" : "MISS")}"; + pendingLine = $"shot {shot.ShotId}: committed {ResultLabel(shot.Outcome.Result)}"; else { var pDeviation = FireControl.DeviationProbability(shot, CurrentEntity.Zone.Time, out var deviation); @@ -1324,7 +1331,7 @@ public class ActionGameManager : MonoBehaviour } var lastLine = _debugLastShots.TryGetValue(selectedItem, out var lastShot) - ? $"last {lastShot.ShotId}: {(lastShot.Hit ? "HIT" : "MISS")} cell {lastShot.Cell.x},{lastShot.Cell.y}" + ? $"last {lastShot.ShotId}: {ResultLabel(lastShot.Result)} cell {lastShot.Cell.x},{lastShot.Cell.y}" : "last: none"; var gates = target == null ? "target: none" diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index f3fcf6b8c..3d08172d8 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -57,6 +57,26 @@ public static class FireControl return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f)); } + // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming + // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The + // arc is InArc's own cone: an aim inside it is used as is, one outside it is turned to the nearest edge of + // the same cone. There is no second bearing test here; the edge is where InArc stops passing. A shooter + // with no aim set (LookDirection zero) aims down the mount, the same fallback InArc takes for a zero bearing. + // The simulation had no aim clamp before this: the Unity barrel's ArticulationPoint clamps the picture, + // and a target outside the arc merely priced the shot at zero. + public static float2 AimDirection(EquippedItem item, Entity shooter) + { + var mount = MountDirection(item).xz; + var look = shooter.LookDirection.xz; + if (lengthsq(look) < 1e-6f) return mount; + look = normalize(look); + if (InArc(item, float3(look.x, 0, look.y))) return look; + + var half = radians(ArcFor(item) / 2f); + var turn = mount.x * look.y - mount.y * look.x >= 0f ? half : -half; + return float2(mount.x * cos(turn) - mount.y * sin(turn), mount.x * sin(turn) + mount.y * cos(turn)); + } + // Cut 2, R5: whether `observer` has resolved `item` (which must belong to some entity in the zone) well // enough to aim at it. Derived fresh from EntityInfoGathered on every call -- nothing caches a reveal // result, so decay drops it the moment info falls back below tier, with no clearing loop anywhere. @@ -152,15 +172,23 @@ public static class FireControl // freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette // enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again, // live, by Commit at the commit tick. 0 the moment any gate closes. - private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _); + private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _); // The `out range` overload is HitProbability's own gate call -- one computation of `target.Position - // source.Position`, not two. HitProbability used to recompute its own copy for PSpread, which is exactly // the kind of split authority Cut 3 named as the risk (two places computing the same vector, free to // drift or to have their subtraction order flipped in only one of them). - private static float PFire(Weapon weapon, Entity source, Entity target, out float range) + private static float PFire(Weapon weapon, Entity source, Entity target, out float range) => + PFire(weapon, source, target, out range, out _); + + // `bears` is the one answer to "is this target one the weapon can fire at": in range, visible, locked (for a + // LockWeapon) and in arc. PFire prices a shot at zero when it is false; Fire reads it to decide that a + // fused round has no target at all (operator ruling 2026-09-30: an invalid target counts as no lock). + // Inspect recomputes the four gates one by one, to show the HUD which is closed; it decides nothing. + private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool bears) { range = 0f; + bears = false; if (target == null) return 0f; // F8 correction (Soul's fix batch, 2026-09-24): range is computed for any non-null target, before any @@ -174,6 +202,7 @@ public static class FireControl if (range < weapon.MinRange || range > weapon.Range) return 0f; if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; if (!InArc(weapon.Item, toTarget)) return 0f; + bears = true; var settings = source.ItemManager.GameplaySettings; var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; @@ -364,20 +393,7 @@ public static class FireControl // F8 (Soul's fix batch, 2026-09-24): the `out range` overload is the one range HitProbability now // reads too -- Fire used to recompute its own second copy of `target.Position - source.Position` for // fireRange, right beside the copy PFire's gate already computed. - var pFire = PFire(weapon, source, target, out var fireRange); - var travelDirection = target != null ? TravelDirection(weapon, source, target) : float2(0, 1); - - var targetVelocity = float3.zero; - var targetPosition = source.Position; - var flightTime = 0f; - if (target != null) - { - targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); - targetPosition = target.Position; - flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f; - } - - var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; + var pFire = PFire(weapon, source, target, out var fireRange, out var bears); // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided @@ -388,14 +404,33 @@ public static class FireControl var weaponItemData = item.Data as WeaponItemData; var blastRadius = weaponItemData?.BlastRadius > 0f ? weaponItemData.BlastRadius.Value : 0f; var fuse = blastRadius > 0f ? weaponItemData.Fuse : null; - var burstPosition = blastRadius > 0f && target != null ? PredictedIntercept(weapon, source, target) : targetPosition; - // Operator ruling 2026-09-30 ("fused weapons without a lock explode at max range"): a fused shot with no - // target bursts where its own aim line ends -- the mount direction at the weapon's Range stat, the same - // stat PFire's range gate reads -- and flies there at the weapon's Velocity, like a targeted shot flies - // to its intercept. Only the burst point and flight time differ; Apply detonates it through Detonate. - if (fuse != null && target == null) + + // Operator rulings 2026-09-30: an invalid target counts as no lock -- a fused round whose selected + // target is out of range, out of arc, not visible or not locked (PFire's `bears`) is fired at nothing. + // A fused shot with no target bursts where its own aim line ends: the shooter's aim (AimDirection) at + // the weapon's Range stat, the same stat PFire's range gate reads, flown at the weapon's Velocity like + // a targeted shot flies to its intercept. The round carries no target from here on, so nothing at + // resolution asks whether one is still around. A shot without a fuse keeps its target as before. + var engaged = fuse != null && !bears ? null : target; + var origin = source.Position; + var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); + + var targetVelocity = float3.zero; + var targetPosition = origin; + var flightTime = 0f; + if (engaged != null) { - burstPosition = source.Position + MountDirection(item) * weapon.Range; + targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y); + targetPosition = engaged.Position; + flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f; + } + + var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; + + var burstPosition = blastRadius > 0f && engaged != null ? PredictedIntercept(weapon, source, engaged) : targetPosition; + if (fuse != null && engaged == null) + { + burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * weapon.Range; flightTime = weapon.Velocity > .01f ? weapon.Range / weapon.Velocity : 0f; } @@ -403,7 +438,7 @@ public static class FireControl { ShotId = zone.NextShotId(), Source = source, - Target = target, + Target = engaged, Weapon = item, Aimed = source.ResolvedTargetItem, Damage = damageOverride ?? weapon.Damage, @@ -417,10 +452,14 @@ public static class FireControl Spread = weapon.Spread, FireRange = fireRange, FireTime = now, + FireOrigin = origin, FireTargetPosition = targetPosition, FireTargetVelocity = targetVelocity, ArrivalTime = now + flightTime, - CommitTime = now + max(0f, flightTime - commitHorizon), + // The commit horizon exists so the target's facing can be read late (Bearing timing). A round fired at + // nothing has no facing to read, so it commits at Fire: its burst point is decided from the poses of + // the moment it leaves, and a Contact round short of max range can arrive as early as it truly does. + CommitTime = fuse != null && engaged == null ? now : now + max(0f, flightTime - commitHorizon), BurstPosition = burstPosition, Fuse = fuse, BlastRadius = blastRadius, @@ -432,8 +471,11 @@ public static class FireControl } // Cut 3, R4: ages every shot in the zone, commits the ones that have reached their horizon and resolves - // the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose source or target - // has left the zone resolves as a miss and is removed outright (0b table), whichever stage it is at. + // the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose target has left + // the zone resolves as a miss and is removed outright (0b table), whichever stage it is at. A round outlives + // its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked + // for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot. + // Resolution reads the zone and the target; there is no credit or loot for a shooter to receive. public static void Step(Zone zone, float dt) { var shots = zone.PendingShots; @@ -444,16 +486,15 @@ public static class FireControl { var shot = shots[i]; - var sourceGone = !zone.Entities.Contains(shot.Source); var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target); - if (sourceGone || targetGone) + if (targetGone) { - // Cut 11 (Soul C4): a shot whose source or target has left the zone resolves as a miss whichever + // Cut 11 (Soul C4): a shot whose target has left the zone resolves as a miss whichever // stage it is at (0b table). The resolution is published as a fresh miss rather than by // rewriting shot.Outcome: a committed outcome is immutable (R4) and stays a true record of what // the commit decided, while ShotResolved reports what actually happened -- nothing, because the // target is gone. Republishing the committed outcome here used to put a hit marker on a corpse. - var miss = MakeOutcome(shot, false, false, false, int2.zero, float2.zero, 0f, now); + var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); if (!shot.Committed) zone.ShotCommitted.OnNext(miss); zone.ShotResolved.OnNext(miss); shots.RemoveAt(i); @@ -464,6 +505,9 @@ public static class FireControl { shot.Outcome = Commit(zone, shot, now); shot.Committed = true; + // A contact burst short of max range arrives when the round has flown that far, not at the frozen + // max-range time (CommitBurst). Never later than the frozen time. + if (shot.Outcome.Result == ShotResult.Burst) shot.ArrivalTime = min(shot.ArrivalTime, now + shot.Outcome.ArrivalIn); zone.ShotCommitted.OnNext(shot.Outcome); shots[i] = shot; } @@ -474,7 +518,7 @@ public static class FireControl // 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. - Apply(shot); + Apply(zone, shot); zone.ShotResolved.OnNext(shot.Outcome); shots.RemoveAt(i); } @@ -522,14 +566,18 @@ public static class FireControl if (p <= 0f) return 0f; var bearing = Bearing(shot.Target, shot.TravelDirection); - var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData; + var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision); - var settings = shot.Source.ItemManager.GameplaySettings; + var settings = shot.Target.ItemManager.GameplaySettings; return p * PSpread(shot.Spread, sil.Span, shot.FireRange, settings.SchematicCellSize) * sil.POnHull; } private static ShotOutcome Commit(Zone zone, PendingShot shot, float now) { + // A fused round with no target rolls nothing: it bursts. Where is decided here, at the commit, from the + // poses of this tick (R4); Apply performs it. + if (shot.Fuse != null && shot.Target == null) return CommitBurst(zone, shot, now); + // Cut 6b, 6.1 (Soul finding 6): a shot's dice belong to the shot, not to whatever else happened to // draw from the engine-wide shared generator first. A pure function of (zone identity, shot id) -- // the `| 1u` guards @@ -567,7 +615,7 @@ public static class FireControl bearing = Bearing(shot.Target, shot.TravelDirection); lateral = LateralDraw(sil, random.NextFloat()); - var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData; + var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); try { @@ -601,7 +649,70 @@ public static class FireControl } } - return MakeOutcome(shot, hit, shielded, shieldBroken, cell, bearing, lateral, now); + return MakeOutcome(shot, hit ? ShotResult.Hit : ShotResult.Miss, shielded, shieldBroken, cell, bearing, lateral, now); + } + + // Operator rulings 2026-09-30. A fused round with no target bursts at the max-range point Fire froze along + // the aim line, whatever its fuse -- except a Contact round, which bursts at the first hull that line crosses + // before that point (a Proximity or Delayed fuse does not trip on a hull with nothing locked). The point is + // read from this tick's poses and frozen into the outcome (R4, the same timing Commit reads facing at). + // Nothing is rolled or drawn, so determinism needs nothing beyond the frozen inputs. + private static ShotOutcome CommitBurst(Zone zone, PendingShot shot, float now) + { + var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now); + var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); + var travelled = shot.Fuse == WeaponFuse.Contact ? ContactDistance(zone, shot, reach) : reach; + if (travelled < reach) + { + outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; + // The round arrives once it has flown that far, which is sooner than the frozen max-range arrival. + outcome.ArrivalIn = max(0f, shot.FireTime + (shot.ArrivalTime - shot.FireTime) * travelled / reach - now); + } + else outcome.BurstPoint = shot.BurstPosition.xz; + return outcome; + } + + // The distance along a no-lock round's aim line to the first thing a contact fuse trips on, or `reach` (the + // max-range point) when it meets nothing first. The shooter's own hull is never a candidate. + // SEAM FOR THE MINING CAMPAIGN: mining chunks are not Entities and are not on this branch. When they land, + // whatever they are contributes its own entry distance to this minimum, and nothing else in the file changes. + private static float ContactDistance(Zone zone, PendingShot shot, float reach) + { + var nearest = reach; + foreach (var entity in zone.Entities) + { + if (entity == shot.Source) continue; + nearest = min(nearest, HullEntryDistance(entity, shot.FireOrigin.xz, shot.TravelDirection)); + } + return nearest; + } + + // World distance from `origin` along the unit planar `direction` to the first metal cell of `entity`'s hull, + // or +infinity when the line meets none ahead of it. Read through Lane's own cell admission (CellsOnLine), in + // the entity's schematic frame: the line's lateral offset is its own, and cells wholly behind the origin + // are skipped. An origin already inside the hull is a distance of zero. + private static float HullEntryDistance(Entity entity, float2 origin, float2 direction) + { + var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; + var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; + var start = entity.ToSchematicPoint(origin); + var bearing = Bearing(entity, direction); + var lateral = dot(start, Lateral(bearing)); + var along = dot(start, bearing); + + var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + try + { + var count = CellsOnLine(hullData, bearing, lateral, buffer); + for (var i = 0; i < count; i++) + if (buffer[i].Exit > along) + return max(buffer[i].Entry - along, 0f) * cellSize; + return float.PositiveInfinity; + } + finally + { + ArrayPool.Shared.Return(buffer); + } } // R4: the commit is authoritative and immutable from here on -- this only performs what Commit already @@ -657,13 +768,13 @@ public static class FireControl // contact or delayed fuse detonates only on a committed hit, at a point 12.3's own Lane/Reach machinery // locates. This is a default, not a ruling: a blast shot's DamageSpread is not read, and P sits on the // centre lane (see the fuse-point comment on the Contact/Delayed branch below). - private static void Apply(PendingShot shot) + private static void Apply(Zone zone, PendingShot shot) { - // A fused shot with no target has nothing to hit or miss: whatever its fuse, it bursts at the point Fire - // froze (max range along the aim line). Detonate is still the one blast path. - if (shot.Fuse != null && shot.Target == null) + // A fused shot with no target has nothing to hit or miss: its committed outcome is a Burst, and it + // detonates at the point Commit decided (Burst outcome's BurstPoint). Detonate is still the one blast path. + if (shot.Outcome.Result == ShotResult.Burst) { - Detonate(shot.Source.Zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); + Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType); return; } @@ -675,7 +786,7 @@ public static class FireControl case WeaponFuse.Proximity: // Detonates at shot.BurstPosition.xz, which PredictedIntercept froze at Fire (today's airburst // rule, unchanged) -- no roll, and no gate on shot.Outcome.Hit. - Detonate(shot.Source.Zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); + Detonate(zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); return; case WeaponFuse.Contact: case WeaponFuse.Delayed: @@ -1042,7 +1153,7 @@ public static class FireControl return walked; } - private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) + private static ShotOutcome MakeOutcome(PendingShot shot, ShotResult result, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) { return new ShotOutcome { @@ -1050,7 +1161,7 @@ public static class FireControl Source = shot.Source, Target = shot.Target, Weapon = shot.Weapon, - Hit = hit, + Result = result, Shielded = shielded, ShieldBroken = shieldBroken, Cell = cell, @@ -1381,24 +1492,10 @@ public static class FireControl return sil.A; // sil.Count == 0: no metal in the shadow at all -- unreachable while POnHull > 0 gated Commit's roll } - // Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): exact slab traversal along the - // bearing at a fixed lateral offset s, replacing the old 0.5-step sampling march. Collects the occupied - // cells whose shadow interval contains s, each with its own entry/exit parameter along b, ordered by - // entry (ties broken by dot(cell, b), then cell index for full determinism), then walks forward while - // consecutive cells are contiguous (`next.entry <= current.exit + 1e-4`) -- the first gap ends the walk, - // the same rule Entity.cs's old march kept. A direct hit starts outside the hull (t -> -infinity), so the - // walk's first element is the impact cell. Only that first element is read in 12.2; the rest is 12.3's - // armour-first absorption march. Writes into the caller-supplied pooled buffer and returns the walked - // count; never allocates on its own. - // Cut 12.2 fix batch (S1, Soul's second pass): admission is now decided ONCE, by the exact same shadow - // interval Silhouette sums over (Extent(c, ell), the shared function above) -- not by a re-derivation and - // not by a second, independent geometric test. The old code ran two tests: this shadow prefilter (a - // "re-derivation" of Silhouette's own centre/h, inline) AND SlabAlongB, an independent 2-axis ray-box - // intersection that could reject a cell the shadow test had just admitted, purely on float rounding at an - // interval edge -- an empty lane for a shot that had already passed its roll (R3 violation, the crash Soul - // reproduced through Zone.Update). Deleting the second gate removes the split authority: a cell the shadow - // sum counted metal for can no longer be un-counted here. - public static int Lane(HullData hull, float2 b, float s, LaneCell[] buffer) + // Every occupied cell the line meets, sorted by entry, in the pooled buffer; returns how many. Lane below + // walks the contiguous run from the first; a caller that needs the whole line (a contact fuse looking for + // the first cell ahead of its origin) reads all of them. One admission test either way. + private static int CellsOnLine(HullData hull, float2 b, float s, LaneCell[] buffer) { var ell = Lateral(b); var coords = hull.Shape.Coordinates; @@ -1420,7 +1517,29 @@ public static class FireControl } Array.Sort(buffer, 0, n); + return n; + } + // Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): exact slab traversal along the + // bearing at a fixed lateral offset s, replacing the old 0.5-step sampling march. Collects the occupied + // cells whose shadow interval contains s, each with its own entry/exit parameter along b, ordered by + // entry (ties broken by dot(cell, b), then cell index for full determinism), then walks forward while + // consecutive cells are contiguous (`next.entry <= current.exit + 1e-4`) -- the first gap ends the walk, + // the same rule Entity.cs's old march kept. A direct hit starts outside the hull (t -> -infinity), so the + // walk's first element is the impact cell. Only that first element is read in 12.2; the rest is 12.3's + // armour-first absorption march. Writes into the caller-supplied pooled buffer and returns the walked + // count; never allocates on its own. + // Cut 12.2 fix batch (S1, Soul's second pass): admission is now decided ONCE, by the exact same shadow + // interval Silhouette sums over (Extent(c, ell), the shared function above) -- not by a re-derivation and + // not by a second, independent geometric test. The old code ran two tests: this shadow prefilter (a + // "re-derivation" of Silhouette's own centre/h, inline) AND SlabAlongB, an independent 2-axis ray-box + // intersection that could reject a cell the shadow test had just admitted, purely on float rounding at an + // interval edge -- an empty lane for a shot that had already passed its roll (R3 violation, the crash Soul + // reproduced through Zone.Update). Deleting the second gate removes the split authority: a cell the shadow + // sum counted metal for can no longer be un-counted here. + public static int Lane(HullData hull, float2 b, float s, LaneCell[] buffer) + { + var n = CellsOnLine(hull, b, s, buffer); var walked = n > 0 ? 1 : 0; for (var i = 1; i < n; i++) { @@ -1515,6 +1634,9 @@ public struct PendingShot public WeaponFuse? Fuse; public float BlastRadius; + // Frozen with the rest of the shooter's decision: where the round left from. A round outlives its shooter, so + // nothing at resolution reads the shooter's position. + public float3 FireOrigin; public float3 FireTargetPosition; public float3 FireTargetVelocity; public float FireTime; @@ -1614,7 +1736,12 @@ public sealed class ShotOutcome public Entity Source; public Entity Target; public EquippedItem Weapon; - public bool Hit; + // What the commit decided, typed: a shot rolled and missed, rolled and hit, or was a fused round with no + // target and so burst without rolling (Burst, at BurstPoint). Hit is derived from it, not stored beside it. + public ShotResult Result; + public bool Hit => Result == ShotResult.Hit; + // World planar point a Burst detonates at, frozen by the commit. Meaningless for any other Result. + public float2 BurstPoint; public bool Shielded; // Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active, // and unable to CanTakeHit this shot is decided broken right here, so Apply performs Break() rather than @@ -1629,3 +1756,12 @@ public sealed class ShotOutcome public float ArrivalIn; public DamageType DamageType; } + +// What a committed shot came to. A Burst is a fused round that had no valid target: it rolled nothing, so it is +// neither a hit nor a miss, and the presentation must not call it one. +public enum ShotResult +{ + Miss, + Hit, + Burst +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 3f5e28e97..d25718137 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1127,13 +1127,14 @@ public sealed class FireControlCut124Tests : IDisposable Assert.NotNull(outcome); Assert.False(outcome.Hit); // fixture: a guaranteed miss + Assert.Equal(ShotResult.Miss, outcome.Result); Assert.Empty(e.Zone.PendingShots); Assert.Equal(0, events); Assert.Equal(before, e.Target.Hull.Durability); } // Operator ruling 2026-09-30: "fused weapons without a lock explode at max range." A fused shot with no - // target bursts at the weapon's Range along its aim (the mount direction), whatever its fuse. The fixture + // target bursts at the weapon's Range along its aim (Entity.LookDirection), whatever its fuse. The fixture // aims the shooter along (2,1), a non-axis facing; witnesses sit at the burst point and beside it. Radius 4 // with the witness hull 5x4 cells of 2 world units: a witness centred on the burst point is covered, and // one 30 units off the aim line is not. @@ -1153,7 +1154,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Direction = normalize(float2(2, 1)); + Aim(e, float2(2, 1)); e.Shooter.Target.Value = null; var burst = AimLine(e); var onLine = AddShip(e, burst, 1f, 300); @@ -1178,7 +1179,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Direction = normalize(float2(2, 1)); + Aim(e, float2(2, 1)); e.Shooter.Target.Value = null; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -1210,6 +1211,276 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(5.65884256f, damage, 3); } + // ==== Operator rulings 2026-09-30 (Soul's pass on the no-lock fix): aim, invalid target, contact, shooter ==== + + private static void Aim(Engagement e, float2 direction) => e.Shooter.LookDirection = float3(direction.x, 0, direction.y); + + private static float3 PointAlong(Engagement e, float2 direction, float distance) => + e.Shooter.Position + float3(direction.x, 0, direction.y) * distance; + + private static bool Alive(Engagement e, Entity entity) => e.Zone.Entities.Contains(entity); + + // Ruling 1: an aim beyond the mount's arc flies along the nearest edge of that arc. The mount is (0,1), the + // arc 170 degrees (edge 85), and the aim (+-1,-1) sits 135 degrees off it; the witness on the edge dies and + // the one under the unclamped aim does not (they are 50 units apart). Kills: no clamp; the turn taken the + // wrong way round (the mirrored edge holds no witness). + [Theory] + [InlineData(1f)] + [InlineData(-1f)] + public void ANoLockShotOutsideTheMountArcFliesAlongTheNearestEdgeOfIt(float side) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(side, -1)); + var edge = float2(side * sin(radians(85f)), cos(radians(85f))); + var onEdge = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 300); + var underAim = AddShip(e, PointAlong(e, normalize(float2(side, -1)), NoLockRange), 1f, 302); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.False(Alive(e, onEdge), "the blast must land on the arc's edge"); + Assert.True(Alive(e, underAim), "the blast must not follow an aim the arc does not allow"); + } + + // Ruling 1: an aim inside the arc is used as it is (unnormalised here), and a shooter aiming at nothing + // (LookDirection zero) fires down its mount. Kills: reading MountDirection instead of the aim; a missing + // zero-aim fallback, which would normalise to NaN and burst nowhere. + [Fact] + public void ANoLockShotFliesAlongTheAimAndDownTheMountWhenNothingIsAimed() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + var mount = float2(0, 1); + var aim = normalize(float2(2, 1)); + var onAim = AddShip(e, PointAlong(e, aim, NoLockRange), 1f, 300); + var onMount = AddShip(e, PointAlong(e, mount, NoLockRange), 1f, 302); + + Aim(e, float2(6, 3)); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.False(Alive(e, onAim), "the blast must follow the aim"); + Assert.True(Alive(e, onMount), "the mount direction is not the aim"); + + Aim(e, float2(0, 0)); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.False(Alive(e, onMount), "with nothing aimed the blast follows the mount"); + } + + public enum Invalid { OutOfRange, NotVisible, OutOfArc } + + // Ruling 2: a selected target that is out of range, not visible or out of arc counts as no lock -- the round + // bursts at max range along the aim, and the target is left alone. The aim is (2,1) except when the target is + // behind the mount, where the mount is flipped and the aim follows it. Kills: reading "no lock" as + // Target == null alone (the round would burst at the target's intercept, 100 / 40 / 100 units out). + // (An unlocked LockWeapon shares the same predicate; the fixture has no LockWeapon.) + [Theory] + [InlineData(Invalid.OutOfRange)] + [InlineData(Invalid.NotVisible)] + [InlineData(Invalid.OutOfArc)] + public void AnInvalidTargetCountsAsNoLock(Invalid reason) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: reason == Invalid.NotVisible ? 40f : 100f); + var aim = normalize(float2(2, 1)); + if (reason == Invalid.NotVisible) e.Shooter.VisibleEntities.Remove(e.Target); + if (reason == Invalid.OutOfArc) + { + e.Shooter.Direction = float2(0, -1); + aim = float2(0, -1); + } + Aim(e, aim); + var burst = PointAlong(e, aim, NoLockRange); + var onAim = AddShip(e, burst, 1f, 300); + var targetBefore = e.Target.Hull.Durability; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = Assert.Single(e.Zone.PendingShots); + Assert.Null(shot.Target); + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.False(Alive(e, onAim), "the blast must land at max range along the aim"); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + } + + // Ruling 3: a Contact round with no lock stops on the first hull its aim line crosses before max range; a + // Proximity or Delayed round keeps the max-range burst. The blocker's near face sits 21 units out (a 5x4-cell + // hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a Contact fuse that ignores + // hulls; a Proximity or Delayed fuse that trips on one. + [Theory] + [InlineData(WeaponFuse.Contact, true)] + [InlineData(WeaponFuse.Proximity, false)] + [InlineData(WeaponFuse.Delayed, false)] + public void OnlyAContactRoundWithNoLockStopsOnTheFirstHullItCrosses(WeaponFuse fuse, bool stopsOnTheBlocker) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 302); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + var expectedReach = stopsOnTheBlocker ? 21f : NoLockRange; + Assert.Equal(e.Shooter.Position.z + expectedReach, outcome.BurstPoint.y, 2); + Assert.Equal(e.Shooter.Position.x, outcome.BurstPoint.x, 2); + Assert.Equal(!stopsOnTheBlocker, Alive(e, blocker)); + Assert.Equal(stopsOnTheBlocker, Alive(e, atMaxRange)); + } + + // Ruling 3: a hull beyond max range is not on the round's way, and the shooter's own hull, which the line + // starts inside, is never a candidate. Here the target (100 out) lies on the line of a 60-range Contact + // round. Kills: a burst point beyond the max-range point; a Contact search that admits the shooter. + [Fact] + public void AContactRoundWithNoLockBurstsAtMaxRangeWhenNoHullIsInReach() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var targetBefore = e.Target.Hull.Durability; + var shooterHit = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => shooterHit += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => shooterHit += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => shooterHit += x.damage); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(e.Shooter.Position.z + NoLockRange, outcome.BurstPoint.y, 2); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + Assert.Equal(0f, shooterHit); + } + + // Ruling 3: a Contact round bursting short of max range arrives when it has flown that far -- 21 of 60 units + // at 20 units/s is 1.05 s into a 3 s flight -- and the commit says so through ArrivalIn. Kills: an arrival + // left at the max-range time (the round would still be pending at 1.2 s, and ArrivalIn would read ~3). + [Fact] + public void AContactBurstShortOfMaxRangeArrivesWhenTheRoundHasFlownThatFar() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.NotNull(outcome); + Assert.Equal(1.05f - .01f, outcome.ArrivalIn, 2); + Assert.True(Alive(e, blocker)); + + e.Zone.Update(1.1f); + Assert.Empty(e.Zone.PendingShots); + Assert.False(Alive(e, blocker)); + } + + // Ruling 4: a round outlives its shooter. A no-lock proximity round whose shooter leaves the zone in flight + // still detonates at max range, whether the shooter goes before or after the commit; a targeted proximity + // round does the same at its intercept, for the same damage a shooter who stayed would have dealt (the value + // measured for ATargetedFusedShotIsUnchangedByTheNoLockRule at b66ba524, 5.65884256). Kills: Step + // cancelling a shot whose source has left the zone. + [Theory] + [InlineData(0)] + [InlineData(1)] + public void ANoLockRoundStillBurstsAfterItsShooterLeavesTheZone(int updatesBeforeLeaving) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var witness = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 300); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + for (var i = 0; i < updatesBeforeLeaving; i++) e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(4f); + + Assert.Empty(e.Zone.PendingShots); + Assert.False(Alive(e, witness), "the round must still burst at max range"); + } + + [Fact] + public void ATargetedFusedRoundStillBurstsAfterItsShooterLeavesTheZone() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(6f); + + Assert.Empty(e.Zone.PendingShots); + Assert.Equal(5.65884256f, damage, 3); + } + + // Ruling 4, direct hits: rounds fired at a target and in flight when their shooter leaves still hit it. A dozen + // shots each roll their own dice (seeded by shot id), and with a stationary target and a full-accuracy + // targeting system nearly all land; the test needs only that all resolve and some hit, and that each hit + // still names the departed shooter (identity carried, never asked anything). + [Fact] + public void ADirectRoundStillHitsAfterItsShooterLeavesTheZone() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, damage: 5f); + var resolved = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved.Add(o)); + var struck = 0; + using var s = e.Target.IncomingHit.Subscribe(src => { if (src == e.Shooter) struck++; }); + + for (var i = 0; i < 12; i++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(6f); + + Assert.Empty(e.Zone.PendingShots); + Assert.Equal(12, resolved.Count); + Assert.True(resolved.Any(o => o.Hit), "at least one round must land on the stationary target"); + Assert.Equal(resolved.Count(o => o.Hit), struck); + } + + // Ruling 6: what a commit came to is typed. A no-lock burst is neither a hit nor a miss, and reads as neither. + // Kills: MakeOutcome collapsing a burst back to a miss (the debug HUD read MISS for a real explosion). + [Fact] + public void ANoLockBurstCommitsAsABurstNotAMiss() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(2, 1)); + ShotOutcome committed = null, resolved = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.False(committed.Hit); + var burst = PointAlong(e, normalize(float2(2, 1)), NoLockRange); + Assert.Equal(burst.x, committed.BurstPoint.x, 3); + Assert.Equal(burst.z, committed.BurstPoint.y, 3); + Assert.Same(committed, resolved); + } + // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. @@ -1352,6 +1623,7 @@ public sealed class FireControlCut124Tests : IDisposable } Assert.NotNull(outcome); Assert.True(outcome.Hit); + Assert.Equal(ShotResult.Hit, outcome.Result); Assert.True(armorEvents > 0); var shot = e.Zone.PendingShots.Count == 0 ? null : (PendingShot?) e.Zone.PendingShots[0]; Assert.Null(shot); // resolved and removed -- confirms it went through Apply's null-fuse path to completion From 91ed4ef91b6674dc501bbfd571ab7b550bb70105 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:50:41 +0200 Subject: [PATCH 082/116] Assert on the shot target and outcome identity without formatting entities Assert.Null(shot.Target) and Assert.Same(committed, resolved) format the entity graph when they fail, which overflows the xunit stack and crashes the host instead of reporting a red. Use Assert.True with a message. Co-Authored-By: Claude Sonnet 5 --- tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index d25718137..943475789 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1299,7 +1299,7 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var shot = Assert.Single(e.Zone.PendingShots); - Assert.Null(shot.Target); + Assert.True(shot.Target == null, "an invalid target must not ride with the round"); // not Assert.Null: a failure would format the entity graph and overflow the stack Assert.Equal(burst.x, shot.BurstPosition.x, 3); Assert.Equal(burst.z, shot.BurstPosition.z, 3); e.Zone.Update(.01f); @@ -1478,7 +1478,7 @@ public sealed class FireControlCut124Tests : IDisposable var burst = PointAlong(e, normalize(float2(2, 1)), NoLockRange); Assert.Equal(burst.x, committed.BurstPoint.x, 3); Assert.Equal(burst.z, committed.BurstPoint.y, 3); - Assert.Same(committed, resolved); + Assert.True(ReferenceEquals(committed, resolved), "the resolution republishes the commit unchanged"); } // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre From e7bb1e14f0e071e8265464491ddf789238bab4f9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 14:02:42 +0200 Subject: [PATCH 083/116] Record the airburst intent ruling Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 5028487ea..5c4f7794c 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2631,6 +2631,13 @@ follow-up rulings, all given on 2026-09-30: visible, or not locked takes the no-lock path. Nothing bursts beyond max range. - **A no-lock contact fuse stops on the first hull on its aim line**, computed at commit. Mining adds chunks to this through a named seam. +- **Airburst follows player intent (operator, 2026-09-30):** "If I don't have targeting data but decide + to shoot anyway, my intent is likely to be bomb fishing. If I fire with a target but that target warps + away, I likely to not want to explode whatever was behind it, and the projectile shouldn't burst." + - A round fired without valid targeting data bursts at max range. + - A round fired at a valid target that later leaves or becomes invalid does not burst. It resolves + with no detonation. + - Validity is judged on the targeting data at fire time. - **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go nowhere. From 640fa96787a5fea3ad1dcb7e7a3cdfc7d7c2e700 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:27:46 +0200 Subject: [PATCH 084/116] Fused rounds fire out of arc; safe fuse; proximity misses type as Burst Operator ruling 2026-09-30: a fused weapon with a selected but out-of-arc target fires. Its round flies along the arc-clamped aim and bursts at the target's range. Non-fused weapons still refuse. - FireControl.ArcPermitsFire is the one trigger arc gate (fused weapons are exempt); Fire reads the same InArc through PFire's new designated/inArc outs, which replace `bears`. No valid targeting data still bursts at Range. - F3: a fused weapon whose effective Range does not exceed its BlastRadius stays safed at the trigger (FuseCanArm, read from the runtime Range). The catalog check on Range.Min is deleted; it read the authored floor, which Evaluate can scale below. - F4: a targeted proximity burst is pulled back along its line to Range. - F8: a proximity round that detonates commits as Burst (or Hit); Apply's Proximity case collapses into the Burst path via Outcome.BurstPoint. - Tests: arc-only and LockWeapon gates, out-of-arc burst, trigger gate, runtime-Range refusal, Range clamp, Burst typing, commit horizon, and Soul's adopted probes (aim clamp edges, contact point, target leaves, shooter destroyed). Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 3 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 4 +- .../ServerShared/Behaviors/InstantWeapon.cs | 2 + .../Scripts/ServerShared/Behaviors/Weapon.cs | 15 +- Assets/Scripts/ServerShared/FireControl.cs | 123 ++-- Assets/Scripts/ServerShared/ItemData.cs | 15 - .../AetheriaStoresTests.cs | 54 -- .../FireControlCut124Tests.cs | 534 +++++++++++++++++- 8 files changed, 607 insertions(+), 143 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index b16a33af8..bee8a4c9f 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -30,7 +30,7 @@ public static class AetheriaStores if (missing != null) throw new InvalidOperationException($"Catalog {catalogPath} has no {missing.SchemaName} record; catalog globals are authored, never invented."); // Every catalog document is held to the one check Upsert applies on write: R-heat (docs/stats-and-power- - // cut.md), stat modifiers, role usage (Cut 7), and the fuse range rule. Fails loudly, naming the item. + // cut.md), stat modifiers, role usage (Cut 7). Fails loudly, naming the item. foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); return cache; @@ -73,7 +73,6 @@ public static class CultRecordRefs StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); - StatValidation.ValidateFuse(data); } if (document is ConsumableItemData consumable) { diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 37da4fe06..8a93c2a82 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -91,7 +91,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // Cut 5 (docs/fire-control-cut.md, 5.3, Soul finding 9): StanceAllowsFire alone let a side-mounted beam // fire forward -- ArcAllowsFire joins it here, matching InstantWeapon.cs's player arc gate (Q2: manual and // programmatic are one truth, and a continuous weapon is no exception). - public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; + public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire && FuseAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon. public int DefaultPowerTier => PowerTiers.Low; @@ -99,7 +99,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override bool Execute(float dt) { base.Execute(dt); - if (_firing && !(StanceAllowsFire && ArcAllowsFire)) + if (_firing && !(StanceAllowsFire && ArcAllowsFire && FuseAllowsFire)) { // Safed: shooter has a target and hasn't declared hostility toward it, or the target no longer // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does). diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 6b9b14fc8..1cf398539 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -103,6 +103,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // through -- a player's action-bar Activate() reaches this exactly the same way Combat.cs's and // TurretController.cs's Activate() calls do. if (!ArcAllowsFire) return; + // A fused weapon whose Range no longer clears its blast would burst on its shooter. + if (!FuseAllowsFire) return; // If 1 ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 350d63500..57340f8d9 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -107,16 +107,11 @@ public abstract class Weapon : Behavior, IActivatedBehavior // gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on). // Cut 5, 5.4 (Soul finding 11): the point-blank special case this used to carry moved into InArc itself -- // one bearing test, one owner, so every caller (not just this one) gets a point-blank shot that bears. - public bool ArcAllowsFire - { - get - { - var target = Entity.Target.Value; - if (target == null) return true; - var toTarget = target.Position - Entity.Position; - return FireControl.InArc(Item, toTarget); - } - } + // FireControl owns the gate, including its one exemption: a fused weapon fires out of arc. + public bool ArcAllowsFire => FireControl.ArcPermitsFire(this, Entity); + + // A fused weapon whose current Range does not exceed its blast stays safed (FireControl.FuseCanArm). + public bool FuseAllowsFire => FireControl.FuseCanArm(this); public Weapon(WeaponData data, EquippedItem item) : base(data, item) { diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 3d08172d8..51fab98af 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -57,6 +57,33 @@ public static class FireControl return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f)); } + // The one place a weapon's payload is read: its blast radius, and the fuse it detonates with. A weapon + // detonates only with both a fuse and a radius above zero; either alone is inert and resolves as a direct + // hit (nothing polices that), so a null return means "not fused". + public static WeaponFuse? FuseOf(EquippedItem item, out float blastRadius) + { + var data = item?.Data as WeaponItemData; + blastRadius = data?.BlastRadius > 0f ? data.BlastRadius.Value : 0f; + return blastRadius > 0f ? data.Fuse : null; + } + + // The one arc gate on the trigger. A fused weapon fires whatever the arc says: a target it cannot bear on is + // no reason to keep it silent, the round flies along the arc-clamped aim and bursts at the target's range + // (Fire; operator ruling 2026-09-30, "the user is hoping that the arc gets the weapon close enough to still + // splash some damage"). A weapon that only hits what it fires at still refuses. Fire reads InArc itself to + // choose that flight, so the two share the one bearing test and differ only in this exemption. + public static bool ArcPermitsFire(Weapon weapon, Entity shooter) + { + var target = shooter.Target.Value; + if (target == null) return true; + return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position); + } + + // A fused weapon whose Range, as it stands now (power supply and modifiers included), does not exceed its + // blast would burst on its own shooter at max range, so its trigger stays safed. Judged on the effective + // Range rather than the catalog's authored floor, which Evaluate can scale below. + public static bool FuseCanArm(Weapon weapon) => FuseOf(weapon.Item, out var blastRadius) == null || weapon.Range > blastRadius; + // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The // arc is InArc's own cone: an aim inside it is used as is, one outside it is turned to the nearest edge of @@ -172,23 +199,26 @@ public static class FireControl // freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette // enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again, // live, by Commit at the commit tick. 0 the moment any gate closes. - private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _); + private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _, out _); // The `out range` overload is HitProbability's own gate call -- one computation of `target.Position - // source.Position`, not two. HitProbability used to recompute its own copy for PSpread, which is exactly // the kind of split authority Cut 3 named as the risk (two places computing the same vector, free to // drift or to have their subtraction order flipped in only one of them). private static float PFire(Weapon weapon, Entity source, Entity target, out float range) => - PFire(weapon, source, target, out range, out _); + PFire(weapon, source, target, out range, out _, out _); - // `bears` is the one answer to "is this target one the weapon can fire at": in range, visible, locked (for a - // LockWeapon) and in arc. PFire prices a shot at zero when it is false; Fire reads it to decide that a - // fused round has no target at all (operator ruling 2026-09-30: an invalid target counts as no lock). - // Inspect recomputes the four gates one by one, to show the HUD which is closed; it decides nothing. - private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool bears) + // `designated` is the one answer to "does the shooter hold valid targeting data on this target": visible, in + // range and locked (for a LockWeapon). `inArc` is the bearing test, asked only once a target is designated. + // PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at + // (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but + // out of arc it bursts at the target's range, along the aim. Inspect recomputes the four gates one by one, + // to show the HUD which is closed; it decides nothing. + private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc) { range = 0f; - bears = false; + designated = false; + inArc = false; if (target == null) return 0f; // F8 correction (Soul's fix batch, 2026-09-24): range is computed for any non-null target, before any @@ -201,8 +231,9 @@ public static class FireControl if (!source.VisibleEntities.Contains(target)) return 0f; if (range < weapon.MinRange || range > weapon.Range) return 0f; if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; - if (!InArc(weapon.Item, toTarget)) return 0f; - bears = true; + designated = true; + inArc = InArc(weapon.Item, toTarget); + if (!inArc) return 0f; var settings = source.ItemManager.GameplaySettings; var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; @@ -393,7 +424,7 @@ public static class FireControl // F8 (Soul's fix batch, 2026-09-24): the `out range` overload is the one range HitProbability now // reads too -- Fire used to recompute its own second copy of `target.Position - source.Position` for // fireRange, right beside the copy PFire's gate already computed. - var pFire = PFire(weapon, source, target, out var fireRange, out var bears); + var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc); // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided @@ -401,17 +432,18 @@ public static class FireControl // only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius // is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below // still reads as "resolve this shot with Apply." - var weaponItemData = item.Data as WeaponItemData; - var blastRadius = weaponItemData?.BlastRadius > 0f ? weaponItemData.BlastRadius.Value : 0f; - var fuse = blastRadius > 0f ? weaponItemData.Fuse : null; + var fuse = FuseOf(item, out var blastRadius); - // Operator rulings 2026-09-30: an invalid target counts as no lock -- a fused round whose selected - // target is out of range, out of arc, not visible or not locked (PFire's `bears`) is fired at nothing. - // A fused shot with no target bursts where its own aim line ends: the shooter's aim (AimDirection) at - // the weapon's Range stat, the same stat PFire's range gate reads, flown at the weapon's Velocity like - // a targeted shot flies to its intercept. The round carries no target from here on, so nothing at - // resolution asks whether one is still around. A shot without a fuse keeps its target as before. - var engaged = fuse != null && !bears ? null : target; + // Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the + // mount's arc) and carries no target -- so nothing at resolution asks whether one is still around -- + // unless its target is designated (visible, in range, locked) and in arc: + // * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no + // lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing; + // * valid data but out of arc bursts at the target's range, which the range gate has already held to + // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. + // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot + // without a fuse keeps its target as before. + var engaged = fuse != null && !(designated && inArc) ? null : target; var origin = source.Position; var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); @@ -427,11 +459,20 @@ public static class FireControl var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; - var burstPosition = blastRadius > 0f && engaged != null ? PredictedIntercept(weapon, source, engaged) : targetPosition; - if (fuse != null && engaged == null) + var burstPosition = targetPosition; + if (fuse != null && engaged != null) { - burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * weapon.Range; - flightTime = weapon.Velocity > .01f ? weapon.Range / weapon.Velocity : 0f; + // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is + // pulled back along its own line to Range. + var intercept = PredictedIntercept(weapon, source, engaged); + var reach = length((intercept - origin).xz); + burstPosition = reach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / reach) : intercept; + } + else if (fuse != null) + { + var reach = designated ? fireRange : weapon.Range; + burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; + flightTime = weapon.Velocity > .01f ? reach / weapon.Velocity : 0f; } var shot = new PendingShot @@ -649,7 +690,14 @@ public static class FireControl } } - return MakeOutcome(shot, hit ? ShotResult.Hit : ShotResult.Miss, shielded, shieldBroken, cell, bearing, lateral, now); + // A proximity round detonates at its burst point whether or not the roll hit, so a roll that missed is a + // Burst, not a Miss: the outcome types what happened, and the HUD must not call a real explosion a miss. A + // contact or delayed round detonates only on a hit, so its miss stays a Miss. + var proximity = shot.Fuse == WeaponFuse.Proximity; + var outcome = MakeOutcome(shot, hit ? ShotResult.Hit : proximity ? ShotResult.Burst : ShotResult.Miss, + shielded, shieldBroken, cell, bearing, lateral, now); + if (proximity) outcome.BurstPoint = shot.BurstPosition.xz; + return outcome; } // Operator rulings 2026-09-30. A fused round with no target bursts at the max-range point Fire froze along @@ -770,9 +818,10 @@ public static class FireControl // centre lane (see the fuse-point comment on the Contact/Delayed branch below). private static void Apply(Zone zone, PendingShot shot) { - // A fused shot with no target has nothing to hit or miss: its committed outcome is a Burst, and it - // detonates at the point Commit decided (Burst outcome's BurstPoint). Detonate is still the one blast path. - if (shot.Outcome.Result == ShotResult.Burst) + // A Burst outcome detonates at the point Commit decided (its BurstPoint): a fused round with no target, + // which has nothing to hit or miss, and a proximity round, which detonates whether its roll hit or not. + // Detonate is still the one blast path. + if (shot.Outcome.Result == ShotResult.Burst || shot.Fuse == WeaponFuse.Proximity) { Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType); return; @@ -783,11 +832,6 @@ public static class FireControl case null: ApplyDirectHit(shot); return; - case WeaponFuse.Proximity: - // Detonates at shot.BurstPosition.xz, which PredictedIntercept froze at Fire (today's airburst - // rule, unchanged) -- no roll, and no gate on shot.Outcome.Hit. - Detonate(zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); - return; case WeaponFuse.Contact: case WeaponFuse.Delayed: ApplyBlastHit(shot); @@ -1737,10 +1781,12 @@ public sealed class ShotOutcome public Entity Target; public EquippedItem Weapon; // What the commit decided, typed: a shot rolled and missed, rolled and hit, or was a fused round with no - // target and so burst without rolling (Burst, at BurstPoint). Hit is derived from it, not stored beside it. + // target and so burst without rolling, or a proximity round whose roll missed but which detonates anyway + // (Burst, at BurstPoint). Hit is derived from it, not stored beside it. public ShotResult Result; public bool Hit => Result == ShotResult.Hit; - // World planar point a Burst detonates at, frozen by the commit. Meaningless for any other Result. + // World planar point a proximity or no-target round detonates at, frozen by the commit. Meaningless for any + // other round. public float2 BurstPoint; public bool Shielded; // Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active, @@ -1757,8 +1803,9 @@ public sealed class ShotOutcome public DamageType DamageType; } -// What a committed shot came to. A Burst is a fused round that had no valid target: it rolled nothing, so it is -// neither a hit nor a miss, and the presentation must not call it one. +// What a committed shot came to. A Burst is a detonation that is not a hit on a target: a fused round that had no +// valid target and rolled nothing, or a proximity round whose roll missed but which bursts anyway. It is neither +// a hit nor a miss, and the presentation must not call it one. public enum ShotResult { Miss, diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 40e2beb83..59d93c1ba 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -752,21 +752,6 @@ public static class StatValidation "not exceed them"); } - // Operator ruling 2026-09-30 (Soul): a round with a blast that could burst on its own shooter is refused at - // the catalog rather than policed at runtime. A weapon that detonates (a fuse and a blast radius above zero, - // the two FireControl.Fire freezes together) must reach farther than its blast, at the weakest value its Range - // stat can take: a no-lock round bursts at Range, so Range <= BlastRadius would put the shooter inside the - // burst, and Range <= 0 is a burst on the muzzle. A fuse without a radius is inert and needs no range. - public static void ValidateFuse(EquippableItemData data) - { - if (!(data is WeaponItemData weapon) || weapon.Fuse == null || !(weapon.BlastRadius > 0f)) return; - foreach (var behavior in weapon.Behaviors) - if (behavior is WeaponData weaponData && weaponData.Range.Min <= weapon.BlastRadius.Value) - throw new InvalidOperationException( - $"{weapon.Name}: fused weapon's Range {weaponData.Range.Min} does not exceed its BlastRadius " + - $"{weapon.BlastRadius.Value} -- a burst at max range would land on the shooter"); - } - // Cut 2 (docs/stats-and-power-cut.md): a StatReference names its target by (type name, field name) so a // modifier can point at any design or behaviour without a hard type reference -- but before this cut an // unresolvable reference failed silently: StatModifier.Initialize left `_stats` null and the first diff --git a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs index e23953192..e0fec193d 100644 --- a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs +++ b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs @@ -190,60 +190,6 @@ public sealed class AetheriaStoresTests : IDisposable Assert.Null(record); } - // Operator ruling 2026-09-30 (Soul): a weapon that detonates must reach farther than its blast, at the - // weakest value its Range stat can take, or a max-range burst lands on the shooter. Refused where every - // catalog write and every open already checks, not policed at runtime. - private static WeaponItemData FusedWeapon(string name, float rangeMin, float rangeMax, float? blastRadius, WeaponFuse? fuse) => new WeaponItemData - { - Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, - MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, - Fuse = fuse, BlastRadius = blastRadius, - Behaviors = { new InstantWeaponData { Range = new PerformanceStat { Min = rangeMin, Max = rangeMax } } } - }; - - // Kills: a comparison that admits equality (Range == BlastRadius bursts on the shooter's own edge); reading - // Range.Max instead of Min (the second row: Max 100 clears the blast, the weakest value does not); a - // zero range with a positive blast. - [Theory] - [InlineData(3f, 3f, 3f)] - [InlineData(2f, 2f, 3f)] - [InlineData(2f, 100f, 3f)] - [InlineData(0f, 0f, .5f)] - public void ValidateRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast(float rangeMin, float rangeMax, float radius) - { - var error = Assert.Throws(() => - CultRecordRefs.Validate(FusedWeapon("ShortFuse", rangeMin, rangeMax, radius, WeaponFuse.Proximity))); - Assert.Contains("ShortFuse", error.Message); - } - - // A fuse without a radius, a radius without a fuse, and a range that clears the blast are all fine: only a - // weapon that will actually detonate is held to the rule. - [Theory] - [InlineData(1f, 1f, null, WeaponFuse.Contact)] - [InlineData(1f, 1f, 0f, WeaponFuse.Contact)] - [InlineData(1f, 1f, 30f, null)] - [InlineData(60f, 60f, 4f, WeaponFuse.Delayed)] - public void ValidateAcceptsAWeaponThatCannotBurstOnItsShooter(float rangeMin, float rangeMax, float? radius, WeaponFuse? fuse) - { - var record = Record.Exception(() => CultRecordRefs.Validate(FusedWeapon("Fine", rangeMin, rangeMax, radius, fuse))); - Assert.Null(record); - } - - // Open holds a catalog already on disk to the same rule Upsert applies on write. The bad record is written - // past Upsert, straight through the cache, the way a hand-edited or migrated catalog would carry one. - [Fact] - public void OpenRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast() - { - using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) - { - cache.UpsertAsync(FusedWeapon("OnDisk", 2f, 2f, 3f, WeaponFuse.Contact)).Wait(); - cache.FlushAsync().Wait(); - } - - var error = Assert.Throws(() => AetheriaStores.Open(Catalog)); - Assert.Contains("OnDisk", error.Message); - } - // A document type Validate has no opinion about (neither EquippableItemData nor ConsumableItemData) must be // a pure no-op, not a crash -- the three tools' change lists carry `object`, and not every migration touches // an equippable design (e.g. a future repair over FactionProductData). diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 943475789..3c8f38db3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -83,6 +83,32 @@ public sealed class FireControlCut124Tests : IDisposable public Dictionary Custom = new Dictionary(); } + // The gun's behaviour data: a plain InstantWeaponData, or a LockWeaponData whose lock either grows to full + // (LockAngle 180 admits any bearing) or never grows (LockAngle 0 admits none, so it only decays). A stat + // term on Range lets a test scale it below its authored value through the power supply. + private static InstantWeaponData WeaponBehavior(float damage, float range, StatSource? rangeTerm, float velocity, float penetration, + bool lockWeapon, bool lockAcquires) + { + var data = lockWeapon ? new LockWeaponData + { + LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(lockAcquires ? 180 : 0), + DirectionImpact = Constant(1), Decay = Constant(1) + } : new InstantWeaponData(); + data.Damage = Constant(damage); + data.Range = rangeTerm == null ? Constant(range) + : new PerformanceStat { Min = range, Max = range, Terms = { new StatTerm { Source = rangeTerm.Value, Exponent = 1f } } }; + data.MinRange = Constant(0); + data.Velocity = Constant(velocity); + data.Spread = Constant(0); + data.DamageSpread = Constant(0); + data.Penetration = Constant(penetration); + data.Count = Constant(1); + data.BurstTime = Constant(0); + data.Cooldown = Constant(1000); + data.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + return data; + } + private Engagement Build( GameplaySettings settings, Shape hullShape, float2? targetFacing = null, float targetRange = 100f, float velocity = 0f, @@ -91,7 +117,8 @@ public sealed class FireControlCut124Tests : IDisposable bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true, int2? cockpitCell = null, float cockpitDurability = 1000f, (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null, - float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f) + float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f, + float hardpointArc = 0f, StatSource? rangeTerm = null, bool lockWeapon = false, bool lockAcquires = true) { var hullData = new HullData { @@ -101,7 +128,7 @@ public sealed class FireControlCut124Tests : IDisposable var shooterHullData = new HullData { Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000, - Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape(), FiringArc = hardpointArc } } }; var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); @@ -114,13 +141,7 @@ public sealed class FireControlCut124Tests : IDisposable Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Fuse = fuse, BlastRadius = blastRadius, WeaponModifiers = modifiers, - Behaviors = { new InstantWeaponData - { - Damage = Constant(damage), Range = Constant(weaponRange), MinRange = Constant(0), - Velocity = Constant(velocity), Spread = Constant(0), DamageSpread = Constant(0), - Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } - } } + Behaviors = { WeaponBehavior(damage, weaponRange, rangeTerm, velocity, penetration, lockWeapon, lockAcquires) } }); cache.Upsert(new GearData { @@ -1270,28 +1291,25 @@ public sealed class FireControlCut124Tests : IDisposable Assert.False(Alive(e, onMount), "with nothing aimed the blast follows the mount"); } - public enum Invalid { OutOfRange, NotVisible, OutOfArc } + public enum Invalid { OutOfRange, NotVisible, Unlocked } - // Ruling 2: a selected target that is out of range, not visible or out of arc counts as no lock -- the round - // bursts at max range along the aim, and the target is left alone. The aim is (2,1) except when the target is - // behind the mount, where the mount is flipped and the aim follows it. Kills: reading "no lock" as - // Target == null alone (the round would burst at the target's intercept, 100 / 40 / 100 units out). - // (An unlocked LockWeapon shares the same predicate; the fixture has no LockWeapon.) + // Ruling 2: a selected target that is out of range, not visible or not locked is not valid targeting data, + // and counts as no lock -- the round bursts at max range along the aim, and the target is left alone. Kills: + // reading "no lock" as Target == null alone (the round would burst at the target's intercept, 100 / 40 / 40 + // units out); dropping the range, visibility or lock gate from PFire's `designated`. The lock row needs a + // LockWeapon whose lock never grows; its control, below, is the same gun with the lock acquired. (A closed + // arc is not an invalid target: see AFusedShotAtATargetOutsideTheArc...) [Theory] [InlineData(Invalid.OutOfRange)] [InlineData(Invalid.NotVisible)] - [InlineData(Invalid.OutOfArc)] + [InlineData(Invalid.Unlocked)] public void AnInvalidTargetCountsAsNoLock(Invalid reason) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, - weaponRange: NoLockRange, targetRange: reason == Invalid.NotVisible ? 40f : 100f); + weaponRange: NoLockRange, targetRange: reason == Invalid.OutOfRange ? 100f : 40f, + lockWeapon: reason == Invalid.Unlocked, lockAcquires: false); var aim = normalize(float2(2, 1)); if (reason == Invalid.NotVisible) e.Shooter.VisibleEntities.Remove(e.Target); - if (reason == Invalid.OutOfArc) - { - e.Shooter.Direction = float2(0, -1); - aim = float2(0, -1); - } Aim(e, aim); var burst = PointAlong(e, aim, NoLockRange); var onAim = AddShip(e, burst, 1f, 300); @@ -1308,6 +1326,228 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(targetBefore, e.Target.Hull.Durability); } + // The lock row's control: the same LockWeapon once its lock has grown carries its target to the intercept. + // Kills: a fixture whose lock could never grow (which would make the unlocked row above pass for nothing). + [Fact] + public void ALockedTargetRidesWithTheRound() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f, lockWeapon: true, lockAcquires: true); + Aim(e, float2(0, 1)); + e.Zone.Update(.1f); + Assert.True(((LockWeapon) e.Weapon).IsLocked, "fixture: the lock must have grown"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = Assert.Single(e.Zone.PendingShots); + Assert.True(shot.Target == e.Target, "a locked, visible, in-range, in-arc target rides with the round"); + Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); + } + + // The out-of-arc ruling (operator, 2026-09-30): a fused round at a target it holds valid data on but cannot + // bear on flies along the aim clamped to the mount's arc and bursts at the TARGET'S range, not at max range. + // The mount is flipped to (0,-1) so the target 40 ahead of the shooter is 180 degrees off it, in range, + // visible and unlocked by nothing: the arc is the only closed gate. The aim (1,1) is 135 degrees off the + // mount, so it clamps to the 85-degree edge on its own side. Three witnesses: on the edge at 40 (dies), on + // the edge at max range 60 (lives: the burst is at target range), under the unclamped aim at 40 (lives: the + // clamp holds). Kills: dropping `inArc` from PFire (the target would ride and burst at 40 along the target + // line); bursting at Range instead of the target's range; skipping the clamp. Contact is left out: its round + // stops on the first hull it meets, and these witnesses are hulls. + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Delayed)] + public void AFusedShotAtATargetOutsideTheArcBurstsAtTheTargetsRangeAlongTheClampedAim(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f); + e.Shooter.Direction = float2(0, -1); + var look = normalize(float2(1, 1)); + Aim(e, look); + var mount = float2(0, -1); + var edge = float2(mount.x * cos(radians(85f)) - mount.y * sin(radians(85f)), mount.x * sin(radians(85f)) + mount.y * cos(radians(85f))); + var atTargetRange = AddShip(e, PointAlong(e, edge, 40f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 302); + var underAim = AddShip(e, PointAlong(e, look, 40f), 1f, 304); + var targetBefore = e.Target.Hull.Durability; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = Assert.Single(e.Zone.PendingShots); + Assert.True(shot.Target == null, "a target the weapon cannot bear on must not ride with the round"); + var burst = PointAlong(e, edge, 40f); + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.False(Alive(e, atTargetRange), "the blast must land at the target's range along the clamped aim"); + Assert.True(Alive(e, atMaxRange), "the round bursts at the target's range, not at max range"); + Assert.True(Alive(e, underAim), "the aim is clamped to the arc's edge"); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + } + + // The trigger and Fire share one bearing test (InArc) and differ only in the fused weapon's exemption, so + // this pins the pair at the layer a player uses: Activate. With the target out of arc and the weapon + // otherwise clear, a fused weapon fires a round; the same weapon without a fuse stays silent. Kills: + // removing the exemption from FireControl.ArcPermitsFire (the fused row goes silent); removing the arc gate + // itself (the plain row fires); widening the exemption to a weapon with a blast radius but no fuse. + [Theory] + [InlineData(WeaponFuse.Proximity, true)] + [InlineData(null, false)] + public void OnlyAFusedWeaponFiresAtATargetOutsideItsArc(WeaponFuse? fuse, bool fires) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f); + e.Shooter.Direction = float2(0, -1); + Aim(e, float2(0, -1)); + Assert.Equal(fires, e.Weapon.ArcAllowsFire); + var committed = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++); + + e.Weapon.Activate(); + for (var i = 0; i < 5; i++) e.Zone.Update(.01f); + + Assert.Equal(fires ? 1 : 0, committed); + } + + // A fused weapon safes itself while its effective Range does not exceed its blast, because a round bursting at + // max range would land on its own shooter. The gate reads the Range the weapon has NOW: the third row is + // Soul's probe (authored Range 100 clears a blast of 30, but at a power supply of 0.1 Range is 10), and the + // first two pin the boundary itself (equal refuses). Kills: reading the authored figure instead of the + // runtime Range; `>=` for `>`; dropping the gate from InstantWeapon.Trigger. + [Theory] + [InlineData(30f, 30f, false, false)] + [InlineData(31f, 30f, false, true)] + [InlineData(100f, 30f, true, false)] + public void AFusedWeaponWhoseRangeDoesNotExceedItsBlastStaysSafed(float range, float blast, bool starvedOfPower, bool fires) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: blast, damage: 100f, + weaponRange: range, rangeTerm: starvedOfPower ? StatSource.PowerSupply : (StatSource?) null); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + if (starvedOfPower) + { + typeof(EquippedItem).GetProperty("PowerSupply").GetSetMethod(true).Invoke(e.WeaponItem, new object[] { .1f }); + e.Shooter.Resolver.InvalidateSource(e.WeaponItem, StatSource.PowerSupply); + e.Weapon.GetType().GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(e.Weapon, null); + Assert.True(e.Weapon.Range < blast, "fixture: the runtime Range must have fallen below the blast"); + } + var committed = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++); + + Assert.Equal(fires, e.Weapon.FuseAllowsFire); + e.Weapon.Activate(); + // The starved row must not run a full Update first: the power bus would restore the supply we forced. + // Activate queues the burst inside Trigger, so one short tick is enough for a commit if it fires. + for (var i = 0; i < 5; i++) e.Zone.Update(.01f); + + Assert.Equal(fires ? 1 : 0, committed); + } + + // Nothing bursts beyond max range. A target receding faster than the round would be led past Range by the + // intercept; the burst is pulled back along the line to the intercept, to exactly Range. The target moves + // (6,8), so the direction is off-axis and a clamp that moved the point along the wrong line shows. + // Kills: no clamp (the probe burst 180 units out on Range 100); a clamp on one axis only. + [Fact] + public void ATargetedProximityBurstIsPulledBackToMaxRange() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 100f, targetRange: 90f); + e.Target.Velocity = float2(6f, 8f); + var intercept = FireControl.PredictedIntercept(e.Weapon, e.Shooter, e.Target); + var origin = e.Shooter.Position; + var toIntercept = normalize((intercept - origin).xz); + Assert.True(length((intercept - origin).xz) > 100f, "fixture: the intercept must lie beyond Range"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = Assert.Single(e.Zone.PendingShots); + Assert.Equal(origin.x + toIntercept.x * 100f, shot.BurstPosition.x, 2); + Assert.Equal(origin.z + toIntercept.y * 100f, shot.BurstPosition.z, 2); + } + + // What a proximity round's commit says. A round whose roll missed still detonates, so it commits as a Burst + // at the intercept, and only a roll that landed is a Hit; a contact or delayed round detonates only on a hit, + // so its guaranteed miss (accuracy 0) stays a Miss and explodes nowhere. Kills: typing a proximity miss as a + // Miss (the HUD said MISS over a real explosion); typing every proximity round a Burst (no Hit among the + // twelve); dropping the burst point from a Hit (it would detonate at the origin). + [Fact] + public void AProximityRoundThatMissesCommitsAsABurstAtTheIntercept() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, accuracy: 0f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.Equal(e.Target.Position.x, committed.BurstPoint.x, 3); + Assert.Equal(e.Target.Position.z, committed.BurstPoint.y, 3); + Assert.Equal(5.65884256f, damage, 3); + } + + [Fact] + public void AProximityRoundThatLandsCommitsAsAHitAndStillDetonates() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var results = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => results.Add(o)); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + for (var n = 0; n < 12; n++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(12, results.Count); + Assert.True(results.All(o => o.Result != ShotResult.Miss), "a proximity round always detonates, so it is never a Miss"); + Assert.True(results.Any(o => o.Result == ShotResult.Hit), "the stationary, fully-accurate target must be hit at least once"); + Assert.True(results.All(o => abs(o.BurstPoint.x - e.Target.Position.x) < 1e-3f && abs(o.BurstPoint.y - e.Target.Position.z) < 1e-3f), + "a hit detonates at the intercept too, not at the origin"); + Assert.True(damage > 0f, "the blasts must have landed"); + } + + [Theory] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AContactOrDelayedRoundThatMissesIsAMissAndDoesNotDetonate(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 100f, accuracy: 0f); + var bystander = AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300); + ShotOutcome resolved = null; + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(resolved); + Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.True(Alive(e, bystander), "a missed contact or delayed round detonates nowhere"); + } + + // A targeted fused round commits at its horizon, not at Fire: the target's facing is read late (Bearing + // timing), and only a round with no target commits at once. 100 units at 20/s is a 5 s flight, so with the + // 0.5 s horizon it commits at 4.5 s. Kills: CommitTime = now for every fused shot (the round would be + // committed after the first tick). + [Fact] + public void ATargetedFusedShotCommitsAtItsHorizonNotAtFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = Assert.Single(e.Zone.PendingShots); + Assert.Equal(shot.FireTime + 4.5f, shot.CommitTime, 3); + e.Zone.Update(.01f); + Assert.False(e.Zone.PendingShots[0].Committed, "a targeted round reads the target's facing late"); + e.Zone.Update(4.5f); + Assert.True(e.Zone.PendingShots[0].Committed, "and commits once its horizon is reached, with the round still in flight"); + } + // Ruling 3: a Contact round with no lock stops on the first hull its aim line crosses before max range; a // Proximity or Delayed round keeps the max-range burst. The blocker's near face sits 21 units out (a 5x4-cell // hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a Contact fuse that ignores @@ -1481,6 +1721,256 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(ReferenceEquals(committed, resolved), "the resolution republishes the commit unchanged"); } + // ==== Soul's probes on hands/fuse-fix2 (2026-09-30), adopted as tests ==== + + private static float2 Dir(float degreesFromMount) => float2(sin(radians(degreesFromMount)), cos(radians(degreesFromMount))); + + private static float2 Clamped(Engagement e, float2 look) + { + Aim(e, look); + return FireControl.AimDirection(e.WeaponItem, e.Shooter); + } + + private static void Near(float2 expected, float2 actual, int digits = 3) + { + Assert.Equal(expected.x, actual.x, digits); + Assert.Equal(expected.y, actual.y, digits); + } + + // AimDirection at the arc's edges: the mount is (0,1) and the arc 170 degrees, so the edge is 85. On the edge + // and inside are untouched, just outside turns to the edge on its own side, straight behind turns to the + // positive edge, a zero aim (and one too short to normalise) is the mount, and a NaN aim never poisons the + // result. Kills: a clamp that turns the wrong way; an edge off by a degree; a missing zero-aim fallback. + [Fact] + public void TheAimClampHoldsAtTheEdgesOfTheArc() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + Near(Dir(85), Clamped(e, Dir(85))); + Near(Dir(85), Clamped(e, Dir(85.5f))); + Near(Dir(-85), Clamped(e, Dir(-86))); + Near(Dir(84), Clamped(e, Dir(84))); + Near(Dir(0), Clamped(e, Dir(0))); + Near(Dir(85), Clamped(e, Dir(180 - 1e-3f)), 2); + Near(float2(0, 1), Clamped(e, float2(0, 0))); + Near(float2(0, 1), Clamped(e, float2(5e-4f, 0))); + Near(Dir(85), Clamped(e, float2(2e-3f, 0))); + e.Shooter.LookDirection = float3(0, 1, 0); + Near(float2(0, 1), FireControl.AimDirection(e.WeaponItem, e.Shooter)); + e.Shooter.LookDirection = float3(float.NaN, 0, float.NaN); + var nan = FireControl.AimDirection(e.WeaponItem, e.Shooter); + Assert.False(float.IsNaN(nan.x) || float.IsNaN(nan.y), "a NaN aim must not produce a NaN flight direction"); + } + + [Fact] + public void AFullCircleMountPassesEveryAim() + { + var settings = TestSettings(); + settings.FiringArc = 360; + var e = Build(settings, SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + Near(float2(0, -1), Clamped(e, float2(0, -3))); + Near(normalize(float2(-1, -1)), Clamped(e, float2(-5, -5))); + } + + [Fact] + public void AZeroWidthArcIsTheMount() + { + var settings = TestSettings(); + settings.FiringArc = 0; + var e = Build(settings, SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + foreach (var d in new[] { 0f, 1e-3f, 30f, 90f, 179f, -120f }) + Near(float2(0, 1), Clamped(e, Dir(d)), 4); + } + + // The hardpoint's authored arc overrides the default (90 here, so the edge is 45), and the arc turns with the + // mount: a shooter facing (1,1) has its edge 45 degrees counter-clockwise of that when aimed along -x. + [Fact] + public void TheHardpointsOwnArcAndTheRotatedMountSetTheEdge() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f, hardpointArc: 90f); + Near(Dir(45), Clamped(e, float2(1, 0))); + Near(Dir(-45), Clamped(e, Dir(-100))); + e.Shooter.Direction = normalize(float2(1, 1)); + var mount = normalize(float2(1, 1)); + var edgeCcw = float2(mount.x * cos(radians(45)) - mount.y * sin(radians(45)), mount.x * sin(radians(45)) + mount.y * cos(radians(45))); + Near(edgeCcw, Clamped(e, float2(-1, 0))); + } + + // A contact round with no lock commits at Fire, against the poses of that tick: a hull that sails onto the + // aim line afterwards is not in its way, and one that leaves it afterwards does not un-stop it. Kills: + // committing later than Fire (the blocker that arrives would stop the round); re-deciding the burst point + // from live poses. + [Fact] + public void AContactRoundIgnoresAHullThatCrossesItsLineAfterFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f) + float3(300, 0, 0), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.Equal(e.Shooter.Position.z + 60f, committed.BurstPoint.y, 2); + blocker.Position = PointAlong(e, float2(0, 1), 25f); + e.Zone.Update(4f); + + Assert.Empty(e.Zone.PendingShots); + Assert.True(Alive(e, blocker), "the round flew through the hull that arrived after Fire and burst at max range"); + } + + [Fact] + public void AContactRoundStillBurstsAtAHullThatLeavesItsLineAfterFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 302); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(e.Shooter.Position.z + 21f, committed.BurstPoint.y, 2); + blocker.Position += float3(300, 0, 0); + e.Zone.Update(4f); + + Assert.True(Alive(e, blocker), "the hull moved away; the round still burst at its old face"); + Assert.True(Alive(e, atMaxRange), "and did not continue to max range"); + } + + // The contact point is measured from where the round left (FireOrigin), not from where its shooter is when + // the commit tick runs. Unblocked, a max-range burst point is frozen at Fire either way; with a blocker 21 + // units out the origin matters. Kills: Commit reading the shooter's live position (Soul's M6). + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AShooterMovingAfterFireDoesNotMoveTheContactPoint(bool withBlocker) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var origin = e.Shooter.Position; + if (withBlocker) AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Shooter.Position += float3(25f, 0, 10f); + e.Zone.Update(.01f); + + Assert.Equal(origin.x, committed.BurstPoint.x, 2); + Assert.Equal(origin.z + (withBlocker ? 21f : 60f), committed.BurstPoint.y, 2); + } + + // A round fired from inside another hull bursts at the muzzle: the origin is already in metal, a distance of + // zero. Kills: reading a cell's Entry for its Exit (Soul's M7: a cell the origin sits in is skipped and the + // round flies on); dropping the clamp at zero (Soul's M8: the negative distance puts the burst behind the + // muzzle). + [Fact] + public void AContactRoundFiredFromInsideAnotherHullBurstsAtTheMuzzle() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + AddShip(e, e.Shooter.Position + float3(0.5f, 0, 0.5f), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(e.Shooter.Position.x, committed.BurstPoint.x, 2); + Assert.Equal(e.Shooter.Position.z, committed.BurstPoint.y, 2); + } + + // A hull wholly behind the muzzle is not on the round's way. Kills: admitting cells whose exit lies behind + // the origin (the round would burst at the hull behind it, at distance zero). + [Fact] + public void AContactRoundIgnoresAHullBehindTheMuzzle() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + AddShip(e, PointAlong(e, float2(0, -1), 25f), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(e.Shooter.Position.z + 60f, committed.BurstPoint.y, 2); + } + + // The intent ruling: a round fired at a valid target that has left the zone does not burst, at either stage + // (before or after its commit), whatever its fuse -- it resolves as a Miss, and nothing standing beside where + // the target was, nor the shooter, takes blast damage. Kills: a round that detonates at its frozen burst point + // after its target is gone. + [Theory] + [InlineData(WeaponFuse.Proximity, false)] + [InlineData(WeaponFuse.Contact, false)] + [InlineData(WeaponFuse.Delayed, false)] + [InlineData(WeaponFuse.Proximity, true)] + [InlineData(WeaponFuse.Contact, true)] + [InlineData(WeaponFuse.Delayed, true)] + public void ARoundWhoseTargetLeavesTheZoneDoesNotBurst(WeaponFuse fuse, bool committedFirst) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 500f); + var bystanders = new Entity[] + { + AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300), + AddShip(e, e.Target.Position + float3(-8, 0, 12), 1f, 302) + }; + var damage = 0f; + var subscriptions = new List(); + foreach (var o in bystanders.Append(e.Shooter)) + { + subscriptions.Add(o.ArmorDamage.Subscribe(x => damage += x.damage)); + subscriptions.Add(o.HullDamage.Subscribe(x => damage += x)); + subscriptions.Add(o.ItemDamage.Subscribe(x => damage += x.damage)); + } + var resolved = new List(); + subscriptions.Add(e.Zone.ShotResolved.Subscribe(o => resolved.Add(o))); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(committedFirst ? 4.6f : .01f); + Assert.Equal(committedFirst, e.Zone.PendingShots[0].Committed); + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(6f); + + foreach (var s in subscriptions) s.Dispose(); + Assert.Empty(e.Zone.PendingShots); + Assert.True(bystanders.All(b => Alive(e, b)), "no bystander may take blast damage"); + Assert.Equal(0f, damage); + Assert.Equal(ShotResult.Miss, Assert.Single(resolved).Result); + } + + // A no-lock round outlives a shooter destroyed outright in flight, for every fuse: Death removes the shooter + // and the round still bursts at max range and resolves once as a Burst. + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void ANoLockRoundStillBurstsAfterItsShooterIsDestroyed(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var witness = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 300); + var resolved = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved.Add(o)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Shooter.DamageHull(1e9f); + e.Zone.Update(4f); + + Assert.False(Alive(e, e.Shooter)); + Assert.False(Alive(e, witness), "the round must still burst at max range"); + Assert.Equal(ShotResult.Burst, Assert.Single(resolved).Result); + } + // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. From 9c0029931a715ebdce5e194c83eb5b82fda12d29 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:27:54 +0200 Subject: [PATCH 085/116] Presentation follows the committed shot, not the selection (F7) EntityInstance handed effect managers the shooter's selected target whatever FireControl decided. A fused round whose target was invalid or out of arc flies at none, and GuidedProjectileManager dropped a launcher shot with no target entirely, so a real burst had no missile. - Zone.TryGetShot: a presentation reads the shot the simulation committed (its target, its burst point) by the ShotId OnFire hands it. - EntityInstance passes the shot's own target; a launcher round with none flies to the shot's burst point. Unity-only; compile-checked by reading. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/Gameplay/EntityInstance.cs | 9 ++++++++- .../Gameplay/Weapons/GuidedProjectileManager.cs | 9 +++++++-- Assets/Scripts/ServerShared/Zone.cs | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 4034c2e55..f3d977c8c 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -203,8 +203,11 @@ public class EntityInstance : MonoBehaviour // Cut 3 (docs/fire-control-cut.md): carries the ShotId FireControl.Fire assigned so the // effect manager (and whatever it spawns) can bind its presentation to Zone.ShotCommitted / // ShotResolved instead of deciding or applying anything itself. + // The target handed to the effect is the shot's own, not the shooter's selection: a fused round + // whose selected target was invalid, or out of arc, flies at none (FireControl.Fire) and the + // effect must not chase one. instantWeapon.OnFire += shotId => - _instantWeaponManagers[data].Fire(instantWeapon, item, this, entity.Target.Value != null && ZoneRenderer.EntityInstances.ContainsKey(entity.Target.Value) ? ZoneRenderer.EntityInstances[entity.Target.Value] : null, shotId); + _instantWeaponManagers[data].Fire(instantWeapon, item, this, ShotTarget(shotId), shotId); if (behavior is ChargedWeapon chargedWeapon) { @@ -336,6 +339,10 @@ public class EntityInstance : MonoBehaviour } } + private EntityInstance ShotTarget(int shotId) => + Entity.Zone.TryGetShot(shotId, out var shot) && shot.Target != null && + ZoneRenderer.EntityInstances.TryGetValue(shot.Target, out var instance) ? instance : null; + private void OnSensorPingEnd() { Destroy(_currentPing.transform.gameObject); diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index 51b62969e..748503967 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -17,12 +17,17 @@ public class GuidedProjectileManager : InstantWeaponEffectManager { if(weapon.Data is LauncherData launcher) { - if (target == null) return; + // A round the simulation fired at no target (FireControl.Fire: a fused round with no valid targeting + // data, or out of arc) flies to the point it bursts at; one with no target and no burst is not a shot. + PendingShot shot = default; + if (target == null && !(source.Entity.Zone.TryGetShot(shotId, out shot) && shot.Fuse != null)) return; + var burst = shot.BurstPosition.ToUnity(); var p = ProjectilePrototype.Instantiate(); p.ShotId = shotId; p.Source = source.transform; p.SourceEntity = source.Entity; - p.Target = target.transform; + if (target != null) p.Target = target.transform; + else p.TargetPosition = () => burst; p.Frequency = launcher.DodgeFrequency; var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 14ee6b6c0..9b34e6687 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -43,6 +43,21 @@ public class Zone private int _nextShotId; public int NextShotId() => ++_nextShotId; + // The committed shot a presentation was handed the id of by OnFire. It carries what the simulation decided + // -- the target it flies at (null for a round that carries none) and where it bursts -- so a presentation + // reads that rather than the shooter's current selection. + public bool TryGetShot(int shotId, out PendingShot shot) + { + foreach (var pending in PendingShots) + if (pending.ShotId == shotId) + { + shot = pending; + return true; + } + shot = default; + return false; + } + private List BeltUpdates = new List(); public float Time From 2d4474f859df6d09f78e2bd55c76a6d32a9842c6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:49:36 +0200 Subject: [PATCH 086/116] Record out-of-arc, arming-distance and pivot-arc rulings Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 5c4f7794c..755a5c707 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2638,6 +2638,16 @@ follow-up rulings, all given on 2026-09-30: - A round fired at a valid target that later leaves or becomes invalid does not burst. It resolves with no detonation. - Validity is judged on the targeting data at fire time. +- **Out of arc (operator, 2026-09-30):** a fused weapon whose selected target is out of arc still fires + (bomb fishing). It bursts at the target's range, clamped to max range, along the arc-clamped aim. + Non-fused weapons refuse. +- **Arming distance (operator, 2026-09-30):** a round never bursts closer than its blast radius to its fire + origin, and close bursts are pushed out to that distance. A pilot who flies into their own distant burst + takes the damage. Fire is refused when the live Range cannot clear BlastRadius. +- **Turret arc ownership (operator, 2026-09-30):** the simulation's arc is authoritative, and Unity barrels + derive their aim from it. The true owner of an arc is the **pivot**, not the hardpoint. The arc stays on + the hardpoint until the moddable-ships campaign adds pivots to `HullData`. Until then, a pivot carrying + several hardpoints turns within the **intersection** of their arcs. - **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go nowhere. From 24b4071b694bf9f5a1c9a16204c224b858cba88b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:05:14 +0200 Subject: [PATCH 087/116] Arming distance: no round bursts nearer than its blast radius past the shooter's hull Operator ruling 2026-09-30. A fused round's burst is at least BlastRadius beyond the far edge of the shooter's own hull, measured along the flight line against the hull as it stood at fire (HullAhead's exit). A close burst -- a target inside that distance, in or out of arc -- is pushed out along the aim and carries no target; a contact round is floored to it too. - The F3 trigger gate is deleted. Fire refuses a round (returns 0) whose arming distance exceeds Range, per round, where Range is read after Execute refreshed it; InstantWeapon and ConstantWeapon skip a refused round. - PFire's range, Inspect's range and the range clamp are planar (R7). - HullEntryDistance becomes HullAhead (entry and exit); the max(...,0) clamp on entry is gone, the arming floor covers it. - ShotOutcome.HasBurstPoint names the rounds that have a frozen burst point. - SafeAssert: the tests no longer hand xunit an Entity, PendingShot or ShotOutcome to format; a failing assertion had been overflowing the test host's stack. Co-Authored-By: Claude Sonnet 5 --- .../ServerShared/Behaviors/ConstantWeapon.cs | 6 +- .../ServerShared/Behaviors/InstantWeapon.cs | 5 +- .../Scripts/ServerShared/Behaviors/Weapon.cs | 3 - Assets/Scripts/ServerShared/FireControl.cs | 106 ++++--- .../FireAuthorityTests.cs | 4 +- .../FireControlCut11Tests.cs | 6 +- .../FireControlCut124Tests.cs | 277 ++++++++++++++---- .../FireControlCut5Tests.cs | 4 +- .../FireControlCut8Tests.cs | 4 +- tests/Aetheria.Shared.Tests/SafeAssert.cs | 37 +++ 10 files changed, 344 insertions(+), 108 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/SafeAssert.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 8a93c2a82..14d6b4820 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -91,7 +91,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // Cut 5 (docs/fire-control-cut.md, 5.3, Soul finding 9): StanceAllowsFire alone let a side-mounted beam // fire forward -- ArcAllowsFire joins it here, matching InstantWeapon.cs's player arc gate (Q2: manual and // programmatic are one truth, and a continuous weapon is no exception). - public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire && FuseAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; + public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon. public int DefaultPowerTier => PowerTiers.Low; @@ -99,7 +99,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override bool Execute(float dt) { base.Execute(dt); - if (_firing && !(StanceAllowsFire && ArcAllowsFire && FuseAllowsFire)) + if (_firing && !(StanceAllowsFire && ArcAllowsFire)) { // Safed: shooter has a target and hasn't declared hostility toward it, or the target no longer // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does). @@ -179,7 +179,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // argument included, when OnBeamShot has no subscriber (true today, R9's "no design uses it // yet"), so the roll would silently never happen. Evaluate it into a local first. var shotId = FireControl.Fire(this, Item, Entity, Damage * interval); - OnBeamShot?.Invoke(shotId); + if (shotId != 0) OnBeamShot?.Invoke(shotId); } } return true; diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 1cf398539..6de087ba0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -103,8 +103,6 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // through -- a player's action-bar Activate() reaches this exactly the same way Combat.cs's and // TurretController.cs's Activate() calls do. if (!ArcAllowsFire) return; - // A fused weapon whose Range no longer clears its blast would burst on its shooter. - if (!FuseAllowsFire) return; // If 1 ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case @@ -248,6 +246,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // 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); + // A fused round whose arming distance Range cannot reach is refused (FireControl.Fire returns 0): + // nothing was fired, so nothing sounds, wears or heats. + if (shotId == 0) continue; OnFire?.Invoke(shotId); if(!firedThisFrame) { diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 57340f8d9..47f2acf11 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -110,9 +110,6 @@ public abstract class Weapon : Behavior, IActivatedBehavior // FireControl owns the gate, including its one exemption: a fused weapon fires out of arc. public bool ArcAllowsFire => FireControl.ArcPermitsFire(this, Entity); - // A fused weapon whose current Range does not exceed its blast stays safed (FireControl.FuseCanArm). - public bool FuseAllowsFire => FireControl.FuseCanArm(this); - public Weapon(WeaponData data, EquippedItem item) : base(data, item) { _data = data; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 51fab98af..adf1721e0 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -79,10 +79,15 @@ public static class FireControl return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position); } - // A fused weapon whose Range, as it stands now (power supply and modifiers included), does not exceed its - // blast would burst on its own shooter at max range, so its trigger stays safed. Judged on the effective - // Range rather than the catalog's authored floor, which Evaluate can scale below. - public static bool FuseCanArm(Weapon weapon) => FuseOf(weapon.Item, out var blastRadius) == null || weapon.Range > blastRadius; + // Operator ruling 2026-09-30 (arming distance): a round never bursts closer than its blast radius to where + // it was fired, and the disc must clear the shooter's own hull as it stood at fire. So the burst is at least + // BlastRadius beyond the far edge of the shooter's hull along the flight line. Close bursts are pushed out + // along the aim to this distance; a pilot who flies into their own distant burst takes the damage. + private static float ArmingDistance(Entity shooter, float2 direction, float blastRadius) + { + HullAhead(shooter, shooter.Position.xz, direction, out _, out var exit); + return exit + blastRadius; + } // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The @@ -225,8 +230,10 @@ public static class FireControl // gate -- Fire reads it through this same overload for flightTime, and flightTime must reflect the // real distance even when a gate (not visible, out of arc) already prices the shot at PFire 0. The // gates below still short-circuit the rest of the shooter-side probability in the same order as before. + // Planar (R7: the simulation is 2D): the range gate, the burst reach and the range clamp all measure the + // same distance, so a target's height never moves one of them without the others. var toTarget = target.Position - source.Position; - range = length(toTarget); + range = length(toTarget.xz); if (!source.VisibleEntities.Contains(target)) return 0f; if (range < weapon.MinRange || range > weapon.Range) return 0f; @@ -316,7 +323,7 @@ public static class FireControl if (target == null) return diagnostic; var toTarget = target.Position - source.Position; - diagnostic.Range = length(toTarget); + diagnostic.Range = length(toTarget.xz); diagnostic.Visible = source.VisibleEntities.Contains(target); diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range; diagnostic.Locked = !(weapon is LockWeapon lockWeapon) || lockWeapon.IsLocked; @@ -443,10 +450,36 @@ public static class FireControl // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot // without a fuse keeps its target as before. + // Arming distance (operator ruling 2026-09-30): a round never bursts closer than its blast radius past the + // shooter's hull, so a burst that would land nearer -- a target close in, or out of arc and inside the + // radius -- is pushed out along the aim to that distance, and the round carries no target. A round whose + // arming distance exceeds the weapon's Range cannot be fired at all: it is refused here, per round, where + // Range is read after Execute refreshed it (a burst's later rounds and a power-starved Range included). var engaged = fuse != null && !(designated && inArc) ? null : target; var origin = source.Position; var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); + var arming = 0f; + var burstPosition = engaged != null ? engaged.Position : origin; + if (fuse != null && engaged != null) + { + // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is + // pulled back along its own line to Range. + var intercept = PredictedIntercept(weapon, source, engaged); + var interceptReach = length((intercept - origin).xz); + arming = ArmingDistance(source, travelDirection, blastRadius); + if (min(interceptReach, weapon.Range) < arming) engaged = null; + else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + } + if (fuse != null && engaged == null) + { + travelDirection = AimDirection(item, source); + arming = ArmingDistance(source, travelDirection, blastRadius); + var reach = max(designated ? fireRange : weapon.Range, arming); + burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; + } + if (arming > weapon.Range) return 0; + var targetVelocity = float3.zero; var targetPosition = origin; var flightTime = 0f; @@ -456,25 +489,14 @@ public static class FireControl targetPosition = engaged.Position; flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f; } - - var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; - - var burstPosition = targetPosition; - if (fuse != null && engaged != null) - { - // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is - // pulled back along its own line to Range. - var intercept = PredictedIntercept(weapon, source, engaged); - var reach = length((intercept - origin).xz); - burstPosition = reach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / reach) : intercept; - } else if (fuse != null) { - var reach = designated ? fireRange : weapon.Range; - burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; - flightTime = weapon.Velocity > .01f ? reach / weapon.Velocity : 0f; + var flown = length((burstPosition - origin).xz); + flightTime = weapon.Velocity > .01f ? flown / weapon.Velocity : 0f; } + var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; + var shot = new PendingShot { ShotId = zone.NextShotId(), @@ -504,6 +526,7 @@ public static class FireControl BurstPosition = burstPosition, Fuse = fuse, BlastRadius = blastRadius, + ArmingDistance = arming, Committed = false }; @@ -696,7 +719,11 @@ public static class FireControl var proximity = shot.Fuse == WeaponFuse.Proximity; var outcome = MakeOutcome(shot, hit ? ShotResult.Hit : proximity ? ShotResult.Burst : ShotResult.Miss, shielded, shieldBroken, cell, bearing, lateral, now); - if (proximity) outcome.BurstPoint = shot.BurstPosition.xz; + if (proximity) + { + outcome.BurstPoint = shot.BurstPosition.xz; + outcome.HasBurstPoint = true; + } return outcome; } @@ -708,8 +735,10 @@ public static class FireControl private static ShotOutcome CommitBurst(Zone zone, PendingShot shot, float now) { var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now); + outcome.HasBurstPoint = true; var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); var travelled = shot.Fuse == WeaponFuse.Contact ? ContactDistance(zone, shot, reach) : reach; + travelled = min(reach, max(travelled, shot.ArmingDistance)); if (travelled < reach) { outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; @@ -730,16 +759,18 @@ public static class FireControl foreach (var entity in zone.Entities) { if (entity == shot.Source) continue; - nearest = min(nearest, HullEntryDistance(entity, shot.FireOrigin.xz, shot.TravelDirection)); + if (HullAhead(entity, shot.FireOrigin.xz, shot.TravelDirection, out var entry, out _)) nearest = min(nearest, entry); } return nearest; } - // World distance from `origin` along the unit planar `direction` to the first metal cell of `entity`'s hull, - // or +infinity when the line meets none ahead of it. Read through Lane's own cell admission (CellsOnLine), in - // the entity's schematic frame: the line's lateral offset is its own, and cells wholly behind the origin - // are skipped. An origin already inside the hull is a distance of zero. - private static float HullEntryDistance(Entity entity, float2 origin, float2 direction) + // Where the line from `origin` along the unit planar `direction` meets `entity`'s hull, in world distance + // from `origin`: `entry` to the first metal cell ahead (+infinity when the line meets none, and negative when the + // origin is already inside the hull: the arming distance floors every caller's use of it) and `exit` to the far edge of the last one (zero when none). Read + // through Lane's own cell admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset + // is its own, and cells wholly behind the origin are skipped. Contact fuses read `entry`; a round's arming + // distance reads `exit` of its own shooter. + private static bool HullAhead(Entity entity, float2 origin, float2 direction, out float entry, out float exit) { var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; @@ -748,14 +779,19 @@ public static class FireControl var lateral = dot(start, Lateral(bearing)); var along = dot(start, bearing); + entry = float.PositiveInfinity; + exit = 0f; var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); try { var count = CellsOnLine(hullData, bearing, lateral, buffer); for (var i = 0; i < count; i++) - if (buffer[i].Exit > along) - return max(buffer[i].Entry - along, 0f) * cellSize; - return float.PositiveInfinity; + { + if (!(buffer[i].Exit > along)) continue; + if (float.IsPositiveInfinity(entry)) entry = (buffer[i].Entry - along) * cellSize; + exit = max(exit, (buffer[i].Exit - along) * cellSize); + } + return !float.IsPositiveInfinity(entry); } finally { @@ -1677,6 +1713,9 @@ public struct PendingShot public float3 BurstPosition; public WeaponFuse? Fuse; public float BlastRadius; + // How far along its flight line a fused round may first burst: BlastRadius past the far edge of the + // shooter's hull, frozen at Fire (a round outlives its shooter's pose, too). + public float ArmingDistance; // Frozen with the rest of the shooter's decision: where the round left from. A round outlives its shooter, so // nothing at resolution reads the shooter's position. @@ -1785,9 +1824,10 @@ public sealed class ShotOutcome // (Burst, at BurstPoint). Hit is derived from it, not stored beside it. public ShotResult Result; public bool Hit => Result == ShotResult.Hit; - // World planar point a proximity or no-target round detonates at, frozen by the commit. Meaningless for any - // other round. + // World planar point a proximity or no-target round detonates at, frozen by the commit; HasBurstPoint says + // whether this round has one (a contact or delayed round detonates where it hits, not at a frozen point). public float2 BurstPoint; + public bool HasBurstPoint; public bool Shielded; // Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active, // and unable to CanTakeHit this shot is decided broken right here, so Apply performs Break() rather than diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 3d3568b93..921961b15 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -331,7 +331,7 @@ public sealed class FireAuthorityTests : IDisposable e.Zone.Update(10f); // well past arrival Assert.Equal(before, e.Target.Hull.Durability); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); } // R4: ShotCommitted fires exactly CommitHorizon before ShotResolved, once per shot. Mutation: publish only @@ -420,7 +420,7 @@ public sealed class FireAuthorityTests : IDisposable e.Zone.Update(.016f); // one ordinary tick Assert.Equal(new[] { "committed", "resolved" }, order); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); } // R10/Q6: a shot's parameters freeze when the trigger is pulled. Changing the weapon's authored Damage diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index 2a65fda64..ab2966025 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -305,19 +305,19 @@ public sealed class FireControlCut11Tests : IDisposable var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(1f); - Assert.Empty(committed); + SafeAssert.Empty(committed); e.Zone.Entities.Remove(e.Target); e.Zone.Update(1f); - foreach (var outcome in new[] { Assert.Single(committed), Assert.Single(resolved) }) + foreach (var outcome in new[] { SafeAssert.Only(committed), SafeAssert.Only(resolved) }) { Assert.Equal(shotId, outcome.ShotId); Assert.False(outcome.Hit); Assert.False(outcome.Shielded, "a shot at nothing did not hit a shield"); Assert.False(outcome.ShieldBroken, "a shot at nothing did not break a shield"); } - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); } // ---- A targeting system's Tracking is what forgives a target that moves off its predicted line. ---- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 3c8f38db3..f6b91f97e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -543,10 +543,10 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.Equal(1, resolved); - Assert.DoesNotContain(near, e.Zone.Entities); // fixture: the blast really was lethal to a ship mid-pass - Assert.Contains(far, e.Zone.Entities); + SafeAssert.NotIn(e.Zone, near); // fixture: the blast really was lethal to a ship mid-pass + SafeAssert.In(e.Zone, far); Assert.True(targetDamage > 0f, "fixture: the target must be inside the blast"); var afterFirstTick = targetDamage; @@ -588,7 +588,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(new[] { 1, 1, 1 }, hullHits); Assert.Equal(new[] { 1, 1, 1 }, deaths); foreach (var s in ships) Assert.False(e.Zone.Entities.Contains(s), "a killed ship must have left the zone"); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.Equal(1, resolved); } @@ -1149,7 +1149,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.NotNull(outcome); Assert.False(outcome.Hit); // fixture: a guaranteed miss Assert.Equal(ShotResult.Miss, outcome.Result); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.Equal(0, events); Assert.Equal(before, e.Target.Hull.Durability); } @@ -1189,7 +1189,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.Equal(0f, shooterHit); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.False(e.Zone.Entities.Contains(onLine), "the blast must reach the burst point (fixture: 1 durability, so any damage kills it)"); Assert.True(e.Zone.Entities.Contains(offLine), "the blast must not reach 42 units off the aim line"); } @@ -1205,7 +1205,7 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.Equal(NoLockRange / 20f, shot.ArrivalTime - shot.FireTime, 4); var burst = AimLine(e); Assert.Equal(burst.x, shot.BurstPosition.x, 3); @@ -1224,7 +1224,7 @@ public sealed class FireControlCut124Tests : IDisposable using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.Equal(e.Target.Position.x, shot.BurstPosition.x, 3); Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); e.Zone.Update(.01f); @@ -1316,7 +1316,7 @@ public sealed class FireControlCut124Tests : IDisposable var targetBefore = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.True(shot.Target == null, "an invalid target must not ride with the round"); // not Assert.Null: a failure would format the entity graph and overflow the stack Assert.Equal(burst.x, shot.BurstPosition.x, 3); Assert.Equal(burst.z, shot.BurstPosition.z, 3); @@ -1339,7 +1339,7 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.True(shot.Target == e.Target, "a locked, visible, in-range, in-arc target rides with the round"); Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); } @@ -1371,7 +1371,7 @@ public sealed class FireControlCut124Tests : IDisposable var targetBefore = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.True(shot.Target == null, "a target the weapon cannot bear on must not ride with the round"); var burst = PointAlong(e, edge, 40f); Assert.Equal(burst.x, shot.BurstPosition.x, 3); @@ -1408,18 +1408,140 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(fires ? 1 : 0, committed); } - // A fused weapon safes itself while its effective Range does not exceed its blast, because a round bursting at - // max range would land on its own shooter. The gate reads the Range the weapon has NOW: the third row is - // Soul's probe (authored Range 100 clears a blast of 30, but at a power supply of 0.1 Range is 10), and the - // first two pin the boundary itself (equal refuses). Kills: reading the authored figure instead of the - // runtime Range; `>=` for `>`; dropping the gate from InstantWeapon.Trigger. + // ==== Operator ruling 2026-09-30, batch 4: the arming distance ==== + // + // A round never bursts closer than its blast radius past the shooter's own hull. The fixture's shooter hull is + // a 5x5 block of 2-unit cells centred on the shooter, so its far edge is 5 units out along the axis and + // 5 * sqrt(2) along a diagonal; with a blast of 30 the arming distance is 35 (or 30 + 7.07). + + private const float ShooterHullReach = 5f; + + private static float Planar(float3 from, float3 to) => length((to - from).xz); + + // A target inside the arming distance is not detonated on: the round flies along the aim and bursts at the + // arming distance, carrying no target, and the shooter that stood beside it is untouched (the probes measured + // 7.07 of self-damage for a target at 5, before this). The out-of-arc row is a target 10 away behind the + // mount, inside the blast radius, with the mount flipped so the arc is closed. Kills: no arming distance; a + // burst that still rides the target; an arming distance measured from the shooter's centre instead of the far + // edge of its hull (the burst would land 5 units short and the shooter would still be inside the disc). [Theory] - [InlineData(30f, 30f, false, false)] - [InlineData(31f, 30f, false, true)] - [InlineData(100f, 30f, true, false)] - public void AFusedWeaponWhoseRangeDoesNotExceedItsBlastStaysSafed(float range, float blast, bool starvedOfPower, bool fires) + [InlineData(5f, false)] + [InlineData(10f, true)] + public void ACloseBurstIsPushedOutAlongTheAimToTheArmingDistance(float targetRange, bool outOfArc) { - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: blast, damage: 100f, + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: targetRange); + var aim = float2(0, 1); + if (outOfArc) + { + e.Shooter.Direction = float2(0, -1); + aim = float2(0, -1); + } + Aim(e, aim); + var burst = PointAlong(e, aim, 30f + ShooterHullReach); + var hurt = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => hurt += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => hurt += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => hurt += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == null, "a burst pushed out to the arming distance carries no target"); + Assert.Equal(burst.x, shot.BurstPosition.x, 2); + Assert.Equal(burst.z, shot.BurstPosition.z, 2); + Assert.Equal(35f, shot.ArmingDistance, 2); + e.Zone.Update(.01f); + + Assert.Equal(0f, hurt); + } + + // The far edge is read along the flight line, not along an axis: aimed down a diagonal the 5x5 hull reaches + // 5 * sqrt(2) = 7.07 units, so the burst sits at 30 + 7.07. A close in-arc target makes the arming distance + // the burst reach. Kills: an extent read along the mount or the hull's own axes. + [Fact] + public void TheArmingDistanceIsMeasuredAlongTheFlightLine() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, normalize(float2(1, 1))); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(30f + ShooterHullReach * sqrt(2f), Planar(e.Shooter.Position, shot.BurstPosition), 1); + } + + // A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its + // intercept. Kills: demoting every fused round to the aim. + [Fact] + public void ATargetBeyondTheArmingDistanceStillRidesWithTheRound() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 40f); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "a target the disc clears the shooter to reach is still the round's target"); + Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); + } + + // A contact round stops on the first hull it crosses, but never nearer than its arming distance: a blocker + // whose near face is 6 units out (centred 10) is passed, and the round bursts at 5 + 4 = 9. Kills: a contact + // point taken as the hull face regardless of arming. + [Fact] + public void AContactRoundNeverStopsNearerThanItsArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + AddShip(e, PointAlong(e, float2(0, 1), 10f), 1f, 300); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.Equal(e.Shooter.Position.z + ShooterHullReach + 4f, outcome.BurstPoint.y, 2); + } + + // A round fired from inside another hull is already in metal: its contact point is its arming distance. The + // host is a column with a 30-unit gap after the cell the muzzle sits in (cells 0-2 and 18-21, the muzzle in + // cell 2), so a contact search that skips the cell the origin is inside finds the far block instead of the + // muzzle's own. Kills: reading a cell's Entry for its Exit (Soul's M7). + [Fact] + public void AContactRoundFiredFromInsideAnotherHullBurstsAtTheArmingDistance() + { + var column = new Shape(1, 22); + foreach (var y in new[] { 0, 1, 2, 18, 19, 20, 21 }) column[new int2(0, y)] = true; + var e = Build(TestSettings(), column, velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var offset = e.Target.ToWorldPoint(float2(0, 2)) - e.Target.Position.xz; + e.Target.Position = float3(e.Shooter.Position.x - offset.x, 0, e.Shooter.Position.z - offset.y); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(e.Shooter.Position.z + ShooterHullReach + 4f, committed.BurstPoint.y, 2); + } + + // A fused round is refused, per round and at the moment Fire reads Range, when its arming distance exceeds + // that Range: 34.5 against 35 refuses, 35.01 fires, and a power-starved Range (authored 100, forced to about + // 10 the way Soul's probe did after the trigger has passed) refuses although the authored figure clears + // it. Fire returns 0 and queues nothing. Kills: a gate that reads the authored Range; `>=` for `>`. + [Theory] + [InlineData(34.5f, false, false)] + [InlineData(35.01f, false, true)] + [InlineData(100f, true, false)] + public void AFusedRoundWhoseArmingDistanceExceedsRangeIsRefusedByFire(float range, bool starvedOfPower, bool fires) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: range, rangeTerm: starvedOfPower ? StatSource.PowerSupply : (StatSource?) null); e.Shooter.Target.Value = null; Aim(e, float2(0, 1)); @@ -1428,18 +1550,75 @@ public sealed class FireControlCut124Tests : IDisposable typeof(EquippedItem).GetProperty("PowerSupply").GetSetMethod(true).Invoke(e.WeaponItem, new object[] { .1f }); e.Shooter.Resolver.InvalidateSource(e.WeaponItem, StatSource.PowerSupply); e.Weapon.GetType().GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(e.Weapon, null); - Assert.True(e.Weapon.Range < blast, "fixture: the runtime Range must have fallen below the blast"); + Assert.True(e.Weapon.Range < 35f, "fixture: the runtime Range must have fallen below the arming distance"); } + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + Assert.Equal(fires, shotId != 0); + Assert.Equal(fires ? 1 : 0, e.Zone.PendingShots.Count); + } + + // The same refusal through the weapon a player uses: a refused round sounds nothing (OnFire never runs) and + // queues nothing, and the same weapon with Range to spare fires once. Kills: Execute still announcing a + // refused round (Fire's 0 handed to the presentation as a shot id). + [Theory] + [InlineData(34.5f, 0)] + [InlineData(40f, 1)] + public void ARefusedRoundIsNotAnnounced(float range, int fired) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: range); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var announced = 0; var committed = 0; + e.Weapon.OnFire += _ => announced++; using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++); - Assert.Equal(fires, e.Weapon.FuseAllowsFire); e.Weapon.Activate(); - // The starved row must not run a full Update first: the power bus would restore the supply we forced. - // Activate queues the burst inside Trigger, so one short tick is enough for a commit if it fires. for (var i = 0; i < 5; i++) e.Zone.Update(.01f); - Assert.Equal(fires ? 1 : 0, committed); + Assert.Equal(fired, announced); + Assert.Equal(fired, committed); + } + + // Planar, everywhere the gate, the clamp and the burst meet (R7). A target 50 units above the plane at a + // planar range of 40 is 64 units away in three dimensions: inside a Range of 60 by the plane, outside it by + // the straight line. It is designated, rides with the round, and the HUD's diagnostic agrees. Kills: a 3D + // length in PFire's range or Inspect's. + [Fact] + public void ATargetsHeightDoesNotMoveTheRangeGate() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 60f, targetRange: 40f); + e.Target.Position += float3(0, 50, 0); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "the target is inside Range in the plane, so it is valid data"); + Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); + var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.True(diagnostic.InRange, "the HUD's range gate is planar too"); + Assert.Equal(40f, diagnostic.Range, 2); + } + + // The F4 clamp is planar as well: the target's height does not change how far the burst is pulled back. + [Fact] + public void TheRangeClampIgnoresTheTargetsHeight() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 100f, targetRange: 90f); + e.Target.Position += float3(0, 200, 0); + e.Target.Velocity = float2(6f, 8f); + var intercept = FireControl.PredictedIntercept(e.Weapon, e.Shooter, e.Target); + Assert.True(Planar(e.Shooter.Position, intercept) > 100f, "fixture: the intercept must lie beyond Range in the plane"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(100f, Planar(e.Shooter.Position, shot.BurstPosition), 2); } // Nothing bursts beyond max range. A target receding faster than the round would be led past Range by the @@ -1459,7 +1638,7 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.Equal(origin.x + toIntercept.x * 100f, shot.BurstPosition.x, 2); Assert.Equal(origin.z + toIntercept.y * 100f, shot.BurstPosition.z, 2); } @@ -1509,6 +1688,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(results.Any(o => o.Result == ShotResult.Hit), "the stationary, fully-accurate target must be hit at least once"); Assert.True(results.All(o => abs(o.BurstPoint.x - e.Target.Position.x) < 1e-3f && abs(o.BurstPoint.y - e.Target.Position.z) < 1e-3f), "a hit detonates at the intercept too, not at the origin"); + Assert.True(results.All(o => o.HasBurstPoint), "a proximity outcome names its burst point, hit or not: presentation retargets to it"); Assert.True(damage > 0f, "the blasts must have landed"); } @@ -1527,6 +1707,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.NotNull(resolved); Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.False(resolved.HasBurstPoint, "a contact or delayed round has no frozen burst point"); Assert.True(Alive(e, bystander), "a missed contact or delayed round detonates nowhere"); } @@ -1540,7 +1721,7 @@ public sealed class FireControlCut124Tests : IDisposable var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.Equal(shot.FireTime + 4.5f, shot.CommitTime, 3); e.Zone.Update(.01f); Assert.False(e.Zone.PendingShots[0].Committed, "a targeted round reads the target's facing late"); @@ -1625,7 +1806,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(Alive(e, blocker)); e.Zone.Update(1.1f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.False(Alive(e, blocker)); } @@ -1650,7 +1831,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Entities.Remove(e.Shooter); e.Zone.Update(4f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.False(Alive(e, witness), "the round must still burst at max range"); } @@ -1668,7 +1849,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Entities.Remove(e.Shooter); e.Zone.Update(6f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.Equal(5.65884256f, damage, 3); } @@ -1690,7 +1871,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Entities.Remove(e.Shooter); e.Zone.Update(6f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.Equal(12, resolved.Count); Assert.True(resolved.Any(o => o.Hit), "at least one round must land on the stationary target"); Assert.Equal(resolved.Count(o => o.Hit), struck); @@ -1714,6 +1895,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.NotNull(committed); Assert.Equal(ShotResult.Burst, committed.Result); + Assert.True(committed.HasBurstPoint); Assert.False(committed.Hit); var burst = PointAlong(e, normalize(float2(2, 1)), NoLockRange); Assert.Equal(burst.x, committed.BurstPoint.x, 3); @@ -1816,7 +1998,7 @@ public sealed class FireControlCut124Tests : IDisposable blocker.Position = PointAlong(e, float2(0, 1), 25f); e.Zone.Update(4f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.True(Alive(e, blocker), "the round flew through the hull that arrived after Fire and burst at max range"); } @@ -1865,27 +2047,6 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(origin.z + (withBlocker ? 21f : 60f), committed.BurstPoint.y, 2); } - // A round fired from inside another hull bursts at the muzzle: the origin is already in metal, a distance of - // zero. Kills: reading a cell's Entry for its Exit (Soul's M7: a cell the origin sits in is skipped and the - // round flies on); dropping the clamp at zero (Soul's M8: the negative distance puts the burst behind the - // muzzle). - [Fact] - public void AContactRoundFiredFromInsideAnotherHullBurstsAtTheMuzzle() - { - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; - Aim(e, float2(0, 1)); - AddShip(e, e.Shooter.Position + float3(0.5f, 0, 0.5f), 1f, 300); - ShotOutcome committed = null; - using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); - - FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - e.Zone.Update(.01f); - - Assert.Equal(e.Shooter.Position.x, committed.BurstPoint.x, 2); - Assert.Equal(e.Shooter.Position.z, committed.BurstPoint.y, 2); - } - // A hull wholly behind the muzzle is not on the round's way. Kills: admitting cells whose exit lies behind // the origin (the round would burst at the hull behind it, at distance zero). [Fact] @@ -1941,10 +2102,10 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(6f); foreach (var s in subscriptions) s.Dispose(); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.True(bystanders.All(b => Alive(e, b)), "no bystander may take blast damage"); Assert.Equal(0f, damage); - Assert.Equal(ShotResult.Miss, Assert.Single(resolved).Result); + Assert.Equal(ShotResult.Miss, SafeAssert.Only(resolved).Result); } // A no-lock round outlives a shooter destroyed outright in flight, for every fuse: Death removes the shooter @@ -1968,7 +2129,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.False(Alive(e, e.Shooter)); Assert.False(Alive(e, witness), "the round must still burst at max range"); - Assert.Equal(ShotResult.Burst, Assert.Single(resolved).Result); + Assert.Equal(ShotResult.Burst, SafeAssert.Only(resolved).Result); } // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre @@ -2116,7 +2277,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(ShotResult.Hit, outcome.Result); Assert.True(armorEvents > 0); var shot = e.Zone.PendingShots.Count == 0 ? null : (PendingShot?) e.Zone.PendingShots[0]; - Assert.Null(shot); // resolved and removed -- confirms it went through Apply's null-fuse path to completion + Assert.True(!shot.HasValue, "the shot must be resolved and removed"); // resolved and removed -- confirms it went through Apply's null-fuse path to completion } // ARadiusWithoutAFuseIsADirectHit (Soul F1): the symmetric inert half -- BlastRadius > 0 but the catalog diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index 525a24ce6..e1366721e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -621,12 +621,12 @@ public sealed class FireControlCut5Tests : IDisposable var e = Build(TestSettings(), damage: 10000, velocity: 0, accuracy: 1, resolution: 1, spread: 0, hullDurability: 5, armor: 0); // one hit is lethal - Assert.Contains(e.Target, e.Zone.Entities); + SafeAssert.In(e.Zone, e.Target); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.1f); Assert.True(e.Target.Hull.Durability < .01f); - Assert.DoesNotContain(e.Target, e.Zone.Entities); + SafeAssert.NotIn(e.Zone, e.Target); } // ---- 5.7: delete the two carried-and-unread fields (Soul finding 12). ---- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index 81256f3ec..96fb785d1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -293,10 +293,10 @@ public sealed class FireControlCut8Tests : IDisposable e.Zone.Update(.1f); Assert.True(e.Target.Hull.Durability < .01f); - Assert.DoesNotContain(e.Target, e.Zone.Entities); + SafeAssert.NotIn(e.Zone, e.Target); Assert.False(e.Target.Active); Assert.Empty(e.Target.EntityInfoGathered); - Assert.Empty(e.Target.VisibleEntities); + Assert.True(e.Target.VisibleEntities.Count == 0, "the target's visible set must be empty"); } // ---- Ordering risk (8.1's own callout): EntityInstance's loot-drop subscription (EntityInstance.cs:292-325) diff --git a/tests/Aetheria.Shared.Tests/SafeAssert.cs b/tests/Aetheria.Shared.Tests/SafeAssert.cs new file mode 100644 index 000000000..2ab52d6bd --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SafeAssert.cs @@ -0,0 +1,37 @@ +/* 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 Xunit; + +// Assertions over collections of entities and shots. xunit formats a collection's contents into its failure +// message, and an Entity, a PendingShot or a ShotOutcome drags the whole zone graph in with it: the formatter +// recurses until the test host dies of a stack overflow, which a mutation tool scores as a kill of every test +// that was running. A failure here must be an ordinary red naming the test, so these compare counts and +// identities and never hand xunit an entity. +internal static class SafeAssert +{ + public static void NoShots(Zone zone) => + Assert.True(zone.PendingShots.Count == 0, $"{zone.PendingShots.Count} shot(s) still pending"); + + public static PendingShot OnlyShot(Zone zone) + { + Assert.True(zone.PendingShots.Count == 1, $"expected exactly one pending shot, found {zone.PendingShots.Count}"); + return zone.PendingShots[0]; + } + + public static T Only(List list) + { + Assert.True(list.Count == 1, $"expected exactly one {typeof(T).Name}, found {list.Count}"); + return list[0]; + } + + public static void Empty(List list) => Assert.True(list.Count == 0, $"expected no {typeof(T).Name}, found {list.Count}"); + + public static void In(Zone zone, Entity entity, string why = "entity should be in the zone") => + Assert.True(zone.Entities.Contains(entity), why); + + public static void NotIn(Zone zone, Entity entity, string why = "entity should have left the zone") => + Assert.False(zone.Entities.Contains(entity), why); +} From 543edee35834fc67caa3f4365cd5423999c1ba05 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:05:15 +0200 Subject: [PATCH 088/116] Guided rounds follow the committed burst and resolution; no-target launcher no longer throws - GuidedProjectileManager passed target.transform to OnFireGuided for a no-target fused round: a NullReferenceException inside InstantWeapon.Execute after the shot was queued, which skipped audio, wear, heat and visibility and unwound Zone.Update. The tuple's transform is null-safe now, and the trackmissile console command falls back to the missile. - Presentation follows the commit rather than predicting it. The manager binds each guided round to Zone.ShotCommitted (retarget to the burst point the commit names, so a Contact round bursts at its hull and a Range-clamped burst is not overshot) and ShotResolved (burst there, then). The binding is disposed when the projectile dies, so a pooled round does not accumulate them. - Beams and barrel-fired projectiles stay with F5. Unity-only; compile-checked by reading. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 2 +- .../Gameplay/Weapons/GuidedProjectile.cs | 27 +++++++++++++++++++ .../Weapons/GuidedProjectileManager.cs | 16 ++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index bc9bce8a2..3f69563ed 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -535,7 +535,7 @@ public class ActionGameManager : MonoBehaviour missileManager.OnFireGuided.Where(x => x.source == _currentEntity).Take(1).Subscribe(x => { FollowCamera.Follow = x.missile.transform; - FollowCamera.LookAt = x.target; + FollowCamera.LookAt = x.target ? x.target : x.missile.transform; x.missile.OnKill += () => { FollowCamera.LookAt = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint; diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index 9d9b02070..b5fdc47da 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -48,6 +48,31 @@ public class GuidedProjectile : MonoBehaviour public event Action OnKill; + // The simulation's verdict on this round, bound by GuidedProjectileManager to Zone.ShotCommitted and + // ShotResolved for its ShotId: this object flies and looks, and neither decides where the round bursts nor when. + public IDisposable Binding { get; set; } + + // The commit named a burst point: fly to it, whatever this round was homing on (a target it would have + // passed, or a Range clamp it would have overshot). + public void BurstAt(Vector3 point) + { + Target = null; + TargetPosition = () => point; + } + + // The resolution: the round bursts now, at the simulation's burst point when it has one. + public void Resolve(Vector3? point) + { + if (!_alive || !_active) return; + if (point.HasValue) transform.position = point.Value; + if (HitEffect != null) + { + var ht = HitEffect.Instantiate(); + ht.position = transform.position; + } + StartCoroutine(Kill()); + } + void OnEnable() { _active = _alive = true; @@ -170,6 +195,8 @@ public class GuidedProjectile : MonoBehaviour IEnumerator Kill() { + Binding?.Dispose(); + Binding = null; _active = false; _alive = false; Particles.Stop(true, ParticleSystemStopBehavior.StopEmitting); diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index 748503967..2e41570ef 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -9,6 +9,17 @@ using static CultMath.math; public class GuidedProjectileManager : InstantWeaponEffectManager { + // Presentation follows the committed shot (FireControl): the round retargets to the burst point the commit + // names and bursts when the shot resolves, rather than predicting either from its own homing. + private static void Bind(GuidedProjectile p, Zone zone, int shotId) + { + Vector3 At(ShotOutcome o) => new Vector3(o.BurstPoint.x, p.transform.position.y, o.BurstPoint.y); + var bindings = new CompositeDisposable(); + zone.ShotCommitted.Where(o => o.ShotId == shotId && o.HasBurstPoint).Subscribe(o => p.BurstAt(At(o))).AddTo(bindings); + zone.ShotResolved.Where(o => o.ShotId == shotId).Subscribe(o => p.Resolve(o.HasBurstPoint ? At(o) : (Vector3?) null)).AddTo(bindings); + p.Binding = bindings; + } + public Prototype ProjectilePrototype; public Subject<(Entity source, Transform target, GuidedProjectile missile)> OnFireGuided = new Subject<(Entity source, Transform target, GuidedProjectile missile)>(); @@ -39,7 +50,9 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.Velocity = barrel.forward * weapon.Velocity; p.Thrust = item.Evaluate(launcher.Thrust); p.TopSpeed = item.Evaluate(launcher.MissileVelocity); - OnFireGuided.OnNext((source.Entity, target.transform, p)); + Bind(p, source.Entity.Zone, shotId); + // A round with no target (a fused round fired at none) has no transform to hand a listener. + OnFireGuided.OnNext((source.Entity, target != null ? target.transform : null, p)); } else if(weapon.Data is GuidedWeaponData guidance) { @@ -59,6 +72,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.Thrust = item.Evaluate(guidance.Thrust); p.TopSpeed = item.Evaluate(guidance.MissileVelocity); p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.LookDirection).ToUnity(); + Bind(p, source.Entity.Zone, shotId); } else Debug.LogError($"Weapon {item.Data.Name} linked to {name} effect, but is not a Launcher!"); } From 14f666347c34a599db6aebd23daedb7baa92b924 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:27:47 +0200 Subject: [PATCH 089/116] Pin the arming-distance boundary: a Range equal to it fires The clean mutation run left one survivor, `>=` for `>` in Fire's refusal. Co-Authored-By: Claude Sonnet 5 --- .../FireControlCut124Tests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index f6b91f97e..cb50782d7 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1559,6 +1559,25 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(fires ? 1 : 0, e.Zone.PendingShots.Count); } + // The boundary itself: a Range exactly equal to the arming distance fires (the disc is tangent to the hull, + // which delivers nothing). The arming distance is read from a probe round on the same fixture, so the + // equality is exact rather than a decimal that float arithmetic could tip. Kills: `>=` for `>`. + [Fact] + public void ARangeExactlyEqualToTheArmingDistanceFires() + { + var probe = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 60f); + probe.Shooter.Target.Value = null; + Aim(probe, float2(0, 1)); + FireControl.Fire(probe.Weapon, probe.WeaponItem, probe.Shooter); + var arming = SafeAssert.OnlyShot(probe.Zone).ArmingDistance; + + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: arming); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + + Assert.True(FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter) != 0, "a Range equal to the arming distance must fire"); + } + // The same refusal through the weapon a player uses: a refused round sounds nothing (OnFire never runs) and // queues nothing, and the same weapon with Range to spare fires once. Kills: Execute still announcing a // refused round (Fire's 0 handed to the presentation as a shot id). From d6f1e192516194d15c9a9300c9b1752e412edc04 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:42:49 +0200 Subject: [PATCH 090/116] Record AI per-weapon firing, HUD outcome forecast, and free refusal rulings Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 755a5c707..66f0b68e7 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2648,6 +2648,14 @@ follow-up rulings, all given on 2026-09-30: derive their aim from it. The true owner of an arc is the **pivot**, not the hardpoint. The arc stays on the hardpoint until the moddable-ships campaign adds pivots to `HullData`. Until then, a pivot carrying several hardpoints turns within the **intersection** of their arcs. +- **AI and turrets (operator, 2026-09-30):** each weapon decides for itself, not the group's first weapon. + A fused weapon fires whenever its target is designated (visible, in range, locked), whatever the arc. A + non-fused weapon keeps the hit-chance threshold. The AI never fires at nothing, so AI does not bomb-fish. +- **HUD forecast (operator, 2026-09-30):** for a fused weapon, the forecast shows the outcome (Direct, Burst + at N m, Refused). `Fire` and `Inspect` compute it with the same helper. Direct-hit weapons keep the + percentage. +- **A refused round is free (operator, 2026-09-30):** it spends no ammo or energy, and makes no sound, heat, + wear or visibility. - **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go nowhere. From 96c2f6ba8d0c2b229c774135510f07b6a85343cf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:28:06 +0200 Subject: [PATCH 091/116] Record the literal arming distance and demoted-round rulings Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 66f0b68e7..0823120ee 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2641,8 +2641,13 @@ follow-up rulings, all given on 2026-09-30: - **Out of arc (operator, 2026-09-30):** a fused weapon whose selected target is out of arc still fires (bomb fishing). It bursts at the target's range, clamped to max range, along the arc-clamped aim. Non-fused weapons refuse. -- **Arming distance (operator, 2026-09-30):** a round never bursts closer than its blast radius to its fire - origin, and close bursts are pushed out to that distance. A pilot who flies into their own distant burst +- **Arming distance (operator, 2026-09-30; refined the same day):** it is literal. A round never bursts + closer than `BlastRadius` to the ship's centre at fire time (`FireOrigin`), whatever the hull size or the + aim. Close bursts are pushed out to that distance, and fire is refused when `Range < BlastRadius`. A large + hull can take splash from its own close bursts, and that is accepted. +- **Demoted rounds keep their target (operator, 2026-09-30):** a round fired at a valid target closer than + the blast radius flies to the arming point but stays targeted. If the target leaves before resolution, + the round does not burst. A pilot who flies into their own distant burst takes the damage. Fire is refused when the live Range cannot clear BlastRadius. - **Turret arc ownership (operator, 2026-09-30):** the simulation's arc is authoritative, and Unity barrels derive their aim from it. The true owner of an arc is the **pivot**, not the hardpoint. The arc stays on From a193853d34d07bf9152e454bf8351bcbb070f947 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:36:59 +0200 Subject: [PATCH 092/116] Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 7 +- .../ServerShared/Agents/States/Combat.cs | 9 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 15 +- .../ServerShared/Behaviors/InstantWeapon.cs | 15 +- .../Behaviors/TurretController.cs | 10 +- Assets/Scripts/ServerShared/FireControl.cs | 278 ++++++---- .../FireControlCut124Tests.cs | 142 +++-- .../FireControlPerWeaponTests.cs | 503 ++++++++++++++++++ 8 files changed, 803 insertions(+), 176 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 3f69563ed..040bd4644 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1337,6 +1337,11 @@ public class ActionGameManager : MonoBehaviour ? "target: none" : $"gates vis {d.Visible} range {d.InRange} arc {d.InArc} lock {d.Locked}"; + // A fused weapon's forecast is its outcome, not a hit chance: the burst point or the refusal. + var outcomeLine = d.Outcome == FireOutcome.Burst ? $"burst at {d.BurstReach:F0}" + : d.Outcome == FireOutcome.Refused ? "refused: arming distance exceeds range" + : $"base {d.PBase:P1}"; + DebugInfoText.text = $"FIRE CONTROL - {selectedItem.Data.Name}\n" + $"{gates}\n" + @@ -1344,7 +1349,7 @@ public class ActionGameManager : MonoBehaviour $"info {d.Info:F3}/{d.InfoDemandCeiling:F3} sensor {d.PSensor:F3}\n" + $"accuracy {d.Accuracy:F3} spread {d.PSpread:F3} hull {d.POnHull:F3}\n" + $"precision {d.Precision:F3} tracking {d.Tracking:F1}\n" + - $"base {d.PBase:P1}\n" + + $"{outcomeLine}\n" + $"{pendingLine}\n" + lastLine; } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 019198f36..41e2511da 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -111,13 +111,12 @@ public class CombatState : BaseState toTarget = normalize(predictedPosition - _agent.Ship.Position); } - // Cut 3: replaces Cut 1's bare InArc, which HitProbability already subsumes (it returns zero out - // of arc) -- this is the AI's own "worth it" heuristic layered on top of the shared gate, not a - // second gate. - var shouldFire = FireControl.HitProbability(testWeapon, _agent.Ship, target) >= _agent.Settings.AgentMinHitProbability; + // 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 + // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (shouldFire) + if (FireControl.AgentFires(weapon, _agent.Ship, target)) weapon.Activate(); else if (weapon.Firing) weapon.Deactivate(); diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 14d6b4820..704635678 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -91,7 +91,13 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // Cut 5 (docs/fire-control-cut.md, 5.3, Soul finding 9): StanceAllowsFire alone let a side-mounted beam // fire forward -- ArcAllowsFire joins it here, matching InstantWeapon.cs's player arc gate (Q2: manual and // programmatic are one truth, and a continuous weapon is no exception). - public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; + // Operator ruling 2026-09-30: a refused round is free, and a beam is a stream of rounds, so a fused beam whose + // arming distance exceeds its Range fires nothing and draws nothing -- no power, ammo, wear, heat or + // visibility. FireControl.Refuses is the one arming test; it reads the Range Execute last refreshed, the same + // one-tick lag every PowerRequest reads its stats with. + private bool MayFire => StanceAllowsFire && ArcAllowsFire && !FireControl.Refuses(this, Entity); + + public float PowerRequest(float dt) => _firing && MayFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon. public int DefaultPowerTier => PowerTiers.Low; @@ -99,10 +105,11 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override bool Execute(float dt) { base.Execute(dt); - if (_firing && !(StanceAllowsFire && ArcAllowsFire)) + if (_firing && !MayFire) { // Safed: shooter has a target and hasn't declared hostility toward it, or the target no longer - // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does). + // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does), or a fused round is + // refused. _firing = false; OnStopFiring?.Invoke(); return false; @@ -179,7 +186,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // argument included, when OnBeamShot has no subscriber (true today, R9's "no design uses it // yet"), so the roll would silently never happen. Evaluate it into a local first. var shotId = FireControl.Fire(this, Item, Entity, Damage * interval); - if (shotId != 0) OnBeamShot?.Invoke(shotId); + OnBeamShot?.Invoke(shotId); } } return true; diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 6de087ba0..e4f4618be 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -103,6 +103,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // through -- a player's action-bar Activate() reaches this exactly the same way Combat.cs's and // TurretController.cs's Activate() calls do. if (!ArcAllowsFire) return; + // A refused round is free (operator ruling 2026-09-30): no ammo, energy or cooldown is spent on a burst + // whose first round FireControl would refuse. + if (FireControl.Refuses(this, Entity)) return; // If 1 ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case @@ -227,16 +230,21 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _burstTimer += dt; while (_burstRemaining > 0 && _burstTimer > 0) { + // A fused round whose arming distance Range cannot reach is refused, per round, before anything is + // spent (operator ruling 2026-09-30): it costs no ammo, energy, sound, heat, wear or visibility. + // The decision is FireControl's; Fire makes the same one. + var refused = FireControl.Refuses(this, Entity); // If multiple ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case - if (!_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) + if (!refused && !_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) { _burstRemaining = 0; return false; } - + _burstRemaining--; _burstTimer -= _burstInterval; + if (refused) continue; // Cut 3: this is fire authority's one entry point. FireControl.Fire freezes the payload snapshot // (Q6 -- the gun's own stats at this exact instant, base.Execute(dt) above already refreshed them // this tick) and queues a PendingShot; nothing downstream re-evaluates a stat. @@ -246,9 +254,6 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // 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); - // A fused round whose arming distance Range cannot reach is refused (FireControl.Fire returns 0): - // nothing was fired, so nothing sounds, wears or heats. - if (shotId == 0) continue; OnFire?.Invoke(shotId); if(!firedThisFrame) { diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 4a2fdab63..48b4c6c4e 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -73,16 +73,12 @@ public class TurretController : Behavior, IInitializableBehavior } else Entity.LookDirection = normalize(diff); - var dist = length(diff); foreach (var x in _weapons) { - var data = x.Data as WeaponData; - // Cut 3 (docs/fire-control-cut.md): the same gate and threshold Combat.cs uses -- HitProbability - // already subsumes the bare arc test (zero out of arc), so this is the "worth it" heuristic, not - // a parallel arc check. - var fire = FireControl.HitProbability(x, Entity, Entity.Target.Value) >= Entity.ItemManager.GameplaySettings.AgentMinHitProbability; - if (x.Evaluate(data.Range) > dist && fire) + // The same per-weapon decision Combat.cs makes; the range test is part of it (a shot out of range + // is not designated and prices at zero). + if (FireControl.AgentFires(x, Entity, Entity.Target.Value)) { x.Activate(); } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index adf1721e0..4a6afb5e5 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -79,15 +79,20 @@ public static class FireControl return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position); } - // Operator ruling 2026-09-30 (arming distance): a round never bursts closer than its blast radius to where - // it was fired, and the disc must clear the shooter's own hull as it stood at fire. So the burst is at least - // BlastRadius beyond the far edge of the shooter's hull along the flight line. Close bursts are pushed out - // along the aim to this distance; a pilot who flies into their own distant burst takes the damage. - private static float ArmingDistance(Entity shooter, float2 direction, float blastRadius) - { - HullAhead(shooter, shooter.Position.xz, direction, out _, out var exit); - return exit + blastRadius; - } + // Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so + // a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The + // answer is Solve's own -- there is no second copy of the arming test. + public static bool Refuses(Weapon weapon, Entity shooter) => + Solve(weapon, weapon.Item, shooter, shooter.Target.Value).Outcome == FireOutcome.Refused; + + // The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30). + // A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its + // target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never + // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish. + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) => + FuseOf(weapon.Item, out _) != null + ? Designated(weapon, shooter, target, out _) + : HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The @@ -213,8 +218,24 @@ public static class FireControl private static float PFire(Weapon weapon, Entity source, Entity target, out float range) => PFire(weapon, source, target, out range, out _, out _); - // `designated` is the one answer to "does the shooter hold valid targeting data on this target": visible, in - // range and locked (for a LockWeapon). `inArc` is the bearing test, asked only once a target is designated. + // The one answer to "does the shooter hold valid targeting data on this target": visible, in range and locked + // (for a LockWeapon). PFire, Solve and the agents' trigger decision all read it here, and nowhere else. + // F8 correction (Soul's fix batch, 2026-09-24): `range` is computed for any non-null target, before any + // gate -- Fire reads it for flightTime, and flightTime must reflect the real distance even when a gate (not + // visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the + // burst reach and the range clamp all measure the same distance, so a target's height never moves one of + // them without the others. + public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) + { + range = 0f; + if (target == null) return false; + range = length((target.Position - source.Position).xz); + return source.VisibleEntities.Contains(target) + && range >= weapon.MinRange && range <= weapon.Range + && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); + } + + // `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated. // PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at // (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but // out of arc it bursts at the target's range, along the aim. Inspect recomputes the four gates one by one, @@ -226,20 +247,9 @@ public static class FireControl inArc = false; if (target == null) return 0f; - // F8 correction (Soul's fix batch, 2026-09-24): range is computed for any non-null target, before any - // gate -- Fire reads it through this same overload for flightTime, and flightTime must reflect the - // real distance even when a gate (not visible, out of arc) already prices the shot at PFire 0. The - // gates below still short-circuit the rest of the shooter-side probability in the same order as before. - // Planar (R7: the simulation is 2D): the range gate, the burst reach and the range clamp all measure the - // same distance, so a target's height never moves one of them without the others. - var toTarget = target.Position - source.Position; - range = length(toTarget.xz); - - if (!source.VisibleEntities.Contains(target)) return 0f; - if (range < weapon.MinRange || range > weapon.Range) return 0f; - if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; - designated = true; - inArc = InArc(weapon.Item, toTarget); + designated = Designated(weapon, source, target, out range); + if (!designated) return 0f; + inArc = InArc(weapon.Item, target.Position - source.Position); if (!inArc) return 0f; var settings = source.ItemManager.GameplaySettings; @@ -320,6 +330,9 @@ public static class FireControl MinRange = weapon.MinRange, MaxRange = weapon.Range }; + var solution = Solve(weapon, weapon.Item, source, target); + diagnostic.Outcome = solution.Outcome; + diagnostic.BurstReach = solution.BurstReach; if (target == null) return diagnostic; var toTarget = target.Position - source.Position; @@ -408,6 +421,82 @@ public static class FireControl return shot.Target == null ? 1f : saturate(1f - deviation / shot.Tracking); } + // The one decision about what a round does, made from the shooter's state and a target and nothing else: + // no draw, no side effect. Fire freezes it into a PendingShot; Inspect shows it to the HUD; the weapons ask + // it whether a round is refused before they spend anything -- so a refusal is decided once, by the same + // code that would have flown the round, and the HUD cannot forecast a burst the trigger will not fire. + // Direct: a round that resolves against its target (a weapon without a fuse). Burst: a fused round, with + // BurstReach the planar distance from the shooter at which it bursts. Refused: a fused round whose blast + // radius (its arming distance) exceeds the weapon's Range; it cannot be fired. + public static FireSolution Solve(Weapon weapon, EquippedItem item, Entity source, Entity target) + { + var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc); + + // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the + // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided + // later by a Unity projectile, and never by a label. Solve decides "detonates" once: a fuse is frozen + // only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius + // is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below + // still reads as "resolve this shot with Apply." + var fuse = FuseOf(item, out var blastRadius); + + // Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the + // mount's arc) and carries no target -- so nothing at resolution asks whether one is still around -- + // unless its target is designated (visible, in range, locked) and in arc: + // * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no + // lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing; + // * valid data but out of arc bursts at the target's range, which the range gate has already held to + // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. + // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot + // without a fuse keeps its target as before. + // Arming distance (operator ruling 2026-09-30): the blast radius, measured from the shooter's position and + // blind to hull size -- a large hull can take splash from its own close burst. A round never bursts nearer + // than that, so a burst that would land nearer is pushed out along its flight line to it. A round fired at a + // valid target inside the radius is pushed out but stays a targeted round: it keeps its target, so a target + // that leaves the zone cancels it like any other. A round whose Range is shorter than the arming distance + // cannot be fired at all: it is refused, per round, where Range is read after Execute refreshed it (a + // burst's later rounds and a power-starved Range included). + var engaged = fuse != null && !(designated && inArc) ? null : target; + var origin = source.Position; + var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); + + var arming = fuse != null ? blastRadius : 0f; + var burstPosition = engaged != null ? engaged.Position : origin; + var flightDistance = 0f; + if (fuse != null && engaged != null) + { + // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is + // pulled back along its own line to Range. + var intercept = PredictedIntercept(weapon, source, engaged); + var interceptReach = length((intercept - origin).xz); + if (min(interceptReach, weapon.Range) < arming) burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * arming; + else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + flightDistance = max(fireRange, arming); + } + else if (fuse != null) + { + var reach = max(designated ? fireRange : weapon.Range, arming); + burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; + flightDistance = reach; + } + else if (engaged != null) flightDistance = fireRange; + + var solution = new FireSolution + { + Outcome = weapon.Range < arming ? FireOutcome.Refused : fuse != null ? FireOutcome.Burst : FireOutcome.Direct, + PFire = pFire, + FireRange = fireRange, + Engaged = engaged, + TravelDirection = travelDirection, + BurstPosition = burstPosition, + BurstReach = length((burstPosition - origin).xz), + FlightDistance = flightDistance, + Fuse = fuse, + BlastRadius = blastRadius + }; + return solution; + } + // Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes flight time, freezes the // payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and queues a PendingShot for // Zone.Step to age and eventually resolve. Returns the ShotId so the caller's OnFire event can carry it to @@ -428,72 +517,26 @@ public static class FireControl // 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 -- // those are priced live at Commit, against the target's facing then, not now (Bearing timing, R10). - // F8 (Soul's fix batch, 2026-09-24): the `out range` overload is the one range HitProbability now - // reads too -- Fire used to recompute its own second copy of `target.Position - source.Position` for - // fireRange, right beside the copy PFire's gate already computed. - var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc); - - // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the - // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided - // later by a Unity projectile, and never by a label. Fire decides "detonates" once: a fuse is frozen - // only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius - // is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below - // still reads as "resolve this shot with Apply." - var fuse = FuseOf(item, out var blastRadius); - - // Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the - // mount's arc) and carries no target -- so nothing at resolution asks whether one is still around -- - // unless its target is designated (visible, in range, locked) and in arc: - // * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no - // lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing; - // * valid data but out of arc bursts at the target's range, which the range gate has already held to - // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. - // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot - // without a fuse keeps its target as before. - // Arming distance (operator ruling 2026-09-30): a round never bursts closer than its blast radius past the - // shooter's hull, so a burst that would land nearer -- a target close in, or out of arc and inside the - // radius -- is pushed out along the aim to that distance, and the round carries no target. A round whose - // arming distance exceeds the weapon's Range cannot be fired at all: it is refused here, per round, where - // Range is read after Execute refreshed it (a burst's later rounds and a power-starved Range included). - var engaged = fuse != null && !(designated && inArc) ? null : target; + // Solve is the one range and the one burst decision Inspect and the weapons' refusal check read too. + var solution = Solve(weapon, item, source, target); + if (solution.Outcome == FireOutcome.Refused) return 0; + var pFire = solution.PFire; + var fireRange = solution.FireRange; + var engaged = solution.Engaged; + var travelDirection = solution.TravelDirection; + var burstPosition = solution.BurstPosition; + var fuse = solution.Fuse; + var blastRadius = solution.BlastRadius; var origin = source.Position; - var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); - - var arming = 0f; - var burstPosition = engaged != null ? engaged.Position : origin; - if (fuse != null && engaged != null) - { - // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is - // pulled back along its own line to Range. - var intercept = PredictedIntercept(weapon, source, engaged); - var interceptReach = length((intercept - origin).xz); - arming = ArmingDistance(source, travelDirection, blastRadius); - if (min(interceptReach, weapon.Range) < arming) engaged = null; - else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; - } - if (fuse != null && engaged == null) - { - travelDirection = AimDirection(item, source); - arming = ArmingDistance(source, travelDirection, blastRadius); - var reach = max(designated ? fireRange : weapon.Range, arming); - burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; - } - if (arming > weapon.Range) return 0; var targetVelocity = float3.zero; var targetPosition = origin; - var flightTime = 0f; if (engaged != null) { targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y); targetPosition = engaged.Position; - flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f; - } - else if (fuse != null) - { - var flown = length((burstPosition - origin).xz); - flightTime = weapon.Velocity > .01f ? flown / weapon.Velocity : 0f; } + var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f; var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; @@ -526,7 +569,6 @@ public static class FireControl BurstPosition = burstPosition, Fuse = fuse, BlastRadius = blastRadius, - ArmingDistance = arming, Committed = false }; @@ -540,6 +582,9 @@ public static class FireControl // its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked // for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot. // Resolution reads the zone and the target; there is no credit or loot for a shooter to receive. + // A shot's queue state is settled before anything is published about it: it is committed in the list before + // ShotCommitted fires and removed before ShotResolved fires, so a presentation observer that throws costs + // that one notification and cannot leave the shot to be committed or resolved again on every later tick. public static void Step(Zone zone, float dt) { var shots = zone.PendingShots; @@ -559,9 +604,9 @@ public static class FireControl // the commit decided, while ShotResolved reports what actually happened -- nothing, because the // 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); if (!shot.Committed) zone.ShotCommitted.OnNext(miss); zone.ShotResolved.OnNext(miss); - shots.RemoveAt(i); continue; } @@ -572,8 +617,8 @@ public static class FireControl // A contact burst short of max range arrives when the round has flown that far, not at the frozen // max-range time (CommitBurst). Never later than the frozen time. if (shot.Outcome.Result == ShotResult.Burst) shot.ArrivalTime = min(shot.ArrivalTime, now + shot.Outcome.ArrivalIn); - zone.ShotCommitted.OnNext(shot.Outcome); shots[i] = shot; + zone.ShotCommitted.OnNext(shot.Outcome); } if (shot.Committed && now >= shot.ArrivalTime) @@ -583,8 +628,8 @@ public static class FireControl // 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. Apply(zone, shot); - zone.ShotResolved.OnNext(shot.Outcome); shots.RemoveAt(i); + zone.ShotResolved.OnNext(shot.Outcome); } } } @@ -738,7 +783,7 @@ public static class FireControl outcome.HasBurstPoint = true; var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); var travelled = shot.Fuse == WeaponFuse.Contact ? ContactDistance(zone, shot, reach) : reach; - travelled = min(reach, max(travelled, shot.ArmingDistance)); + travelled = min(reach, max(travelled, shot.BlastRadius)); if (travelled < reach) { outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; @@ -759,18 +804,17 @@ public static class FireControl foreach (var entity in zone.Entities) { if (entity == shot.Source) continue; - if (HullAhead(entity, shot.FireOrigin.xz, shot.TravelDirection, out var entry, out _)) nearest = min(nearest, entry); + nearest = min(nearest, HullEntry(entity, shot.FireOrigin.xz, shot.TravelDirection)); } return nearest; } - // Where the line from `origin` along the unit planar `direction` meets `entity`'s hull, in world distance - // from `origin`: `entry` to the first metal cell ahead (+infinity when the line meets none, and negative when the - // origin is already inside the hull: the arming distance floors every caller's use of it) and `exit` to the far edge of the last one (zero when none). Read - // through Lane's own cell admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset - // is its own, and cells wholly behind the origin are skipped. Contact fuses read `entry`; a round's arming - // distance reads `exit` of its own shooter. - private static bool HullAhead(Entity entity, float2 origin, float2 direction, out float entry, out float exit) + // Where the line from `origin` along the unit planar `direction` meets `entity`'s hull: the world distance from + // `origin` to the first metal cell ahead, +infinity when the line meets none, and negative when the origin is + // already inside the hull (the blast radius floors every caller's use of it). Read through Lane's own cell + // admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset is its own, and cells + // wholly behind the origin are skipped. Only a contact fuse asks. + private static float HullEntry(Entity entity, float2 origin, float2 direction) { var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; @@ -779,8 +823,7 @@ public static class FireControl var lateral = dot(start, Lateral(bearing)); var along = dot(start, bearing); - entry = float.PositiveInfinity; - exit = 0f; + var entry = float.PositiveInfinity; var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); try { @@ -788,10 +831,10 @@ public static class FireControl for (var i = 0; i < count; i++) { if (!(buffer[i].Exit > along)) continue; - if (float.IsPositiveInfinity(entry)) entry = (buffer[i].Entry - along) * cellSize; - exit = max(exit, (buffer[i].Exit - along) * cellSize); + entry = (buffer[i].Entry - along) * cellSize; + break; } - return !float.IsPositiveInfinity(entry); + return entry; } finally { @@ -1708,14 +1751,11 @@ public struct PendingShot // Cut 12.4(a): the blast payload, frozen at Fire alongside everything else above. BlastRadius is zero for // a weapon whose data does not carry one -- Step reads that zero as "resolve with Apply," not a separate - // bool. Fuse is frozen only when BlastRadius > 0 (Fire decides "detonates" once); it is unread until + // bool. Fuse is frozen only when BlastRadius > 0 (Solve decides "detonates" once); it is unread until // Apply's fuse switch (12.4(b)). public float3 BurstPosition; public WeaponFuse? Fuse; public float BlastRadius; - // How far along its flight line a fused round may first burst: BlastRadius past the far edge of the - // shooter's hull, frozen at Fire (a round outlives its shooter's pose, too). - public float ArmingDistance; // Frozen with the rest of the shooter's decision: where the round left from. A round outlives its shooter, so // nothing at resolution reads the shooter's position. @@ -1730,6 +1770,32 @@ public struct PendingShot public ShotOutcome Outcome; } +// What a round does when fired (FireControl.Solve): Direct resolves against its target, Burst detonates at a +// point, Refused cannot be fired -- its blast radius exceeds the weapon's Range. +public enum FireOutcome +{ + Direct, + Burst, + Refused +} + +// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point, read for Burst +// only) are what the HUD shows; the rest is what Fire freezes into the PendingShot. +public struct FireSolution +{ + public FireOutcome Outcome; + public float BurstReach; + // How far the round flies: to its burst point, or for a direct round to its target. + public float FlightDistance; + public float PFire; + public float FireRange; + public Entity Engaged; + public float2 TravelDirection; + public float3 BurstPosition; + public WeaponFuse? Fuse; + public float BlastRadius; +} + public struct FireControlDiagnostic { public bool HasTarget; @@ -1753,6 +1819,10 @@ public struct FireControlDiagnostic public float PSpread; public float POnHull; public float PBase; + // What a round from this weapon does (Solve): the forecast for a fused weapon, whose percentages above do + // not describe it. BurstReach is read only when Outcome is Burst. + public FireOutcome Outcome; + public float BurstReach; } // Cut 12.2 (docs/fire-control-cut.md): a merged interval of a hull's lateral shadow, in the schematic frame's diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index cb50782d7..ca50e475c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1410,24 +1410,22 @@ public sealed class FireControlCut124Tests : IDisposable // ==== Operator ruling 2026-09-30, batch 4: the arming distance ==== // - // A round never bursts closer than its blast radius past the shooter's own hull. The fixture's shooter hull is - // a 5x5 block of 2-unit cells centred on the shooter, so its far edge is 5 units out along the axis and - // 5 * sqrt(2) along a diagonal; with a blast of 30 the arming distance is 35 (or 30 + 7.07). - - private const float ShooterHullReach = 5f; + // A round never bursts closer than its blast radius (its arming distance), measured from the shooter's + // position and blind to hull size: the fixture's shooter hull is a 5x5 block of 2-unit cells, and a blast of 30 + // arms at 30, not 30 plus whatever the hull reaches. A shooter close to its own burst takes the splash. private static float Planar(float3 from, float3 to) => length((to - from).xz); - // A target inside the arming distance is not detonated on: the round flies along the aim and bursts at the - // arming distance, carrying no target, and the shooter that stood beside it is untouched (the probes measured - // 7.07 of self-damage for a target at 5, before this). The out-of-arc row is a target 10 away behind the - // mount, inside the blast radius, with the mount flipped so the arc is closed. Kills: no arming distance; a - // burst that still rides the target; an arming distance measured from the shooter's centre instead of the far - // edge of its hull (the burst would land 5 units short and the shooter would still be inside the disc). + // A target inside the arming distance is not detonated on: the round flies on to the arming distance, exactly + // the blast radius from the shooter (35 would be the hull-inclusive figure this replaced). An in-arc target + // stays the round's target and the burst is pushed out along the line to it; the out-of-arc row is a target 10 + // away behind the mount, inside the blast radius, with the mount flipped so the arc is closed: no target rides + // and the burst is on the aim. Kills: no arming distance; a hull term in it; an in-arc round demoted to no + // target; an out-of-arc round that keeps one. [Theory] [InlineData(5f, false)] [InlineData(10f, true)] - public void ACloseBurstIsPushedOutAlongTheAimToTheArmingDistance(float targetRange, bool outOfArc) + public void ACloseBurstIsPushedOutToTheBlastRadius(float targetRange, bool outOfArc) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 60f, targetRange: targetRange); @@ -1438,37 +1436,88 @@ public sealed class FireControlCut124Tests : IDisposable aim = float2(0, -1); } Aim(e, aim); - var burst = PointAlong(e, aim, 30f + ShooterHullReach); + var burst = PointAlong(e, aim, 30f); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True((shot.Target == e.Target) == !outOfArc, "only a round with a target it can bear on keeps it"); + Assert.Equal(burst.x, shot.BurstPosition.x, 2); + Assert.Equal(burst.z, shot.BurstPosition.z, 2); + } + + // A round pushed out to its arming distance is still a targeted round (operator ruling 2026-09-30): if its target + // leaves the zone before it resolves, it does not burst. The target is 5 units away, inside a blast of 30, and + // the round flies 1.5 seconds; the target is gone after the first tick. The round resolves as a miss with no + // burst point, and the shooter standing inside the would-be disc is untouched. Kills: demoting a close round + // to no target (the round then bursts on schedule and hurts the shooter). + [Fact] + public void APushedOutRoundWhoseTargetLeavesTheZoneDoesNotBurst() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, float2(0, 1)); var hurt = 0f; using var a = e.Shooter.ArmorDamage.Subscribe(x => hurt += x.damage); using var h = e.Shooter.HullDamage.Subscribe(x => hurt += x); using var i = e.Shooter.ItemDamage.Subscribe(x => hurt += x.damage); + ShotOutcome resolved = null; + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = SafeAssert.OnlyShot(e.Zone); - Assert.True(shot.Target == null, "a burst pushed out to the arming distance carries no target"); - Assert.Equal(burst.x, shot.BurstPosition.x, 2); - Assert.Equal(burst.z, shot.BurstPosition.z, 2); - Assert.Equal(35f, shot.ArmingDistance, 2); + Assert.True(SafeAssert.OnlyShot(e.Zone).Target == e.Target, "fixture: the pushed-out round keeps its target"); e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(3f); + SafeAssert.NoShots(e.Zone); + Assert.NotNull(resolved); + Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.False(resolved.HasBurstPoint, "a round whose target is gone does not burst"); Assert.Equal(0f, hurt); } - // The far edge is read along the flight line, not along an axis: aimed down a diagonal the 5x5 hull reaches - // 5 * sqrt(2) = 7.07 units, so the burst sits at 30 + 7.07. A close in-arc target makes the arming distance - // the burst reach. Kills: an extent read along the mount or the hull's own axes. - [Fact] - public void TheArmingDistanceIsMeasuredAlongTheFlightLine() - { - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, - weaponRange: 60f, targetRange: 5f); - Aim(e, normalize(float2(1, 1))); + public enum Publication { Committed, Resolved } + // A presentation observer that throws costs its own notification and nothing more: the shot's queue state is + // settled before anything is published, so it is not committed or resolved a second time on every later tick + // (the queue used to wedge, re-throwing forever). The first tick throws; the second does not, the shot is gone + // or committed exactly once. Kills: publishing before the shot is committed in the list or removed from it. + [Theory] + [InlineData(Publication.Committed)] + [InlineData(Publication.Resolved)] + public void AThrowingObserverCannotWedgeTheShotQueue(Publication throwsOn) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + var commits = 0; + var resolutions = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => { commits++; if (throwsOn == Publication.Committed) throw new InvalidOperationException("observer"); }); + using var r = e.Zone.ShotResolved.Subscribe(_ => { resolutions++; if (throwsOn == Publication.Resolved) throw new InvalidOperationException("observer"); }); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = SafeAssert.OnlyShot(e.Zone); - Assert.Equal(30f + ShooterHullReach * sqrt(2f), Planar(e.Shooter.Position, shot.BurstPosition), 1); + Assert.ThrowsAny(() => e.Zone.Update(.01f)); + var second = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(second); + Assert.Equal(1, commits); + Assert.True(resolutions <= 1, $"resolved {resolutions} times"); + } + + // The same for the miss a departed target publishes: the shot is removed before that notification, so an + // observer that throws on it cannot keep the shot in the queue to be cancelled, and thrown at, on every tick. + // Kills: publishing the miss before removing the shot. + [Fact] + public void AThrowingObserverOfADepartedTargetsMissCannotWedgeTheShotQueue() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + using var c = e.Zone.ShotCommitted.Subscribe(_ => throw new InvalidOperationException("observer")); + e.Zone.Entities.Remove(e.Target); + + Assert.ThrowsAny(() => e.Zone.Update(.01f)); + var second = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(second); + SafeAssert.NoShots(e.Zone); } // A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its @@ -1488,15 +1537,15 @@ public sealed class FireControlCut124Tests : IDisposable } // A contact round stops on the first hull it crosses, but never nearer than its arming distance: a blocker - // whose near face is 6 units out (centred 10) is passed, and the round bursts at 5 + 4 = 9. Kills: a contact - // point taken as the hull face regardless of arming. + // whose near face is 2 units out (centred 6) is passed, and the round bursts at its blast radius, 4. Kills: a + // contact point taken as the hull face regardless of arming. [Fact] public void AContactRoundNeverStopsNearerThanItsArmingDistance() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); e.Shooter.Target.Value = null; Aim(e, float2(0, 1)); - AddShip(e, PointAlong(e, float2(0, 1), 10f), 1f, 300); + AddShip(e, PointAlong(e, float2(0, 1), 6f), 1f, 300); ShotOutcome outcome = null; using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); @@ -1504,7 +1553,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.NotNull(outcome); - Assert.Equal(e.Shooter.Position.z + ShooterHullReach + 4f, outcome.BurstPoint.y, 2); + Assert.Equal(e.Shooter.Position.z + 4f, outcome.BurstPoint.y, 2); } // A round fired from inside another hull is already in metal: its contact point is its arming distance. The @@ -1528,16 +1577,16 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.NotNull(committed); - Assert.Equal(e.Shooter.Position.z + ShooterHullReach + 4f, committed.BurstPoint.y, 2); + Assert.Equal(e.Shooter.Position.z + 4f, committed.BurstPoint.y, 2); } - // A fused round is refused, per round and at the moment Fire reads Range, when its arming distance exceeds - // that Range: 34.5 against 35 refuses, 35.01 fires, and a power-starved Range (authored 100, forced to about + // A fused round is refused, per round and at the moment Fire reads Range, when its arming distance (the blast + // radius, 30) exceeds that Range: 29.5 refuses, 30.01 fires, and a power-starved Range (authored 100, forced to about // 10 the way Soul's probe did after the trigger has passed) refuses although the authored figure clears // it. Fire returns 0 and queues nothing. Kills: a gate that reads the authored Range; `>=` for `>`. [Theory] - [InlineData(34.5f, false, false)] - [InlineData(35.01f, false, true)] + [InlineData(29.5f, false, false)] + [InlineData(30.01f, false, true)] [InlineData(100f, true, false)] public void AFusedRoundWhoseArmingDistanceExceedsRangeIsRefusedByFire(float range, bool starvedOfPower, bool fires) { @@ -1550,7 +1599,7 @@ public sealed class FireControlCut124Tests : IDisposable typeof(EquippedItem).GetProperty("PowerSupply").GetSetMethod(true).Invoke(e.WeaponItem, new object[] { .1f }); e.Shooter.Resolver.InvalidateSource(e.WeaponItem, StatSource.PowerSupply); e.Weapon.GetType().GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(e.Weapon, null); - Assert.True(e.Weapon.Range < 35f, "fixture: the runtime Range must have fallen below the arming distance"); + Assert.True(e.Weapon.Range < 30f, "fixture: the runtime Range must have fallen below the arming distance"); } var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -1559,19 +1608,12 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(fires ? 1 : 0, e.Zone.PendingShots.Count); } - // The boundary itself: a Range exactly equal to the arming distance fires (the disc is tangent to the hull, - // which delivers nothing). The arming distance is read from a probe round on the same fixture, so the - // equality is exact rather than a decimal that float arithmetic could tip. Kills: `>=` for `>`. + // The boundary itself: a Range exactly equal to the arming distance (the blast radius) fires. Kills: `>=` for + // `>`. [Fact] public void ARangeExactlyEqualToTheArmingDistanceFires() { - var probe = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 60f); - probe.Shooter.Target.Value = null; - Aim(probe, float2(0, 1)); - FireControl.Fire(probe.Weapon, probe.WeaponItem, probe.Shooter); - var arming = SafeAssert.OnlyShot(probe.Zone).ArmingDistance; - - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: arming); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 30f); e.Shooter.Target.Value = null; Aim(e, float2(0, 1)); @@ -1582,7 +1624,7 @@ public sealed class FireControlCut124Tests : IDisposable // queues nothing, and the same weapon with Range to spare fires once. Kills: Execute still announcing a // refused round (Fire's 0 handed to the presentation as a shot id). [Theory] - [InlineData(34.5f, 0)] + [InlineData(29.5f, 0)] [InlineData(40f, 1)] public void ARefusedRoundIsNotAnnounced(float range, int fired) { diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs new file mode 100644 index 000000000..c1276745e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -0,0 +1,503 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Operator rulings 2026-09-30 (fuse batch 5): AI and turrets decide per weapon and a fused weapon needs a +// designated target; the HUD forecast shows a fused weapon's outcome and reads the same decision Fire flies; a +// refused round is free, for a discrete weapon and for a beam. Builds its own fixture, the convention every +// fire-control test file follows: its shooter carries several mounts and can hold either kind of weapon. +public sealed class FireControlPerWeaponTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolperweapon-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlPerWeaponTests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings(float minHit) => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = minHit, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Gun + { + public EquippedItem Item; + public Weapon Weapon; + } + + private sealed class Rig + { + public Zone Zone; + public Ship Shooter; + public Ship Target; + public List Guns = new List(); + public Gun Gun => Guns[0]; + } + + // Mount 0 (None, arc 0) bears on a target dead ahead; (Clockwise, arc 0) is a side mount the default 170 + // degree arc cannot bring to bear on it. Fused: Proximity with `blast`, which is also its arming distance: blast 30 + // arms at 30, so a Range of 29.5 refuses and 40 fires. + private Rig Build( + (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, + float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, + bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false) + { + mounts ??= new[] { (ItemRotation.None, 0f) }; + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000 + }; + for (var i = 0; i < mounts.Length; i++) + shooterHullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(), + Rotation = mounts[i].Rotation, FiringArc = mounts[i].Arc + }); + + WeaponData behavior; + if (beam) + behavior = new ConstantWeaponData(); + else if (lockWeapon) + behavior = new LockWeaponData + { + LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(0), DirectionImpact = Constant(1), Decay = Constant(1), + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1) + }; + else + behavior = new InstantWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; + behavior.Damage = Constant(100); + behavior.Range = Constant(range); + behavior.MinRange = Constant(0); + behavior.Velocity = Constant(0); + behavior.Spread = Constant(0); + behavior.DamageSpread = Constant(0); + behavior.Penetration = Constant(0); + behavior.Energy = Constant(energy); + behavior.Heat = Constant(heat); + behavior.Visibility = Constant(visibility); + behavior.MagazineSize = magazine; + behavior.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blast, + Behaviors = { behavior } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData { Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Turret", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TurretControllerData() } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, TestSettings(minHit), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var lot = 1; + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var rig = new Rig(); + for (var i = 0; i < mounts.Length; i++) + { + var gun = Make("Gun", lot++, 1); + Assert.True(shooter.TryEquip(gun, new int2(i, 0))); + var item = shooter.Equipment.Single(x => x.EquippableItem == gun); + rig.Guns.Add(new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }); + } + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + if (true) + { + var reactor = Make("Reactor", lot++, 10); + Assert.True(shooter.TryFindSpace(reactor, out var rpos)); + Assert.True(shooter.TryEquip(reactor, rpos)); + } + if (turret) + { + var t = Make("Turret", lot++, 1); + Assert.True(shooter.TryFindSpace(t, out var upos)); + Assert.True(shooter.TryEquip(t, upos)); + } + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3(-53, 0, -101); + target.Position = shooter.Position + float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(1f); + + rig.Zone = zone; + rig.Shooter = shooter; + rig.Target = target; + return rig; + } + + private static void Aim(Rig r, float2 direction) => r.Shooter.LookDirection = float3(direction.x, 0, direction.y); + + private static readonly (ItemRotation, float)[] SideThenForward = { (ItemRotation.Clockwise, 0f), (ItemRotation.None, 0f) }; + private static readonly (ItemRotation, float)[] SideMount = { (ItemRotation.Clockwise, 0f) }; + + // ==== Ruling 1: per weapon, and a fused weapon needs a designated target ==== + + // A weapon group shares a trigger, not a verdict: two guns of one type, the first on a side mount that cannot + // bear on the target and the second dead ahead. Only the second fires. Kills: the group's verdict read off its + // first weapon (Combat's old `testWeapon`), which silences the group. + [Fact] + public void CombatDecidesEachWeaponOfAGroupForItself() + { + var r = Build(SideThenForward); + var group = r.Shooter.WeaponGroups.Single(g => g.weapons.Count == 2).weapons; + Assert.True(ReferenceEquals(group[0], r.Guns[0].Weapon), "fixture: the side mount leads the group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Guns[0].Weapon.Firing, "the side-mounted gun cannot bear"); + Assert.True(r.Guns[1].Weapon.Firing, "the forward gun bears and is worth firing"); + } + + // A fused weapon fires whenever its target is designated, whatever the arc: the same group, now fused, fires + // both guns, the side mount included. Kills: Combat reading HitProbability (zero out of arc) for a fused weapon. + [Fact] + public void CombatFiresAFusedWeaponAtADesignatedTargetOutOfArc() + { + var r = Build(SideThenForward, fuse: WeaponFuse.Proximity, blast: 4f); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.True(r.Guns[0].Weapon.Firing, "a designated target is enough, arc or no"); + Assert.True(r.Guns[1].Weapon.Firing); + } + + // The turret makes the same per-weapon decision. Kills: TurretController reading HitProbability, or its own + // range test, for a fused weapon. + [Fact] + public void ATurretFiresAFusedWeaponAtADesignatedTargetOutOfArc() + { + var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, turret: true); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc"); + } + + public enum Withheld { NoTarget, OutOfRange, NotVisible, Unlocked } + + // The AI never fires a fused weapon at nothing: a missing target, one out of range, one not visible and one + // an unlocked LockWeapon holds no lock on are each no reason to fish. The direct-hit control fires (true) on + // the same rig once the target is valid. Kills: `designated` dropped from the fused branch (null included). + [Theory] + [InlineData(Withheld.NoTarget)] + [InlineData(Withheld.OutOfRange)] + [InlineData(Withheld.NotVisible)] + [InlineData(Withheld.Unlocked)] + public void AnAgentNeverFiresAFusedWeaponAtNothing(Withheld reason) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 4f, range: 100f, + targetRange: reason == Withheld.OutOfRange ? 150f : 60f, lockWeapon: reason == Withheld.Unlocked); + var target = r.Target; + if (reason == Withheld.NoTarget) target = null; + if (reason == Withheld.NotVisible) r.Shooter.VisibleEntities.Remove(r.Target); + + Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target)); + } + + [Fact] + public void AnAgentFiresAFusedWeaponAtAValidTargetWhateverItsHitChance() + { + // Accuracy 0 prices every direct hit at zero; the fused weapon is unmoved. + var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, accuracy: 0f); + + Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // A weapon that fires at what it hits keeps the AgentMinHitProbability threshold, per weapon. + // Kills: the threshold dropped for a direct-hit weapon, or applied to a fused one. + [Theory] + [InlineData(.5f, .2f, true)] + [InlineData(.5f, .9f, false)] + public void AnAgentKeepsTheHitProbabilityThresholdForADirectHitWeapon(float accuracy, float minHit, bool fires) + { + var r = Build(accuracy: accuracy, minHit: minHit); + var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); + Assert.True(p > .3f && p < .7f, $"fixture: the shot must price near the accuracy, priced {p}"); + + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // ==== Ruling 2: the HUD forecast is Fire's own decision ==== + + // Inspect's Outcome and BurstReach against what Fire then does with the same rig: Refused iff Fire returns 0, + // and a Burst's reach is the planar distance to the burst point Fire froze. Rows: a target beyond the arming + // distance (burst at its range), one inside it (pushed out to 30), no target (max range), out of arc, a + // Range that cannot reach the arming distance, and a weapon without a fuse. + // Kills: an Inspect that computes its own reach (ignoring the arming push or the Range clamp). + [Theory] + [InlineData(60f, 100f, false, false, FireOutcome.Burst, 60f)] + [InlineData(20f, 100f, false, false, FireOutcome.Burst, 30f)] + [InlineData(60f, 40f, true, false, FireOutcome.Burst, 40f)] + [InlineData(60f, 100f, false, true, FireOutcome.Burst, 60f)] + [InlineData(60f, 29.5f, false, false, FireOutcome.Refused, 0f)] + [InlineData(60f, 29.5f, true, false, FireOutcome.Refused, 0f)] + public void TheForecastIsTheOutcomeFireDelivers(float targetRange, float range, bool noTarget, bool outOfArc, FireOutcome outcome, float reach) + { + var r = Build(outOfArc ? SideMount : null, fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: targetRange); + if (noTarget) r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Shooter.Target.Value); + var shotId = FireControl.Fire(r.Gun.Weapon, r.Gun.Item, r.Shooter); + + Assert.Equal(outcome, d.Outcome); + Assert.Equal(outcome != FireOutcome.Refused, shotId != 0); + if (outcome == FireOutcome.Refused) return; + Assert.Equal(reach, d.BurstReach, 2); + var shot = SafeAssert.OnlyShot(r.Zone); + Assert.Equal(d.BurstReach, length((shot.BurstPosition - shot.FireOrigin).xz), 2); + } + + // A weapon without a fuse forecasts a direct hit and keeps its percentages. Kills: Solve calling everything a burst. + [Fact] + public void AWeaponWithoutAFuseForecastsADirectHit() + { + var r = Build(); + + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Target); + + Assert.Equal(FireOutcome.Direct, d.Outcome); + Assert.True(d.PBase > 0f); + } + + // ==== Ruling 3: a refused round is free ==== + + private sealed class Costs + { + public int Ammo; + public float ChargeSpent; + public int Sounds; + public int Wears; + public float Heat; + public bool Visible; + public int Announced; + public int Shots; + } + + // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, + // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. + private Costs Pull(float range, bool single) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon); + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_fire" } } }; + var costs = new Costs(); + r.Gun.Item.AudioEvents.Subscribe(_ => costs.Sounds++); + r.Zone.ShotCommitted.Subscribe(_ => costs.Shots++); // a fused round with no flight commits and resolves in its own tick + r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) costs.Wears++; }); + weapon.OnFire += _ => costs.Announced++; + capacitor.AddCharge(1000f); // a full buffer, so the first round can be paid for + var chargeBefore = capacitor.Charge; + var heatBefore = r.Gun.Item.Temperature; + var ammoBefore = weapon.Ammo; + Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine"); + + weapon.Activate(); + r.Zone.Update(.01f); + + costs.Ammo = ammoBefore - weapon.Ammo; + costs.ChargeSpent = chargeBefore - capacitor.Charge; + costs.Heat = r.Gun.Item.Temperature - heatBefore; + costs.Visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + return costs; + } + + // Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that + // never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no + // visibility, no shot. Kills, one per row of the assertions: a spend that precedes the refusal (Execute or + // Trigger), an announcement, a sound, wear, heat or visibility left ahead of the `continue`. + // `single` is a weapon that pays once per burst, at the trigger rather than at the round. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ARefusedDiscreteRoundCostsNothing(bool single) + { + var fired = Pull(40f, single); + Assert.True(fired.Shots == 1, $"shots {fired.Shots}"); + Assert.True(fired.Ammo == 1, $"ammo spent {fired.Ammo}"); + Assert.True(fired.ChargeSpent > 1f, "a fired round spends its energy"); + Assert.Equal(1, fired.Sounds); + Assert.True(fired.Wears > 0); + Assert.True(fired.Heat > 0f); + Assert.True(fired.Visible); + Assert.Equal(1, fired.Announced); + + var refused = Pull(29.5f, single); + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Ammo); + Assert.Equal(0f, refused.ChargeSpent); + Assert.Equal(0, refused.Sounds); + Assert.Equal(0, refused.Wears); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused round adds no heat ({refused.Heat} vs {fired.Heat})"); + Assert.False(refused.Visible); + Assert.Equal(0, refused.Announced); + } + + // The trigger and the round are separate decisions: a burst authorised at the trigger runs its rounds later, + // at whatever Range Execute has refreshed by then, and a round that Range no longer reaches is refused before + // its spend. The authorisation is set directly (the way a trigger that passed leaves it) on a weapon whose + // Range is short of its blast radius, so the Trigger check cannot be what stops it. Kills: Execute spending + // before it asks. + [Fact] + public void ARoundRefusedAfterTheTriggerPassedIsRefusedBeforeItsSpend() + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, magazine: 5); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); + + r.Zone.Update(.01f); + + Assert.Equal(5, weapon.Ammo); + SafeAssert.NoShots(r.Zone); + Assert.Equal(0, (int) typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon)); + } + + // The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws + // nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of + // that, asks the bus for nothing, and stops itself the way an out-of-arc beam does. + // Kills: ConstantWeapon spending (power request, wear, heat, visibility) before the refusal, or not + // consulting FireControl at all. + private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range) + { + var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ConstantWeapon) r.Gun.Weapon; + var wears = 0; + var stops = 0; + var shots = 0; + r.Zone.ShotResolved.Subscribe(_ => shots++); + r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) wears++; }); + weapon.OnStopFiring += () => stops++; + var heatBefore = r.Gun.Item.Temperature; + + weapon.Activate(); + var request = weapon.PowerRequest(.1f); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + + var visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + return (shots, wears, request, visible, r.Gun.Item.Temperature - heatBefore, stops, weapon.Firing); + } + + [Fact] + public void ARefusedBeamCostsNothing() + { + var fired = Burn(40f); + Assert.True(fired.Shots > 0, "control: the beam rolls shots"); + Assert.True(fired.Wears > 0); + Assert.True(fired.Request > 0f); + Assert.True(fired.Visible); + Assert.True(fired.Heat > 0f); + + var refused = Burn(29.5f); + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Wears); + Assert.Equal(0f, refused.Request); + Assert.False(refused.Visible); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused beam adds no heat ({refused.Heat} vs {fired.Heat})"); + Assert.True(refused.Stops > 0 && !refused.Firing, "a refused beam stops itself, as an out-of-arc beam does"); + } +} From 8c63fbe50e6c98ab8fc1df63618baf44481c0218 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:37:02 +0200 Subject: [PATCH 093/116] Guided projectile: binding lives and dies with the projectile; no explosion without a detonation Unity-only, compile-checked by reading. * F5: GuidedProjectile.Binding is one owner: a new binding disposes the one it replaces, Kill disposes it, and OnDestroy does too, so a projectile torn down with its scene cannot leave an observer on the zone's subjects to touch a destroyed transform. * F6: Resolve takes whether the simulation detonated. A miss (a contact or delayed round that missed, a target that left) fades out with no explosion. * F7: BurstAt resets the previous target position and target velocity, so the retarget jump does not feed the guidance a bogus target velocity. Co-Authored-By: Claude Sonnet 5 --- .../Gameplay/Weapons/GuidedProjectile.cs | 34 ++++++++++++++++--- .../Weapons/GuidedProjectileManager.cs | 6 ++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index b5fdc47da..db2a8674d 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -50,20 +50,45 @@ public class GuidedProjectile : MonoBehaviour // The simulation's verdict on this round, bound by GuidedProjectileManager to Zone.ShotCommitted and // ShotResolved for its ShotId: this object flies and looks, and neither decides where the round bursts nor when. - public IDisposable Binding { get; set; } + // The one owner of the subscription's lifetime: a new binding disposes the one it replaces, Kill disposes it, + // and so does destruction, so a projectile torn down with its scene never leaves an observer on the zone's + // subjects to touch a destroyed transform. + public IDisposable Binding + { + get => _binding; + set + { + _binding?.Dispose(); + _binding = value; + } + } + + private IDisposable _binding; + + void OnDestroy() => Binding = null; // The commit named a burst point: fly to it, whatever this round was homing on (a target it would have - // passed, or a Range clamp it would have overshot). + // passed, or a Range clamp it would have overshot). The point is a static target, so the old target's motion + // is dropped with it: the jump to the new point must not feed the guidance a target velocity. public void BurstAt(Vector3 point) { Target = null; TargetPosition = () => point; + _previousTargetPosition = point; + _targetVelocity = Vector3.zero; } - // The resolution: the round bursts now, at the simulation's burst point when it has one. - public void Resolve(Vector3? point) + // The resolution. A round that detonated (a hit, or a burst) bursts now, at the simulation's burst point when + // it has one; a round the simulation resolved without a detonation (a contact or delayed round that missed, + // one whose target is gone) just falls away, with no explosion to show. + public void Resolve(bool detonated, Vector3? point) { if (!_alive || !_active) return; + if (!detonated) + { + StartCoroutine(FadeOut()); + return; + } if (point.HasValue) transform.position = point.Value; if (HitEffect != null) { @@ -195,7 +220,6 @@ public class GuidedProjectile : MonoBehaviour IEnumerator Kill() { - Binding?.Dispose(); Binding = null; _active = false; _alive = false; diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index 2e41570ef..a611b1106 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -16,8 +16,10 @@ public class GuidedProjectileManager : InstantWeaponEffectManager Vector3 At(ShotOutcome o) => new Vector3(o.BurstPoint.x, p.transform.position.y, o.BurstPoint.y); var bindings = new CompositeDisposable(); zone.ShotCommitted.Where(o => o.ShotId == shotId && o.HasBurstPoint).Subscribe(o => p.BurstAt(At(o))).AddTo(bindings); - zone.ShotResolved.Where(o => o.ShotId == shotId).Subscribe(o => p.Resolve(o.HasBurstPoint ? At(o) : (Vector3?) null)).AddTo(bindings); - p.Binding = bindings; + zone.ShotResolved.Where(o => o.ShotId == shotId) + .Subscribe(o => p.Resolve(o.Result == ShotResult.Hit || o.Result == ShotResult.Burst, o.HasBurstPoint ? At(o) : (Vector3?) null)) + .AddTo(bindings); + p.Binding = bindings; // replaces, and so disposes, any binding the projectile already had } public Prototype ProjectilePrototype; From 7e620ff9f22013af7bf4b83db74c1659af6bda46 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:51:57 +0200 Subject: [PATCH 094/116] Run the refused-round cost test through Execute as well as the trigger The trigger's own refusal check stopped a short-Range round before Execute ever reached its refusal, so wear, heat, visibility and sound left ahead of Execute's `continue` survived every mutation. The cost theory now also authorises a burst directly, the way a trigger that passed leaves it, and the separate ammo-only test folds into it. Co-Authored-By: Claude Sonnet 5 --- .../FireControlPerWeaponTests.cs | 45 ++++++------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index c1276745e..8ee6e70a4 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -374,7 +374,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. - private Costs Pull(float range, bool single) + private Costs Pull(float range, bool single, bool viaTrigger) { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single); r.Shooter.Target.Value = null; @@ -393,7 +393,8 @@ public sealed class FireControlPerWeaponTests : IDisposable var ammoBefore = weapon.Ammo; Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine"); - weapon.Activate(); + if (viaTrigger) weapon.Activate(); + else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); r.Zone.Update(.01f); costs.Ammo = ammoBefore - weapon.Ammo; @@ -405,15 +406,18 @@ public sealed class FireControlPerWeaponTests : IDisposable // Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that // never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no - // visibility, no shot. Kills, one per row of the assertions: a spend that precedes the refusal (Execute or - // Trigger), an announcement, a sound, wear, heat or visibility left ahead of the `continue`. - // `single` is a weapon that pays once per burst, at the trigger rather than at the round. + // visibility, no shot. `single` is a weapon that pays once per burst, at the trigger rather than at the round; + // `viaTrigger` false authorises the burst directly (the way a trigger that passed leaves it) on a weapon whose + // Range is short, so the Trigger check cannot be what stops the round and Execute must refuse it itself. + // Kills, one per row of the assertions: a spend that precedes the refusal (Execute or Trigger), an + // announcement, a sound, wear, heat or visibility left ahead of the `continue`. [Theory] - [InlineData(false)] - [InlineData(true)] - public void ARefusedDiscreteRoundCostsNothing(bool single) + [InlineData(false, true)] + [InlineData(true, true)] + [InlineData(false, false)] + public void ARefusedDiscreteRoundCostsNothing(bool single, bool viaTrigger) { - var fired = Pull(40f, single); + var fired = Pull(40f, single, viaTrigger); Assert.True(fired.Shots == 1, $"shots {fired.Shots}"); Assert.True(fired.Ammo == 1, $"ammo spent {fired.Ammo}"); Assert.True(fired.ChargeSpent > 1f, "a fired round spends its energy"); @@ -423,7 +427,7 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(fired.Visible); Assert.Equal(1, fired.Announced); - var refused = Pull(29.5f, single); + var refused = Pull(29.5f, single, viaTrigger); Assert.Equal(0, refused.Shots); Assert.Equal(0, refused.Ammo); Assert.Equal(0f, refused.ChargeSpent); @@ -434,27 +438,6 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(0, refused.Announced); } - // The trigger and the round are separate decisions: a burst authorised at the trigger runs its rounds later, - // at whatever Range Execute has refreshed by then, and a round that Range no longer reaches is refused before - // its spend. The authorisation is set directly (the way a trigger that passed leaves it) on a weapon whose - // Range is short of its blast radius, so the Trigger check cannot be what stops it. Kills: Execute spending - // before it asks. - [Fact] - public void ARoundRefusedAfterTheTriggerPassedIsRefusedBeforeItsSpend() - { - var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, magazine: 5); - r.Shooter.Target.Value = null; - Aim(r, float2(0, 1)); - var weapon = (InstantWeapon) r.Gun.Weapon; - typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); - - r.Zone.Update(.01f); - - Assert.Equal(5, weapon.Ammo); - SafeAssert.NoShots(r.Zone); - Assert.Equal(0, (int) typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon)); - } - // The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws // nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of // that, asks the bus for nothing, and stops itself the way an out-of-arc beam does. From d3be00c23a4ead2df469eb9c0606d6144db5f661 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:10:06 +0200 Subject: [PATCH 095/116] Record the all-fuses first-hull ruling and the batch 5 Soul pass Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 0823120ee..9235a666a 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2629,8 +2629,10 @@ follow-up rulings, all given on 2026-09-30: the fix had used the hull-fixed mount. - **An invalid target counts as no lock.** A target that is selected but out of range, out of arc, not visible, or not locked takes the no-lock path. Nothing bursts beyond max range. -- **A no-lock contact fuse stops on the first hull on its aim line**, computed at commit. Mining adds chunks - to this through a named seam. +- **A no-lock round of any fuse stops on the first hull on its aim line**, computed at commit, and detonates + there. This covers contact, proximity and delayed fuses (operator, 2026-09-30, extending the contact-only + ruling). A round fired along a clear line still bursts at max range (bomb fishing). Mining adds chunks to + this through a named seam. - **Airburst follows player intent (operator, 2026-09-30):** "If I don't have targeting data but decide to shoot anyway, my intent is likely to be bomb fishing. If I fire with a target but that target warps away, I likely to not want to explode whatever was behind it, and the projectile shouldn't burst." @@ -2661,6 +2663,18 @@ follow-up rulings, all given on 2026-09-30: percentage. - **A refused round is free (operator, 2026-09-30):** it spends no ammo or energy, and makes no sound, heat, wear or visibility. +- **Batch 5 Soul pass, 2026-09-30: fix first.** `Solve` is the one decision, and `Fire`, `Inspect` and + `Refuses` all read it. Findings in fix batch 6 (Hands, `hands/fuse-fix2`): + - Contact and delayed rounds detonate on the hull and ignore the arming push-out. + - A refused beam blinks start and stop. + - A throwing damage observer re-applies a blast every tick. + - The AI selects refused fused weapons. + - A single-ammo burst pays, then refuses. + - The guided projectile's overshoot explosion ignores the simulation. + - Two copies of the range and designation predicates remain. + + An out-of-arc round with a selected target bursts after that target leaves. That is ruled behaviour: an + out-of-arc target is invalid, so the round takes the no-lock path. - **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go nowhere. From 63a2fcdaa4a124ce80199c3cb79075ec7be9bc74 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:20:06 +0200 Subject: [PATCH 096/116] Guided projectile: only the simulation's detonation shows an explosion The overshoot branch of GuidedProjectile.Update (out of range, or flown past its target) instantiated HitEffect whatever the simulation had decided. A round the simulation never detonated could therefore explode on screen. The branch now fades the round out; Resolve stays the one place a HitEffect is shown for a detonation. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index db2a8674d..c66ede374 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -134,14 +134,11 @@ public class GuidedProjectile : MonoBehaviour var targetDist = diff.magnitude; var sourceDist = length(StartPosition.xz - position.xz); + // Out of range, or flown past its target: the round falls away. Only the simulation's detonation + // (Resolve) shows an explosion; a round that merely ran out of flight has none. if (sourceDist > Range || dot(diff.ToCultMath(), Velocity.ToCultMath()) < 0) { StartCoroutine(FadeOut()); - if (HitEffect != null) - { - var ht = HitEffect.Instantiate(); - ht.position = t.position; - } return; } _prevDist = targetDist; From 57c2560fc01fed4353ca2e5cd889ba5af6aeebb3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:31:59 +0200 Subject: [PATCH 097/116] Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 2 +- .../ServerShared/Agents/States/Combat.cs | 10 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 4 +- .../ServerShared/Behaviors/InstantWeapon.cs | 16 +- Assets/Scripts/ServerShared/FireControl.cs | 91 ++++++----- .../FireControlCut124Tests.cs | 121 +++++++++++++-- .../FireControlCut9Tests.cs | 8 +- .../FireControlPerWeaponTests.cs | 142 +++++++++++++++++- 8 files changed, 325 insertions(+), 69 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 040bd4644..543e8f8ef 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1335,7 +1335,7 @@ public class ActionGameManager : MonoBehaviour : "last: none"; var gates = target == null ? "target: none" - : $"gates vis {d.Visible} range {d.InRange} arc {d.InArc} lock {d.Locked}"; + : $"gates designated {d.Designated} arc {d.InArc}"; // A fused weapon's forecast is its outcome, not a hit chance: the burst point or the refusal. var outcomeLine = d.Outcome == FireOutcome.Burst ? $"burst at {d.BurstReach:F0}" diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 41e2511da..d27a90ab1 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -57,10 +57,12 @@ public class CombatState : BaseState var group = _agent.Ship.WeaponGroups[i]; var dps = 0f; foreach (var weapon in group.weapons) - if (weapon.Item.Online.Value && - weapon.MinRange < targetDistance && - targetDistance < weapon.Range && - (weapon is ConstantWeapon || + // The range test is FireControl's (InReach, the part of Designated before the lock, which this + // loop is what acquires), and a weapon Solve refuses adds nothing to a group's damage. + if (weapon.Item.Online.Value && + FireControl.InReach(weapon, _agent.Ship, target, out _) && + !FireControl.Refuses(weapon, _agent.Ship) && + (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) { if(weapon is LockWeapon lockWeapon) diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 704635678..e9dab4ce6 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -194,7 +194,9 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override void Activate() { - if(!_firing && !_reloading) + // 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. + if(!_firing && !_reloading && !FireControl.Refuses(this, Entity)) { _firing = true; OnStartFiring?.Invoke(); diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index e4f4618be..59b7f6344 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -41,6 +41,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo protected readonly InputCapacitor _capacitor = new InputCapacitor(); protected int _burstRemaining; + private bool _burstPaid; private float _burstTimer; private float _burstInterval; protected float _cooldown; // Normalized @@ -107,10 +108,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // whose first round FireControl would refuse. if (FireControl.Refuses(this, Entity)) return; - // If 1 ammo is consumed per burst, perform ammo and energy consumption here - // UseAmmo returns false when triggering reload; cancel firing if that is the case - if(_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) return; - + // A burst that pays once (SingleAmmoBurst) pays at its first round that is not refused, in Execute, judged + // with the Range that round flies with: paying here would judge with the Range of the previous tick, and a + // Range that fell in between would spend the ammo on rounds Fire then refuses. + _burstPaid = false; _burstRemaining = (int) BurstCount; _burstInterval = BurstTime / _burstRemaining; _burstTimer = 0; @@ -234,13 +235,14 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // spent (operator ruling 2026-09-30): it costs no ammo, energy, sound, heat, wear or visibility. // The decision is FireControl's; Fire makes the same one. var refused = FireControl.Refuses(this, Entity); - // If multiple ammo is consumed per burst, perform ammo and energy consumption here - // UseAmmo returns false when triggering reload; cancel firing if that is the case - if (!refused && !_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) + // 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())) { _burstRemaining = 0; return false; } + if (!refused) _burstPaid = _data.SingleAmmoBurst; _burstRemaining--; _burstTimer -= _burstInterval; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 4a6afb5e5..8bdada43b 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -88,11 +88,15 @@ public static class FireControl // The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30). // A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its // target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never - // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish. - public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) => - FuseOf(weapon.Item, out _) != null - ? Designated(weapon, shooter, target, out _) - : HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + // 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. + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) + { + if (FuseOf(weapon.Item, out _) == null) + return HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + var solution = Solve(weapon, weapon.Item, shooter, target); + return solution.Designated && solution.Outcome != FireOutcome.Refused; + } // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The @@ -225,21 +229,25 @@ public static class FireControl // visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the // burst reach and the range clamp all measure the same distance, so a target's height never moves one of // them without the others. - public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) + public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) => + InReach(weapon, source, target, out range) && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); + + // Designated before the lock: the target is seen and within the weapon's planar range. A LockWeapon acquires + // its lock only while its shooter holds the trigger, so an agent asks this to know when to start locking; + // every other reader wants Designated. The one range test in the file. + public static bool InReach(Weapon weapon, Entity source, Entity target, out float range) { range = 0f; if (target == null) return false; range = length((target.Position - source.Position).xz); - return source.VisibleEntities.Contains(target) - && range >= weapon.MinRange && range <= weapon.Range - && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); + return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range; } // `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated. // PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at // (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but - // out of arc it bursts at the target's range, along the aim. Inspect recomputes the four gates one by one, - // to show the HUD which is closed; it decides nothing. + // out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the + // arc from Solve and Inspect and decides nothing. private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc) { range = 0f; @@ -337,9 +345,7 @@ public static class FireControl var toTarget = target.Position - source.Position; diagnostic.Range = length(toTarget.xz); - diagnostic.Visible = source.VisibleEntities.Contains(target); - diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range; - diagnostic.Locked = !(weapon is LockWeapon lockWeapon) || lockWeapon.IsLocked; + diagnostic.Designated = solution.Designated; diagnostic.InArc = InArc(weapon.Item, toTarget); var targetHull = source.ItemManager.GetData(target.Hull) as HullData; @@ -447,6 +453,9 @@ public static class FireControl // lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing; // * valid data but out of arc bursts at the target's range, which the range gate has already held to // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. + // Either way the point frozen here is the farthest the round can burst: whatever its fuse, it detonates at the + // first hull on its aim line short of it (CommitBurst; operator ruling 2026-09-30, "a no-lock round stops at + // the first hull"), and bomb fishing bursts at this point only when the line is clear. // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot // without a fuse keeps its target as before. // Arming distance (operator ruling 2026-09-30): the blast radius, measured from the shooter's position and @@ -469,15 +478,15 @@ public static class FireControl // pulled back along its own line to Range. var intercept = PredictedIntercept(weapon, source, engaged); var interceptReach = length((intercept - origin).xz); - if (min(interceptReach, weapon.Range) < arming) burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * arming; - else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + var point = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + burstPosition = Armed(origin, travelDirection, arming, point); flightDistance = max(fireRange, arming); } else if (fuse != null) { - var reach = max(designated ? fireRange : weapon.Range, arming); - burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; - flightDistance = reach; + var reach = designated ? fireRange : weapon.Range; + burstPosition = Armed(origin, travelDirection, arming, origin + float3(travelDirection.x, 0, travelDirection.y) * reach); + flightDistance = max(reach, arming); } else if (engaged != null) flightDistance = fireRange; @@ -486,6 +495,7 @@ public static class FireControl Outcome = weapon.Range < arming ? FireOutcome.Refused : fuse != null ? FireOutcome.Burst : FireOutcome.Direct, PFire = pFire, FireRange = fireRange, + Designated = designated, Engaged = engaged, TravelDirection = travelDirection, BurstPosition = burstPosition, @@ -497,6 +507,12 @@ public static class FireControl return solution; } + // The arming rule, once: a burst point nearer to `origin` than `arming` (planar, blind to hull size) is pushed + // out along the unit planar `direction` to it. Solve applies it to the point it plans; the detonation of a + // contact or delayed blast, whose point is found on the hull only at resolution, applies it to that point. + private static float3 Armed(float3 origin, float2 direction, float arming, float3 point) => + length((point - origin).xz) < arming ? origin + float3(direction.x, 0, direction.y) * arming : point; + // Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes flight time, freezes the // payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and queues a PendingShot for // Zone.Step to age and eventually resolve. Returns the ShotId so the caller's OnFire event can carry it to @@ -582,9 +598,10 @@ public static class FireControl // its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked // for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot. // Resolution reads the zone and the target; there is no credit or loot for a shooter to receive. - // A shot's queue state is settled before anything is published about it: it is committed in the list before - // ShotCommitted fires and removed before ShotResolved fires, so a presentation observer that throws costs - // that one notification and cannot leave the shot to be committed or resolved again on every later tick. + // A shot's queue state is settled before anything that can run user code: it is committed in the list before + // ShotCommitted fires and removed before its damage is applied (damage observers) or ShotResolved fires, so an + // observer that throws costs that one notification, or the rest of that blast, and cannot leave the shot to be + // committed, resolved or applied again on every later tick. public static void Step(Zone zone, float dt) { var shots = zone.PendingShots; @@ -627,8 +644,10 @@ public static class FireControl // 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. - Apply(zone, shot); + // 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); } } @@ -772,9 +791,9 @@ public static class FireControl return outcome; } - // Operator rulings 2026-09-30. A fused round with no target bursts at the max-range point Fire froze along - // the aim line, whatever its fuse -- except a Contact round, which bursts at the first hull that line crosses - // before that point (a Proximity or Delayed fuse does not trip on a hull with nothing locked). The point is + // Operator rulings 2026-09-30. A fused round with no target bursts at the first hull its aim line crosses + // before the max-range point Fire froze, whatever its fuse (contact, proximity and delayed alike: a round with + // nothing locked stops on what is in its way), and at the max-range point when the line is clear. The point is // read from this tick's poses and frozen into the outcome (R4, the same timing Commit reads facing at). // Nothing is rolled or drawn, so determinism needs nothing beyond the frozen inputs. private static ShotOutcome CommitBurst(Zone zone, PendingShot shot, float now) @@ -782,8 +801,7 @@ public static class FireControl var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now); outcome.HasBurstPoint = true; var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); - var travelled = shot.Fuse == WeaponFuse.Contact ? ContactDistance(zone, shot, reach) : reach; - travelled = min(reach, max(travelled, shot.BlastRadius)); + var travelled = min(reach, max(HullDistance(zone, shot, reach), shot.BlastRadius)); if (travelled < reach) { outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; @@ -794,11 +812,11 @@ public static class FireControl return outcome; } - // The distance along a no-lock round's aim line to the first thing a contact fuse trips on, or `reach` (the + // The distance along a no-lock round's aim line to the first hull it crosses, or `reach` (the // max-range point) when it meets nothing first. The shooter's own hull is never a candidate. // SEAM FOR THE MINING CAMPAIGN: mining chunks are not Entities and are not on this branch. When they land, // whatever they are contributes its own entry distance to this minimum, and nothing else in the file changes. - private static float ContactDistance(Zone zone, PendingShot shot, float reach) + private static float HullDistance(Zone zone, PendingShot shot, float reach) { var nearest = reach; foreach (var entity in zone.Entities) @@ -813,7 +831,7 @@ public static class FireControl // `origin` to the first metal cell ahead, +infinity when the line meets none, and negative when the origin is // already inside the hull (the blast radius floors every caller's use of it). Read through Lane's own cell // admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset is its own, and cells - // wholly behind the origin are skipped. Only a contact fuse asks. + // wholly behind the origin are skipped. Only a no-lock round asks. private static float HullEntry(Entity entity, float2 origin, float2 direction) { var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; @@ -1025,7 +1043,10 @@ public static class FireControl // cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while // every bystander is judged against where it actually is at arrival -- the same live rule a proximity // burst already applies to everyone. - var worldPoint = target.ToWorldPoint(schematicPoint); + // The arming rule applies to this point as it does to every other burst (Solve's Armed): a hull met nearer + // the muzzle than the blast radius is detonated on at the radius, along the round's own flight line. + var hullPoint = target.ToWorldPoint(schematicPoint); + var worldPoint = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(hullPoint.x, shot.FireOrigin.y, hullPoint.y)).xz; Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType); } @@ -1789,6 +1810,8 @@ public struct FireSolution public float FlightDistance; public float PFire; public float FireRange; + // Whether the shooter holds valid targeting data on the target (Designated); the HUD and the agents read it here. + public bool Designated; public Entity Engaged; public float2 TravelDirection; public float3 BurstPosition; @@ -1799,9 +1822,7 @@ public struct FireSolution public struct FireControlDiagnostic { public bool HasTarget; - public bool Visible; - public bool InRange; - public bool Locked; + public bool Designated; public bool InArc; public float Range; public float MinRange; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index ca50e475c..16f663b58 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1520,6 +1520,103 @@ public sealed class FireControlCut124Tests : IDisposable SafeAssert.NoShots(e.Zone); } + public enum Observer { HullDamage, ArmorDamage, IncomingHit } + + // Nothing the queue does is left to a user callback that may throw: a shot is out of the queue before its + // damage is applied, so a damage observer that throws costs the rest of that blast, once, and the shot is not + // applied again on every later tick (its blast, and the observer, used to repeat forever). The observer throws + // on its first call; the following tick is clean and the observer has been called exactly once. Contact for + // the hit report (a roll can miss, so it retries), Proximity for the damage events. + // Kills: applying the shot's damage before removing it from the queue. + [Theory] + [InlineData(Observer.HullDamage)] + [InlineData(Observer.ArmorDamage)] + [InlineData(Observer.IncomingHit)] + public void AThrowingDamageObserverIsCalledOnceAndCannotWedgeTheShotQueue(Observer observer) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: observer == Observer.IncomingHit ? WeaponFuse.Contact : WeaponFuse.Proximity, + blastRadius: 4f, penetration: 1f, damage: 100f); + if (observer == Observer.ArmorDamage) ArmourEverything(e); + var calls = 0; + void Throw() { calls++; throw new InvalidOperationException("observer"); } + using var a = observer == Observer.ArmorDamage ? e.Target.ArmorDamage.Subscribe(_ => Throw()) : Disposable.Empty; + using var h = observer == Observer.HullDamage ? e.Target.HullDamage.Subscribe(_ => Throw()) : Disposable.Empty; + using var i = observer == Observer.IncomingHit ? e.Target.IncomingHit.Subscribe(_ => Throw()) : Disposable.Empty; + + for (var attempt = 0; attempt < 60 && calls == 0; attempt++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + Record.Exception(() => e.Zone.Update(.01f)); + } + Assert.Equal(1, calls); + var later = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(later); + Assert.Equal(1, calls); + SafeAssert.NoShots(e.Zone); + } + + // A round pushed out to its arming distance flies the arming distance: its arrival is that far off, not the + // target's own (5 units, a quarter second) range. 30 units at 20 units/s is 1.5 seconds. Kills: a flight + // distance of the target's range for a pushed-out round. + [Fact] + public void APushedOutRoundDoesNotArriveBeforeItHasFlownTheArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "fixture: the pushed-out round keeps its target"); + Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); + } + + // What the shooter's own hull takes from one round at `targetRange`, counting only rounds that hit when the + // fuse needs a hit (a roll can miss, so it retries). + private static float ShooterSplash(Engagement e, bool needsHit) + { + var taken = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => taken += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => taken += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => taken += x.damage); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + for (var attempt = 0; attempt < 60; attempt++) + { + taken = 0f; + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + if (!needsHit || outcome != null && outcome.Hit) return taken; + } + throw new InvalidOperationException("no round hit in 60 attempts"); + } + + // The arming ruling holds for every fuse, at the point a contact or delayed round detonates on a hull: no + // burst nearer to the shooter than the blast radius. A round that hits a target inside the radius (5 out), or + // one whose near face is inside it (a target 32 out has its face at 28), is detonated on at the radius (30), + // so the shooter takes what any burst 30 out on its line deals: the amount a proximity round at exactly 30 + // deals. Kills: a hull-point detonation that ignores the arming distance (the shooter then took 7.07 from a + // blast 30 at 5 out, more than the armed burst). + [Theory] + [InlineData(WeaponFuse.Contact, 5f)] + [InlineData(WeaponFuse.Delayed, 5f)] + [InlineData(WeaponFuse.Contact, 32f)] + public void AContactOrDelayedBlastNeverDetonatesNearerThanTheArmingDistance(WeaponFuse fuse, float targetRange) + { + var armed = ShooterSplash(Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, penetration: 1f, + damage: 100f, weaponRange: 60f, targetRange: 30f), needsHit: false); + Assert.True(armed > 0f, "fixture: the shooter's own hull takes splash from a burst at the radius"); + + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f, + damage: 100f, weaponRange: 60f, targetRange: targetRange); + var splash = ShooterSplash(e, needsHit: true); + + Assert.Equal(armed, splash, 3); + } + // A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its // intercept. Kills: demoting every fused round to the aim. [Fact] @@ -1661,7 +1758,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(shot.Target == e.Target, "the target is inside Range in the plane, so it is valid data"); Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); - Assert.True(diagnostic.InRange, "the HUD's range gate is planar too"); + Assert.True(diagnostic.Designated, "the HUD's designation is planar too"); Assert.Equal(40f, diagnostic.Range, 2); } @@ -1790,15 +1887,15 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(e.Zone.PendingShots[0].Committed, "and commits once its horizon is reached, with the round still in flight"); } - // Ruling 3: a Contact round with no lock stops on the first hull its aim line crosses before max range; a - // Proximity or Delayed round keeps the max-range burst. The blocker's near face sits 21 units out (a 5x4-cell - // hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a Contact fuse that ignores - // hulls; a Proximity or Delayed fuse that trips on one. + // Ruling 3 (operator, 2026-09-30): a no-lock round of any fuse stops on the first hull its aim line crosses + // before max range and detonates there; with the line clear it bursts at max range. The blocker's near face + // sits 21 units out (a 5x4-cell hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a + // fuse that ignores hulls (a Proximity or Delayed round used to fly on to max range). [Theory] - [InlineData(WeaponFuse.Contact, true)] - [InlineData(WeaponFuse.Proximity, false)] - [InlineData(WeaponFuse.Delayed, false)] - public void OnlyAContactRoundWithNoLockStopsOnTheFirstHullItCrosses(WeaponFuse fuse, bool stopsOnTheBlocker) + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Delayed)] + public void ANoLockRoundOfAnyFuseStopsOnTheFirstHullItCrosses(WeaponFuse fuse) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, weaponRange: NoLockRange); @@ -1813,11 +1910,11 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.NotNull(outcome); - var expectedReach = stopsOnTheBlocker ? 21f : NoLockRange; + const float expectedReach = 21f; Assert.Equal(e.Shooter.Position.z + expectedReach, outcome.BurstPoint.y, 2); Assert.Equal(e.Shooter.Position.x, outcome.BurstPoint.x, 2); - Assert.Equal(!stopsOnTheBlocker, Alive(e, blocker)); - Assert.Equal(stopsOnTheBlocker, Alive(e, atMaxRange)); + Assert.False(Alive(e, blocker)); + Assert.True(Alive(e, atMaxRange), "the witness at max range is beyond the stop"); } // Ruling 3: a hull beyond max range is not on the round's way, and the shooter's own hull, which the line diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 2300108fd..d824d4278 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -428,7 +428,7 @@ public sealed class FireControlCut9Tests : IDisposable e.Shooter.EntityInfoGathered[e.Target] = info; var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); - Assert.True(hud.Visible && hud.InRange && hud.Locked && hud.InArc, $"info {info}: precondition, every gate open"); + Assert.True(hud.Designated && hud.InArc, $"info {info}: precondition, every gate open"); Assert.True(hot > 0f, $"info {info}, Precision {precision}: precondition, a shot that can land"); Assert.Equal(hot, hud.Accuracy * hud.PSensor * hud.PSpread * hud.POnHull, 5); sawPartialSpread |= hud.PSpread < .99f; @@ -445,7 +445,7 @@ public sealed class FireControlCut9Tests : IDisposable } e.Target.Position = origin + float3(0, 0, 5000); - Closed("out of range", d => d.InRange); + Closed("out of range", d => d.Designated); e.Target.Position = origin + float3(0, 0, -100); Closed("behind the mount", d => d.InArc); @@ -455,13 +455,13 @@ public sealed class FireControlCut9Tests : IDisposable e.Target.Position = float3(600, 0, 500); var far = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); Assert.Equal(500f, far.Range, 3); - Assert.True(far.InRange && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin"); + Assert.True(far.Designated && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin"); checkedConfigurations++; e.Shooter.Position = origin; e.Target.Position = origin + float3(0, 0, 100); e.Shooter.VisibleEntities.Remove(e.Target); - Closed("not visible", d => d.Visible); + Closed("not visible", d => d.Designated); Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget); Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null)); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 8ee6e70a4..4f9ffe282 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -75,6 +75,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public Ship Target; public List Guns = new List(); public Gun Gun => Guns[0]; + public Gun Decoy; } // Mount 0 (None, arc 0) bears on a target dead ahead; (Clockwise, arc 0) is a side mount the default 170 @@ -83,7 +84,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private Rig Build( (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, - bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false) + bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false) { mounts ??= new[] { (ItemRotation.None, 0f) }; var hullData = new HullData @@ -101,6 +102,14 @@ public sealed class FireControlPerWeaponTests : IDisposable Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(), Rotation = mounts[i].Rotation, FiringArc = mounts[i].Arc }); + // A second weapon type on its own mount (a group of its own, since groups are per item type), dead ahead + // and unfused, weaker than the gun: what a group pick falls back to when the gun's group is refused. + if (decoy) + shooterHullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(mounts.Length, 0), Shape = new Shape(), + Rotation = ItemRotation.None, FiringArc = 0f + }); WeaponData behavior; if (beam) @@ -138,6 +147,21 @@ public sealed class FireControlPerWeaponTests : IDisposable Fuse = fuse, BlastRadius = blast, Behaviors = { behavior } }); + cache.Upsert(new WeaponItemData + { + Name = "Decoy", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = + { + new InstantWeaponData + { + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), Damage = Constant(50), Range = Constant(100), + MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), + Energy = Constant(0), Heat = Constant(0), Visibility = Constant(0), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } + } + }); cache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, @@ -180,6 +204,13 @@ public sealed class FireControlPerWeaponTests : IDisposable var item = shooter.Equipment.Single(x => x.EquippableItem == gun); rig.Guns.Add(new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }); } + if (decoy) + { + var d = Make("Decoy", lot++, 1); + Assert.True(shooter.TryEquip(d, new int2(mounts.Length, 0))); + var item = shooter.Equipment.Single(x => x.EquippableItem == d); + rig.Decoy = new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }; + } var targeting = Make("Targeting", lot++, 1); Assert.True(shooter.TryFindSpace(targeting, out var tpos)); Assert.True(shooter.TryEquip(targeting, tpos)); @@ -374,7 +405,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. - private Costs Pull(float range, bool single, bool viaTrigger) + private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false) { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single); r.Shooter.Target.Value = null; @@ -393,6 +424,9 @@ public sealed class FireControlPerWeaponTests : IDisposable var ammoBefore = weapon.Ammo; Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine"); + // The Range the previous tick's Execute left, before this tick refreshes it: a power-dependent Range that + // has since fallen. Set through the property's own setter, so the trigger sees a Range the round will not. + if (staleRange) typeof(Weapon).GetProperty("Range").GetSetMethod(true).Invoke(weapon, new object[] { 100f }); if (viaTrigger) weapon.Activate(); else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); r.Zone.Update(.01f); @@ -406,7 +440,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that // never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no - // visibility, no shot. `single` is a weapon that pays once per burst, at the trigger rather than at the round; + // visibility, no shot. `single` is a weapon that pays once per burst, at its first unrefused round rather than at each; // `viaTrigger` false authorises the burst directly (the way a trigger that passed leaves it) on a weapon whose // Range is short, so the Trigger check cannot be what stops the round and Execute must refuse it itself. // Kills, one per row of the assertions: a spend that precedes the refusal (Execute or Trigger), an @@ -440,7 +474,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws // nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of - // that, asks the bus for nothing, and stops itself the way an out-of-arc beam does. + // that, asks the bus for nothing, and never starts (ARefusedBeamNeverStarts). // Kills: ConstantWeapon spending (power request, wear, heat, visibility) before the refusal, or not // consulting FireControl at all. private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range) @@ -481,6 +515,104 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(0f, refused.Request); Assert.False(refused.Visible); Assert.True(fired.Heat - refused.Heat > 5f, $"a refused beam adds no heat ({refused.Heat} vs {fired.Heat})"); - Assert.True(refused.Stops > 0 && !refused.Firing, "a refused beam stops itself, as an out-of-arc beam does"); + Assert.True(refused.Stops == 0 && !refused.Firing, "a refused beam never starts, so it never has to stop"); + } + + // A beam is a stream of rounds, so a refused beam never starts: OnStartFiring drives the beam's visuals and + // audio, and starting it for Execute to stop a tick later would flash a beam that fires nothing. The control + // (Range 40) starts once and keeps firing. Kills: Activate that starts a beam without asking FireControl. + [Theory] + [InlineData(40f, 1)] + [InlineData(29.5f, 0)] + public void ARefusedBeamNeverStarts(float range, int starts) + { + var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ConstantWeapon) r.Gun.Weapon; + var started = 0; + weapon.OnStartFiring += () => started++; + + weapon.Activate(); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + + Assert.Equal(starts, started); + Assert.Equal(starts == 1, weapon.Firing); + } + + // A refused weapon is not one an agent can use: a fused weapon whose arming distance (30) exceeds its Range + // (29.5) is not fired at a target inside that Range, where the same weapon with Range to spare is. + // Kills: AgentFires answering "designated" without asking whether Solve refuses. + [Theory] + [InlineData(29.5f, false)] + [InlineData(40f, true)] + public void AnAgentNeverFiresARefusedFusedWeapon(float range, bool fires) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: 20f); + + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // Combat's group pick counts only weapons that can fire: the refused gun's group out-damages the decoy's, so a + // pick that counted it would select a group whose weapons are then never fired, and the decoy would stay silent. + // Kills: Combat counting a refused weapon's damage toward its group. + [Fact] + public void CombatDoesNotPickAGroupWhoseWeaponIsRefused() + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, targetRange: 20f, decoy: true); + Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the refused gun is the stronger group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Gun.Weapon.Firing, "a refused weapon is never activated"); + Assert.True(r.Decoy.Weapon.Firing, "the group that can fire is the one picked"); + } + + // Combat's range test is Designated's, not a second one: a target exactly at max range is in reach (the old + // private test was strict). Kills: a range comparison of Combat's own. + [Fact] + public void CombatCountsATargetExactlyAtMaxRangeAsInReach() + { + var r = Build(range: 60f, targetRange: 60f); + // The agent is driven directly, not through the zone, so the shooter stays exactly at max range: the first + // update leaves the root state for combat, the second is combat deciding. + var agent = new Minion(r.Shooter); + agent.Update(.01f); + agent.Update(.01f); + + Assert.True(r.Gun.Weapon.Firing); + } + + // The trigger's threshold is inclusive: a shot priced exactly at AgentMinHitProbability is worth taking, and + // one priced a hair under it is not. Kills: `>` for `>=`. + [Fact] + public void AnAgentFiresAtExactlyTheHitProbabilityThreshold() + { + var probe = Build(accuracy: .5f); + var price = FireControl.HitProbability(probe.Gun.Weapon, probe.Shooter, probe.Target); + Assert.True(price > 0f); + + var atThreshold = Build(accuracy: .5f, minHit: price); + var overThreshold = Build(accuracy: .5f, minHit: MathF.BitIncrement(price)); + + Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target)); + Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target)); + } + + // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid + // (SingleAmmoBurst) at its first round that is not refused, judged with the Range that round flies with. + // The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. + // Kills: paying for the burst at the trigger. + [Fact] + public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing() + { + var refused = Pull(29.5f, single: true, viaTrigger: true, staleRange: true); + + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Ammo); + Assert.Equal(0f, refused.ChargeSpent); + Assert.Equal(0, refused.Announced); } } From d00d1580071f2844bfe613a2407d846ea4b67b0b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:42:30 +0200 Subject: [PATCH 098/116] Pin the flight distance of a pushed-out no-lock round The mutation pass on the fuse fixes left one survivor: a no-lock round whose aim ends inside the blast radius (a designated target out of arc) flew only to its aimed point instead of the arming distance, and nothing noticed. Its arrival is now pinned to the arming distance, beside the targeted case. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut124Tests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 16f663b58..d55bc6df6 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1573,6 +1573,25 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); } + // The same for a no-lock round: a target 10 out behind a mount whose arc is closed is designated but out of + // arc, so the round flies the aim to the target's range, inside the blast radius, and is pushed out to 30. It + // arrives after 30 units at 20 units/s, not after the 10 it was aimed at. Kills: a no-lock flight distance + // that ignores the arming distance. + [Fact] + public void APushedOutNoLockRoundDoesNotArriveBeforeItHasFlownTheArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 10f); + e.Shooter.Direction = float2(0, -1); + Aim(e, float2(0, -1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == null, "fixture: out of arc, the round carries no target"); + Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); + } + // What the shooter's own hull takes from one round at `targetRange`, counting only rounds that hit when the // fuse needs a hit (a roll can miss, so it retries). private static float ShooterSplash(Engagement e, bool needsHit) From 76e78db4b2c36ca64cb867b6d6765393d47918a9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:52:21 +0200 Subject: [PATCH 099/116] Record that a refused round starts no cooldown Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 9235a666a..265452916 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2662,7 +2662,8 @@ follow-up rulings, all given on 2026-09-30: at N m, Refused). `Fire` and `Inspect` compute it with the same helper. Direct-hit weapons keep the percentage. - **A refused round is free (operator, 2026-09-30):** it spends no ammo or energy, and makes no sound, heat, - wear or visibility. + wear or visibility. **It does not start the cooldown either** (operator, same day: "triggering cooldown on + refusal would be mostly neutral but punishing for long cooldown weapons, worth the code to fix"). - **Batch 5 Soul pass, 2026-09-30: fix first.** `Solve` is the one decision, and `Fire`, `Inspect` and `Refuses` all read it. Findings in fix batch 6 (Hands, `hands/fuse-fix2`): - Contact and delayed rounds detonate on the hull and ignore the arming push-out. From 136fddf15a0a43b9026f0167c752718e42b0c01d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:16:52 +0200 Subject: [PATCH 100/116] Start the cooldown at a burst's first unrefused round; refused charged weapons never charge A burst whose every round is refused fires nothing and leaves no cooldown: the cooldown moves from Trigger to the first round Execute does not refuse, and CanFire holds a triggered burst that has not yet fired. ChargedWeapon.Activate asks FireControl before it starts charging, so a refused weapon pays no charge heat or audio. InReach is deleted: Designated carries the one range test and the lock gate, and Combat reads it. GuidedProjectile answers Resolve while alive and keeps its binding through the shot's flight time, so a faded round still shows the simulation's detonation. Co-Authored-By: Claude Opus 5.5 --- .../Gameplay/Weapons/GuidedProjectile.cs | 16 +++- .../Weapons/GuidedProjectileManager.cs | 3 + .../ServerShared/Agents/States/Combat.cs | 6 +- .../ServerShared/Behaviors/ChargedWeapon.cs | 3 +- .../ServerShared/Behaviors/InstantWeapon.cs | 20 ++++- Assets/Scripts/ServerShared/FireControl.cs | 11 +-- .../FireControlPerWeaponTests.cs | 81 ++++++++++++++++--- 7 files changed, 113 insertions(+), 27 deletions(-) diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index c66ede374..4eb75f1ad 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -35,6 +35,7 @@ public class GuidedProjectile : MonoBehaviour private float _prevDist; private bool _active; private bool _alive; + private float _spawnTime; private Vector3 _targetVelocity; private Vector3 _previousTargetPosition; @@ -46,6 +47,11 @@ public class GuidedProjectile : MonoBehaviour public Vector3 Velocity { get; set; } public Entity SourceEntity { get; set; } + // How long after spawn the round keeps its binding to the simulation's verdict: the shot's own flight time (the + // manager sets it from the committed shot's ArrivalTime), so a round that faded out early still lives to show + // the detonation the simulation resolves at arrival. 0 (the default) kills the round when its fade ends. + public float BindingLifetime { get; set; } + public event Action OnKill; // The simulation's verdict on this round, bound by GuidedProjectileManager to Zone.ShotCommitted and @@ -83,12 +89,15 @@ public class GuidedProjectile : MonoBehaviour // one whose target is gone) just falls away, with no explosion to show. public void Resolve(bool detonated, Vector3? point) { - if (!_alive || !_active) return; + // A round that already faded out (overshot, out of flight) still shows the simulation's detonation: only a + // round that has been killed is done. The fade is stopped so it cannot Kill the round a second time. + if (!_alive) return; if (!detonated) { - StartCoroutine(FadeOut()); + if (_active) StartCoroutine(FadeOut()); return; } + StopAllCoroutines(); if (point.HasValue) transform.position = point.Value; if (HitEffect != null) { @@ -101,6 +110,7 @@ public class GuidedProjectile : MonoBehaviour void OnEnable() { _active = _alive = true; + _spawnTime = Time.time; _phase = Random.value * 100; _prevDist = Single.MaxValue; Particles.startColor = Color.white; @@ -212,6 +222,8 @@ public class GuidedProjectile : MonoBehaviour yield return null; } + // Faded, but the simulation's verdict has not necessarily arrived: wait for it until the shot's flight is over. + while (Time.time - _spawnTime < BindingLifetime) yield return null; StartCoroutine(Kill()); } diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index a611b1106..e5eb74b79 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -19,6 +19,9 @@ public class GuidedProjectileManager : InstantWeaponEffectManager zone.ShotResolved.Where(o => o.ShotId == shotId) .Subscribe(o => p.Resolve(o.Result == ShotResult.Hit || o.Result == ShotResult.Burst, o.HasBurstPoint ? At(o) : (Vector3?) null)) .AddTo(bindings); + // The binding outlives a fade-out until the shot's own arrival (the moment the simulation resolves it), plus a + // second of margin; a shot no longer pending has nothing left to resolve. + p.BindingLifetime = zone.TryGetShot(shotId, out var shot) ? max(0f, shot.ArrivalTime - zone.Time) + 1f : 0f; p.Binding = bindings; // replaces, and so disposes, any binding the projectile already had } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index d27a90ab1..d605ff8a5 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -57,10 +57,10 @@ public class CombatState : BaseState var group = _agent.Ship.WeaponGroups[i]; var dps = 0f; foreach (var weapon in group.weapons) - // The range test is FireControl's (InReach, the part of Designated before the lock, which this - // loop is what acquires), and a weapon Solve refuses adds nothing to a group's damage. + // 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.InReach(weapon, _agent.Ship, target, out _) && + FireControl.Designated(weapon, _agent.Ship, target, out _) && !FireControl.Refuses(weapon, _agent.Ship) && (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index f3363ac23..ea9134fff 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -160,7 +160,8 @@ public class ChargedWeapon : InstantWeapon public override void Activate() { - if(!_charging && !_coolingDown) + // A refused weapon never starts charging: it would pay charge heat and audio for a shot Trigger drops. + if(!_charging && !_coolingDown && !FireControl.Refuses(this, Entity)) { OnStartCharging?.Invoke(); Item.FireAudioEvent(ChargedWeaponAudioEvent.Start); diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 59b7f6344..4d6cb80d0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -42,6 +42,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo protected int _burstRemaining; private bool _burstPaid; + private bool _burstStarted; private float _burstTimer; private float _burstInterval; protected float _cooldown; // Normalized @@ -53,7 +54,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public float Cooldown { get; protected set; } public virtual bool CanFire { - get => !_coolingDown; + // A triggered burst that has not yet fired a round is not ready either: its cooldown has not started. + get => !_coolingDown && (_burstStarted || _burstRemaining == 0); } public override float DamagePerSecond => Damage / Cooldown; @@ -115,8 +117,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _burstRemaining = (int) BurstCount; _burstInterval = BurstTime / _burstRemaining; _burstTimer = 0; - _cooldown = 1; - _coolingDown = true; + // The cooldown starts with the burst's first round that is not refused (Execute), not here: a burst whose + // every round is refused fires nothing and leaves no cooldown behind. + _burstStarted = false; } protected override void UpdateStats() @@ -242,7 +245,16 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _burstRemaining = 0; return false; } - if (!refused) _burstPaid = _data.SingleAmmoBurst; + if (!refused) + { + _burstPaid = _data.SingleAmmoBurst; + if (!_burstStarted) + { + _burstStarted = true; + _cooldown = 1; + _coolingDown = true; + } + } _burstRemaining--; _burstTimer -= _burstInterval; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 8bdada43b..06c9d3ca5 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -229,18 +229,13 @@ public static class FireControl // visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the // burst reach and the range clamp all measure the same distance, so a target's height never moves one of // them without the others. - public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) => - InReach(weapon, source, target, out range) && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); - - // Designated before the lock: the target is seen and within the weapon's planar range. A LockWeapon acquires - // its lock only while its shooter holds the trigger, so an agent asks this to know when to start locking; - // every other reader wants Designated. The one range test in the file. - public static bool InReach(Weapon weapon, Entity source, Entity target, out float range) + public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) { range = 0f; if (target == null) return false; range = length((target.Position - source.Position).xz); - return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range; + return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range && + !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); } // `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated. diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 4f9ffe282..70d083e27 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -84,7 +84,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private Rig Build( (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, - bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false) + bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false, bool charged = false, bool auto = false) { mounts ??= new[] { (ItemRotation.None, 0f) }; var hullData = new HullData @@ -120,6 +120,14 @@ public sealed class FireControlPerWeaponTests : IDisposable LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(0), DirectionImpact = Constant(1), Decay = Constant(1), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1) }; + else if (charged) + behavior = new ChargedWeaponData + { + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst, + ChargeTime = Constant(1), ChargeEnergy = Constant(0), ChargeHeat = Constant(5000), CanFireEarly = true + }; + else if (auto) + behavior = new AutoWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; else behavior = new InstantWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; behavior.Damage = Constant(100); @@ -401,13 +409,14 @@ public sealed class FireControlPerWeaponTests : IDisposable public bool Visible; public int Announced; public int Shots; + public bool CanFire; } // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. - private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false) + private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false, bool auto = false) { - var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single); + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single, auto: auto); r.Shooter.Target.Value = null; Aim(r, float2(0, 1)); var weapon = (InstantWeapon) r.Gun.Weapon; @@ -427,7 +436,13 @@ public sealed class FireControlPerWeaponTests : IDisposable // The Range the previous tick's Execute left, before this tick refreshes it: a power-dependent Range that // has since fallen. Set through the property's own setter, so the trigger sees a Range the round will not. if (staleRange) typeof(Weapon).GetProperty("Range").GetSetMethod(true).Invoke(weapon, new object[] { 100f }); - if (viaTrigger) weapon.Activate(); + if (auto) + { + // AutoWeapon's own trigger: a held weapon whose cooldown has run out retriggers inside Execute. + typeof(Weapon).GetField("_firing", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, true); + typeof(InstantWeapon).GetField("_cooldown", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, -1f); + } + else if (viaTrigger) weapon.Activate(); else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); r.Zone.Update(.01f); @@ -435,6 +450,7 @@ public sealed class FireControlPerWeaponTests : IDisposable costs.ChargeSpent = chargeBefore - capacitor.Charge; costs.Heat = r.Gun.Item.Temperature - heatBefore; costs.Visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + costs.CanFire = weapon.CanFire; return costs; } @@ -603,16 +619,63 @@ public sealed class FireControlPerWeaponTests : IDisposable // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid // (SingleAmmoBurst) at its first round that is not refused, judged with the Range that round flies with. - // The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. - // Kills: paying for the burst at the trigger. - [Fact] - public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing() + // The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. The refusal + // starts no cooldown either (operator ruling 2026-09-30): the weapon can fire again at once, while the + // control (Range 40) fired and is cooling down. Covers the player's trigger and AutoWeapon's own. + // Kills: paying for the burst at the trigger; starting the cooldown at the trigger (Trigger or AutoWeapon). + [Theory] + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(false, true)] + public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing(bool single, bool auto) { - var refused = Pull(29.5f, single: true, viaTrigger: true, staleRange: true); + var fired = Pull(40f, single, viaTrigger: true, staleRange: true, auto: auto); + Assert.Equal(1, fired.Shots); + Assert.False(fired.CanFire, "control: a fired burst starts the cooldown"); + + var refused = Pull(29.5f, single, viaTrigger: true, staleRange: true, auto: auto); Assert.Equal(0, refused.Shots); Assert.Equal(0, refused.Ammo); Assert.Equal(0f, refused.ChargeSpent); Assert.Equal(0, refused.Announced); + Assert.True(refused.CanFire, "a burst that fired nothing leaves no cooldown"); + } + + // A refused charged weapon never starts charging: no charge heat, no audio, no start event, and it can be + // triggered again at once. The control (Range 40) charges, sounds and heats. + // Kills: ChargedWeapon.Activate that does not ask FireControl. + private (int Starts, int Sounds, float Heat, bool Charging) Charge(float range) + { + var r = Build(charged: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ChargedWeapon) r.Gun.Weapon; + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_fire" } } }; + var starts = 0; + var sounds = 0; + weapon.OnStartCharging += () => starts++; + r.Gun.Item.AudioEvents.Subscribe(_ => sounds++); + var heatBefore = r.Gun.Item.Temperature; + + weapon.Activate(); + for (var i = 0; i < 3; i++) r.Zone.Update(.1f); + + return (starts, sounds, r.Gun.Item.Temperature - heatBefore, weapon.Progress > 0f); + } + + [Fact] + public void ARefusedChargedWeaponNeverStartsCharging() + { + var fired = Charge(40f); + Assert.Equal(1, fired.Starts); + Assert.True(fired.Sounds > 0, "control: charging sounds"); + Assert.True(fired.Heat > 0f, "control: charging heats"); + + var refused = Charge(29.5f); + Assert.Equal(0, refused.Starts); + Assert.Equal(0, refused.Sounds); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused weapon adds no charge heat ({refused.Heat} vs {fired.Heat})"); } } From 0e73bbac728daab465d92bf17ff116e7c9aec0d8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:23:04 +0200 Subject: [PATCH 101/116] Read the pending burst, not Progress, as the mark that Trigger ran; sound the charged control by its own event The cooldown now starts at a burst's first fired round, so Progress no longer moves at the trigger. IffAndCombatTests read CanFire (false while a burst is pending) instead, and the charged-weapon control uses the charge-start event name the sound bank resolves. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs | 2 +- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 70d083e27..f61c75fe9 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -652,7 +652,7 @@ public sealed class FireControlPerWeaponTests : IDisposable r.Shooter.Target.Value = null; Aim(r, float2(0, 1)); var weapon = (ChargedWeapon) r.Gun.Weapon; - r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_fire" } } }; + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_charge_play" } } }; var starts = 0; var sounds = 0; weapon.OnStartCharging += () => starts++; diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 6c01aafc9..1d3863b50 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -250,7 +250,7 @@ public sealed class IffAndCombatTests : IDisposable weapon.Activate(); - Assert.Equal(0f, weapon.Progress); // Trigger() was safed: no cooldown/burst was started + Assert.True(weapon.CanFire); // Trigger() was safed: no burst was started } [Fact] @@ -266,7 +266,7 @@ public sealed class IffAndCombatTests : IDisposable weapon.Activate(); - Assert.Equal(1f, weapon.Progress); // Trigger() ran: cooldown/burst started + Assert.False(weapon.CanFire); // Trigger() ran: a burst is pending } [Fact] @@ -279,7 +279,7 @@ public sealed class IffAndCombatTests : IDisposable weapon.Activate(); - Assert.Equal(1f, weapon.Progress); + Assert.False(weapon.CanFire); // Trigger() ran: a burst is pending } [Fact] From 7c1b1cd3d8bd87fd62b33bb48f3b80d5089f8039 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:27:28 +0200 Subject: [PATCH 102/116] Tick the zone before reading the cooldown in the stance tests BurstCount is set by the first Execute, so a trigger pulled with no tick has no burst pending. The tests now run one tick and read whether the cooldown started. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 1d3863b50..814ab0176 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -250,7 +250,9 @@ public sealed class IffAndCombatTests : IDisposable weapon.Activate(); - Assert.True(weapon.CanFire); // Trigger() was safed: no burst was started + zone.Update(.01f); + + Assert.True(weapon.CanFire); // Trigger() was safed: no burst fired, so no cooldown } [Fact] @@ -265,8 +267,9 @@ public sealed class IffAndCombatTests : IDisposable shooter.SetIff(target, true); weapon.Activate(); + zone.Update(.01f); - Assert.False(weapon.CanFire); // Trigger() ran: a burst is pending + Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } [Fact] @@ -278,8 +281,9 @@ public sealed class IffAndCombatTests : IDisposable var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); weapon.Activate(); + zone.Update(.01f); - Assert.False(weapon.CanFire); // Trigger() ran: a burst is pending + Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } [Fact] From 020a42fcb6e37fa58f37eed4228fdb24dc2b467f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:31:38 +0200 Subject: [PATCH 103/116] Execute the weapon directly in the stance tests The stance fixture has a shooter the zone cannot tick; the weapon runs on its own. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 814ab0176..9eed9d17e 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -250,7 +250,7 @@ public sealed class IffAndCombatTests : IDisposable weapon.Activate(); - zone.Update(.01f); + weapon.Execute(.01f); Assert.True(weapon.CanFire); // Trigger() was safed: no burst fired, so no cooldown } @@ -267,7 +267,7 @@ public sealed class IffAndCombatTests : IDisposable shooter.SetIff(target, true); weapon.Activate(); - zone.Update(.01f); + weapon.Execute(.01f); Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } @@ -281,7 +281,7 @@ public sealed class IffAndCombatTests : IDisposable var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); weapon.Activate(); - zone.Update(.01f); + weapon.Execute(.01f); Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } From c4b628fad7dd9d76f1797e47ac8627a3734d9872 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:32:31 +0200 Subject: [PATCH 104/116] Resolve the stance-test gun before triggering it A burst is sized from stats the first Execute resolves, so the untouched fixture gun fired nothing. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 9eed9d17e..bd8ad8513 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -248,8 +248,8 @@ public sealed class IffAndCombatTests : IDisposable shooter.Target.Value = target; shooter.SetIff(target, false); // explicitly neutral + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); - weapon.Execute(.01f); Assert.True(weapon.CanFire); // Trigger() was safed: no burst fired, so no cooldown @@ -266,6 +266,7 @@ public sealed class IffAndCombatTests : IDisposable shooter.Target.Value = target; shooter.SetIff(target, true); + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); weapon.Execute(.01f); @@ -280,6 +281,7 @@ public sealed class IffAndCombatTests : IDisposable var shooter = NewShip(items, zone, new Faction { Name = "Shooter" }, equipGun: true); var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); weapon.Execute(.01f); From 1c5872e7a899c4fa8cd4f94b7351aceef939e6d7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:35:53 +0200 Subject: [PATCH 105/116] Give the stance-test gun a burst and a cooldown The default InstantWeaponData has Count 0, so the fixture gun never had a round to fire. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index bd8ad8513..31e390abe 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -32,7 +32,7 @@ public sealed class IffAndCombatTests : IDisposable cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, - Behaviors = { new InstantWeaponData() } + Behaviors = { new InstantWeaponData { Count = new PerformanceStat { Min = 1, Max = 1 }, Cooldown = new PerformanceStat { Min = 1, Max = 1 } } } }); cache.FlushAsync().Wait(); } From c0fb26fca117d4f71fd9c438cdee98d527524ace Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:39:18 +0200 Subject: [PATCH 106/116] Pin that a triggered burst is not ready before its first round Co-Authored-By: Claude Opus 5.5 --- .../FireControlPerWeaponTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index f61c75fe9..5a3578987 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -678,4 +678,20 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(0, refused.Sounds); Assert.True(fired.Heat - refused.Heat > 5f, $"a refused weapon adds no charge heat ({refused.Heat} vs {fired.Heat})"); } + // A triggered burst has not started its cooldown, but it is not ready either: pulled again before its first round + // flies it would restart the burst. Kills: CanFire that reads the cooldown alone. + [Fact] + public void ATriggeredBurstIsNotReadyBeforeItsFirstRound() + { + var r = Build(range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + r.Zone.Update(.01f); // resolves the gun's stats, which size the burst + Assert.True(weapon.CanFire); + + weapon.Activate(); + + Assert.False(weapon.CanFire); + } } From 819b28234662c937abbd32cdcd90508e3a9c38fc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:54:36 +0200 Subject: [PATCH 107/116] Pin that Combat picks no group whose weapon cannot reach the target Co-Authored-By: Claude Opus 5.5 --- .../FireControlPerWeaponTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 5a3578987..d188fea46 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -586,6 +586,23 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(r.Decoy.Weapon.Firing, "the group that can fire is the one picked"); } + // Combat's group pick counts only weapons whose target is designated: the stronger gun's Range (10) does not reach the + // target (20), so its group is not picked and the decoy's, which does reach, is. Kills: Combat asking + // Designated nothing (a group of weapons that cannot reach the target selected). + [Fact] + public void CombatDoesNotPickAGroupWhoseWeaponCannotReachTheTarget() + { + var r = Build(range: 10f, targetRange: 20f, decoy: true); + Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the short gun is the stronger group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Gun.Weapon.Firing, "a weapon that cannot reach the target is never activated"); + Assert.True(r.Decoy.Weapon.Firing, "the group that reaches is the one picked"); + } + // Combat's range test is Designated's, not a second one: a target exactly at max range is in reach (the old // private test was strict). Kills: a range comparison of Combat's own. [Fact] From 742cb23f5b030fbebc6e9613d3a82689f9721a32 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 22:15:51 +0200 Subject: [PATCH 108/116] Gate a charged weapon's Activate on CanFire, and start a burst's cooldown with the tick it flew in A released charge has a burst pending and no cooldown until its first round, so a second Activate in that window started a second charge; Activate now asks CanFire. The round that starts a burst's cooldown does so after the tick's decrement, which made every cooldown one tick long; it now charges that tick to the cooldown. Pins both, and that a burst triggered after a fired one is not ready before its first round. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/ChargedWeapon.cs | 4 +- .../ServerShared/Behaviors/InstantWeapon.cs | 3 +- .../FireControlPerWeaponTests.cs | 75 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index ea9134fff..bc80bc1e5 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -160,8 +160,10 @@ public class ChargedWeapon : InstantWeapon public 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. // A refused weapon never starts charging: it would pay charge heat and audio for a shot Trigger drops. - if(!_charging && !_coolingDown && !FireControl.Refuses(this, Entity)) + if(!_charging && CanFire && !FireControl.Refuses(this, Entity)) { OnStartCharging?.Invoke(); Item.FireAudioEvent(ChargedWeaponAudioEvent.Start); diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 4d6cb80d0..2801110c9 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -251,7 +251,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if (!_burstStarted) { _burstStarted = true; - _cooldown = 1; + _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 _coolingDown = true; } } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index d188fea46..7d79a99b1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -711,4 +711,79 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.False(weapon.CanFire); } + + // A charge released with a burst pending has not started its cooldown, but it is not ready either: pressed + // again before that burst's first round it would start a second charge over it. Kills: ChargedWeapon.Activate + // that reads the cooldown alone instead of CanFire. + [Fact] + public void AChargedWeaponPressedAgainBeforeItsReleasedRoundFliesStartsNoSecondCharge() + { + var r = Build(charged: true, range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ChargedWeapon) r.Gun.Weapon; + var starts = 0; + weapon.OnStartCharging += () => starts++; + r.Zone.Update(.01f); // resolves the stats + weapon.Activate(); + for (var i = 0; i < 15; i++) r.Zone.Update(.1f); + Assert.Equal(1, starts); + + weapon.Deactivate(); + weapon.Activate(); + r.Zone.Update(.1f); + + Assert.Equal(1, starts); + Assert.False((bool) typeof(ChargedWeapon).GetField("_charging", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon)); + } + + // Holding the trigger fires once per Cooldown: the round that starts the cooldown pays for the tick it flew in. + // Cooldown 1 at .25 s ticks is a shot every 5 ticks, 12 in 15 s. And a burst whose every round is refused + // leaves no cooldown: the same held trigger is ready every tick. Kills: a first round that starts the cooldown + // at 1 after the tick's decrement (a shot every 6 ticks, 10 in 15 s); a cooldown left by a refused burst. + [Theory] + [InlineData(100f, 12)] + [InlineData(29.5f, 0)] + public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + var fired = 0; + weapon.OnFire += _ => fired++; + r.Zone.Update(.25f); // resolves the stats + var readyTicks = 0; + + for (var i = 0; i < 60; i++) + { + weapon.Activate(); + r.Zone.Update(.25f); + if (weapon.CanFire) readyTicks++; + } + + Assert.Equal(shots, fired); + if (shots == 0) Assert.Equal(60, readyTicks); + } + + // Kills: Trigger that leaves _burstStarted true from the previous burst, so the next burst reads as started + // (and ready) before its first round has flown. + [Fact] + public void ABurstTriggeredAfterAFiredOneIsNotReadyBeforeItsFirstRound() + { + var r = Build(range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + r.Zone.Update(.25f); + weapon.Activate(); + r.Zone.Update(.25f); + Assert.False(weapon.CanFire, "control: the first burst fired and is cooling"); + for (var i = 0; i < 10 && !weapon.CanFire; i++) r.Zone.Update(.25f); + Assert.True(weapon.CanFire, "fixture: the cooldown expired"); + + weapon.Activate(); + + Assert.False(weapon.CanFire); + } } From 7f317bd33274a0cd8d496e4100ca49c80af40985 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 22:15:53 +0200 Subject: [PATCH 109/116] Freeze a guided round that waits on its verdict and place a positionless hit at its target After its fade a round kept integrating Velocity through the BindingLifetime wait, then a Hit (which carries no burst point) played HitEffect at the drifted position. The wait now holds still; a hit without a point plays the effect at the round's target position, and is skipped when the round never knew one. OnEnable resets BindingLifetime so a pooled split child, which is never bound, does not inherit the last round's. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index 4eb75f1ad..26a6db650 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -98,11 +98,14 @@ public class GuidedProjectile : MonoBehaviour return; } StopAllCoroutines(); + // A hit carries no burst point: the effect plays where the round was homing (a round that faded out has stopped + // short of it), and is skipped when the round never knew where its target was. + Vector3? effectAt = point ?? (TargetPosition != null ? TargetPosition() : Target ? Target.position : (Vector3?) null); if (point.HasValue) transform.position = point.Value; - if (HitEffect != null) + if (HitEffect != null && effectAt.HasValue) { var ht = HitEffect.Instantiate(); - ht.position = transform.position; + ht.position = effectAt.Value; } StartCoroutine(Kill()); } @@ -111,6 +114,7 @@ public class GuidedProjectile : MonoBehaviour { _active = _alive = true; _spawnTime = Time.time; + BindingLifetime = 0f; // a pooled round starts unbound; the manager binds it after enabling _phase = Random.value * 100; _prevDist = Single.MaxValue; Particles.startColor = Color.white; @@ -222,6 +226,8 @@ public class GuidedProjectile : MonoBehaviour yield return null; } + // The wait holds still: a round waiting on the verdict must not drift off its last position. + Velocity = Vector3.zero; // Faded, but the simulation's verdict has not necessarily arrived: wait for it until the shot's flight is over. while (Time.time - _spawnTime < BindingLifetime) yield return null; StartCoroutine(Kill()); From 113164fadb9e671ed452a71ad6fcc7110c30890e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 22:24:39 +0200 Subject: [PATCH 110/116] Record fire control batches 5-8 landing and their follow-ups Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 265452916..00d783dc6 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2676,6 +2676,14 @@ follow-up rulings, all given on 2026-09-30: An out-of-arc round with a selected target bursts after that target leaves. That is ruled behaviour: an out-of-arc target is invalid, so the round takes the no-lock path. +- **Landed, 2026-09-29: batches 5-8 merged at `06001b57`.** 476 tests pass on the merged tree, after Soul + passes 5-7. Recorded follow-ups: + - The push-out follows the flight line, not the ray to the hull point. + - The HUD's burst range is an upper bound when a no-lock round stops at an earlier hull. + - The first-hull stop reads poses at fire time. + - A burst whose first round fails its energy or ammo spend retries every tick, bounded by capacitor refill. + - The first-round cooldown divides by `Cooldown` even when the magazine empties (a reload quirk that + predates this work). - **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go nowhere. From e0a802084e9735e49cd56570bf5f350c23a74cbd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:38:48 +0200 Subject: [PATCH 111/116] Use the generated accessor for Cycle Target Item Regenerate AetheriaInput.cs from the committed Aetheria.inputactions so the wrapper carries Cycle Target Item, and bind it through Input.Player like the other target actions. The by-name FindAction lookup was a stopgap from when the generator could not be run outside the editor. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/AetheriaInput.cs | 40 ++++++++++++++++++++ Assets/Scripts/Gameplay/ActionGameManager.cs | 7 +--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/AetheriaInput.cs b/Assets/Scripts/AetheriaInput.cs index c6eb618c2..938444fef 100644 --- a/Assets/Scripts/AetheriaInput.cs +++ b/Assets/Scripts/AetheriaInput.cs @@ -162,6 +162,16 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable ""initialStateCheck"": false, ""priority"": 0 }, + { + ""name"": ""Cycle Target Item"", + ""type"": ""Button"", + ""id"": ""3f9a2f0a-6e3f-4a53-9f5e-1a9c1e6d9f7b"", + ""expectedControlType"": ""Button"", + ""processors"": """", + ""interactions"": """", + ""initialStateCheck"": false, + ""priority"": 0 + }, { ""name"": ""Toggle Heatsinks"", ""type"": ""Button"", @@ -409,6 +419,17 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable ""isComposite"": false, ""isPartOfComposite"": false }, + { + ""name"": """", + ""id"": ""8c2d5e77-4b18-4f0b-9a2e-6d3f7c1a5e90"", + ""path"": ""/j"", + ""interactions"": """", + ""processors"": """", + ""groups"": ""Keyboard&Mouse"", + ""action"": ""Cycle Target Item"", + ""isComposite"": false, + ""isPartOfComposite"": false + }, { ""name"": """", ""id"": ""71ac6088-1e18-4810-86be-1179918dd4a2"", @@ -1178,6 +1199,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable m_Player_TargetPrevious = m_Player.FindAction("Target Previous", throwIfNotFound: true); m_Player_TargetNext = m_Player.FindAction("Target Next", throwIfNotFound: true); m_Player_TargetNearest = m_Player.FindAction("Target Nearest", throwIfNotFound: true); + m_Player_CycleTargetItem = m_Player.FindAction("Cycle Target Item", throwIfNotFound: true); m_Player_ToggleHeatsinks = m_Player.FindAction("Toggle Heatsinks", throwIfNotFound: true); m_Player_ToggleShield = m_Player.FindAction("Toggle Shield", throwIfNotFound: true); m_Player_ToggleStance = m_Player.FindAction("Toggle Stance", throwIfNotFound: true); @@ -1294,6 +1316,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable private readonly InputAction m_Player_TargetPrevious; private readonly InputAction m_Player_TargetNext; private readonly InputAction m_Player_TargetNearest; + private readonly InputAction m_Player_CycleTargetItem; private readonly InputAction m_Player_ToggleHeatsinks; private readonly InputAction m_Player_ToggleShield; private readonly InputAction m_Player_ToggleStance; @@ -1342,6 +1365,10 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// public InputAction @TargetNearest => m_Wrapper.m_Player_TargetNearest; /// + /// Provides access to the underlying input action "Player/CycleTargetItem". + /// + public InputAction @CycleTargetItem => m_Wrapper.m_Player_CycleTargetItem; + /// /// Provides access to the underlying input action "Player/ToggleHeatsinks". /// public InputAction @ToggleHeatsinks => m_Wrapper.m_Player_ToggleHeatsinks; @@ -1420,6 +1447,9 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable @TargetNearest.started += instance.OnTargetNearest; @TargetNearest.performed += instance.OnTargetNearest; @TargetNearest.canceled += instance.OnTargetNearest; + @CycleTargetItem.started += instance.OnCycleTargetItem; + @CycleTargetItem.performed += instance.OnCycleTargetItem; + @CycleTargetItem.canceled += instance.OnCycleTargetItem; @ToggleHeatsinks.started += instance.OnToggleHeatsinks; @ToggleHeatsinks.performed += instance.OnToggleHeatsinks; @ToggleHeatsinks.canceled += instance.OnToggleHeatsinks; @@ -1476,6 +1506,9 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable @TargetNearest.started -= instance.OnTargetNearest; @TargetNearest.performed -= instance.OnTargetNearest; @TargetNearest.canceled -= instance.OnTargetNearest; + @CycleTargetItem.started -= instance.OnCycleTargetItem; + @CycleTargetItem.performed -= instance.OnCycleTargetItem; + @CycleTargetItem.canceled -= instance.OnCycleTargetItem; @ToggleHeatsinks.started -= instance.OnToggleHeatsinks; @ToggleHeatsinks.performed -= instance.OnToggleHeatsinks; @ToggleHeatsinks.canceled -= instance.OnToggleHeatsinks; @@ -1964,6 +1997,13 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// void OnTargetNearest(InputAction.CallbackContext context); /// + /// Method invoked when associated input action "Cycle Target Item" is either , or . + /// + /// + /// + /// + void OnCycleTargetItem(InputAction.CallbackContext context); + /// /// Method invoked when associated input action "Toggle Heatsinks" is either , or . /// /// diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 543e8f8ef..50f2ff2f5 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -405,11 +405,8 @@ public class ActionGameManager : MonoBehaviour // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's revealed // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), same - // predicate (FireControl.IsRevealed) the AI path uses. No generated typed accessor exists for this - // action yet (it was hand-authored into Aetheria.inputactions rather than regenerated through the - // Unity Input Actions editor, unavailable here), so it is looked up by name instead of through - // Input.Player. - Input.asset.FindAction("Player/Cycle Target Item").performed += context => + // predicate (FireControl.IsRevealed) the AI path uses. + Input.Player.CycleTargetItem.performed += context => { var target = CurrentEntity.Target.Value; if (target == null) return; From 97edf48cec622963209bfdce802b4153702f50ca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:38:54 +0200 Subject: [PATCH 112/116] Add the debug info panel and the shield panel test rig ARPG.unity gains Debug Info Panel and Debug Info Text, which ActionGameManager.DebugInfoText drives as the fire-control debug HUD. FieldShieldTest.unity gains the ShieldInterceptor, ShieldEnvelope and Panel prototype rig from shield-panel Cut 4. Co-Authored-By: Claude Opus 5.5 --- Assets/Scenes/ARPG.unity | 374 +++++++++++++++++++++++++++- Assets/Scenes/FieldShieldTest.unity | 263 ++++++++++++++++--- 2 files changed, 600 insertions(+), 37 deletions(-) diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index c4bd71fcf..1ef50e0ea 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -436,6 +436,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -457,9 +459,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &23420360 MeshFilter: @@ -701,6 +705,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -722,9 +728,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &45636168 MeshFilter: @@ -1137,6 +1145,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -1597,6 +1606,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -1821,6 +1831,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -2497,6 +2508,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -2518,9 +2531,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &195655953 MeshFilter: @@ -2885,6 +2900,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -3410,6 +3426,7 @@ MonoBehaviour: InfiniteAmmoIcon: {fileID: 2044077503} CooldownFill: {fileID: 757740693} HeatFill: {fileID: 2060807771} + SelectedIndicator: {fileID: 0} --- !u!114 &232470601 MonoBehaviour: m_ObjectHideFlags: 0 @@ -3835,6 +3852,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -4117,6 +4135,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -4138,9 +4158,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &259676750 MeshFilter: @@ -4843,6 +4865,7 @@ RectTransform: m_Children: - {fileID: 2352149240727418666} - {fileID: 410701128} + - {fileID: 568886950} - {fileID: 1558520822} - {fileID: 1396909587} - {fileID: 1556666354} @@ -5153,6 +5176,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -5289,6 +5313,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -5695,6 +5720,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -7714,6 +7740,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -8358,6 +8385,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -8732,6 +8760,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -8753,9 +8783,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &542243549 MeshFilter: @@ -9080,6 +9112,82 @@ RectTransform: type: 3} m_PrefabInstance: {fileID: 2279630454421103832} m_PrefabAsset: {fileID: 0} +--- !u!1 &568886949 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 568886950} + - component: {fileID: 568886952} + - component: {fileID: 568886951} + m_Layer: 5 + m_Name: Debug Info Panel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &568886950 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1731988778} + m_Father: {fileID: 346000247} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 16, y: -800} + m_SizeDelta: {x: 220, y: 120} + m_Pivot: {x: 0, y: 0} +--- !u!114 &568886951 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 2100000, guid: 46238d909c2092c4eb873278d84c6ce8, type: 2} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 0 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 84d126f984de5fd42ac66361c00f830c, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 0.5 +--- !u!222 &568886952 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_CullTransparentMesh: 0 --- !u!1 &569895571 GameObject: m_ObjectHideFlags: 0 @@ -10233,9 +10341,6 @@ MonoBehaviour: TintDisplay: {fileID: 218215088} InfluenceDisplay: {fileID: 1390179643} Scale: 1 - Position: - x: 0 - y: 0 IconSize: 32 --- !u!1001 &642590526 PrefabInstance: @@ -11058,6 +11163,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -11079,9 +11186,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &677960094 MeshFilter: @@ -11394,6 +11503,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -11512,6 +11622,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -11533,9 +11645,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &708399897 MeshFilter: @@ -12046,6 +12160,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13167,6 +13282,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13434,6 +13550,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13738,6 +13855,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14206,6 +14324,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14343,6 +14462,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14510,6 +14630,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -14531,9 +14653,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &926083405 MeshFilter: @@ -14699,6 +14823,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14927,6 +15052,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -15094,6 +15220,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -15115,9 +15243,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &936330464 MeshFilter: @@ -15244,6 +15374,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -15265,9 +15397,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &945347194 MeshFilter: @@ -16545,9 +16679,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Settings: {fileID: 11400000, guid: cd2344e2001a37544aab0960c164d4ff, type: 2} - Sensitivity: - x: -0.001 - y: 0.001 Credits: 100000000 TargetSpottedBlinkFrequency: 20 TargetSpottedBlinkOffset: -0.25 @@ -16594,6 +16725,7 @@ MonoBehaviour: HitMarkerDuration: 0.25 SchematicDisplay: {fileID: 7628189704109037584} TargetSchematicDisplay: {fileID: 410701129} + DebugInfoText: {fileID: 1731988779} TargetIndicator: {fileID: 1556666357} TargetHitpointsFill: {fileID: 1691348884} TargetVisibilityFill: {fileID: 1715434759} @@ -16997,6 +17129,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -17431,6 +17564,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -17567,6 +17701,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -18347,6 +18482,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -18759,6 +18895,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -19045,6 +19182,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -19066,9 +19205,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1163671429 MeshFilter: @@ -21015,6 +21156,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -21635,6 +21777,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -21656,9 +21800,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1390575965 MeshFilter: @@ -21855,6 +22001,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22054,6 +22201,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22240,6 +22388,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22746,6 +22895,7 @@ MonoBehaviour: InfiniteAmmoIcon: {fileID: 0} CooldownFill: {fileID: 0} HeatFill: {fileID: 0} + SelectedIndicator: {fileID: 0} --- !u!114 &1462704894 MonoBehaviour: m_ObjectHideFlags: 0 @@ -23011,6 +23161,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -23237,6 +23388,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -23258,9 +23411,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1476765551 MeshFilter: @@ -23428,6 +23583,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -23824,6 +23980,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -24490,6 +24647,11 @@ PrefabInstance: propertyPath: m_firstOverflowCharacterIndex value: 0 objectReference: {fileID: 0} + - target: {fileID: 1713457305180270767, guid: 2758dfa2744d7ad4d8877545189d116e, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 2297648055057363568, guid: 2758dfa2744d7ad4d8877545189d116e, type: 3} propertyPath: m_Pivot.x @@ -24931,6 +25093,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -25211,6 +25374,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -25232,9 +25397,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1566772338 MeshFilter: @@ -25567,6 +25734,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 50 m_lineSpacingMax: 0 @@ -25710,6 +25878,11 @@ PrefabInstance: propertyPath: m_firstOverflowCharacterIndex value: 0 objectReference: {fileID: 0} + - target: {fileID: 1713457305180270767, guid: 2758dfa2744d7ad4d8877545189d116e, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 2297648055057363568, guid: 2758dfa2744d7ad4d8877545189d116e, type: 3} propertyPath: m_Pivot.x @@ -26204,6 +26377,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -26715,6 +26889,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -26962,6 +27137,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -26983,9 +27160,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1713745167 MeshFilter: @@ -27473,6 +27652,151 @@ MonoBehaviour: TabButtons: {fileID: 1783772015} ActiveTabColor: {r: 1, g: 0.44583336, b: 0.050000012, a: 1} InactiveTabColor: {r: 0.6981132, g: 0.6981132, b: 0.6981132, a: 1} +--- !u!1 &1731988777 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1731988778} + - component: {fileID: 1731988780} + - component: {fileID: 1731988779} + m_Layer: 5 + m_Name: Debug Info Text + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1731988778 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 568886950} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 2, y: 2} + m_SizeDelta: {x: -20, y: -20} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1731988779 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: 'New TextNew TextNew Text + + New TextNew TextNew TextNew Text + + New + TextNew Text + + New TextNew Text' + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 53555f7a2d5e745428813f1b1118734f, type: 2} + m_sharedMaterial: {fileID: -2804126062856497447, guid: 53555f7a2d5e745428813f1b1118734f, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 1, g: 1, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 15.85 + m_fontSizeBase: 12 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 8 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 1 + m_VerticalAlignment: 1024 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_characterHorizontalScale: 1 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!222 &1731988780 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_CullTransparentMesh: 1 --- !u!1 &1733988258 GameObject: m_ObjectHideFlags: 0 @@ -27569,6 +27893,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -27928,6 +28253,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -28979,6 +29305,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -29000,9 +29328,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1857285688 MeshFilter: @@ -30080,6 +30410,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31056,6 +31387,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31112,6 +31444,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -31133,9 +31467,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!1 &2095699056 GameObject: @@ -31368,6 +31704,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31708,6 +32045,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -31729,9 +32068,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &2117796868 MeshFilter: @@ -31892,6 +32233,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -32028,6 +32370,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33170,6 +33513,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33270,6 +33614,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33590,6 +33935,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33719,6 +34065,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33819,6 +34166,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34030,6 +34378,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34369,6 +34718,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34506,6 +34856,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34625,6 +34976,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34780,6 +35132,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34917,6 +35270,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35222,6 +35576,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35358,6 +35713,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35457,6 +35813,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35631,6 +35988,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35787,6 +36145,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37131,6 +37490,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37577,6 +37937,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37888,6 +38249,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 diff --git a/Assets/Scenes/FieldShieldTest.unity b/Assets/Scenes/FieldShieldTest.unity index b1b2e50b9..51c915b7f 100644 --- a/Assets/Scenes/FieldShieldTest.unity +++ b/Assets/Scenes/FieldShieldTest.unity @@ -13,7 +13,7 @@ OcclusionCullingSettings: --- !u!104 &2 RenderSettings: m_ObjectHideFlags: 0 - serializedVersion: 9 + serializedVersion: 10 m_Fog: 0 m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} m_FogMode: 3 @@ -38,13 +38,12 @@ RenderSettings: m_ReflectionIntensity: 1 m_CustomReflection: {fileID: 0} m_Sun: {fileID: 0} - m_IndirectSpecularColor: {r: 0.023674645, g: 0.04639011, b: 0.122535154, a: 1} m_UseRadianceAmbientProbe: 0 --- !u!157 &3 LightmapSettings: m_ObjectHideFlags: 0 - serializedVersion: 12 - m_GIWorkflowMode: 1 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 m_GISettings: serializedVersion: 2 m_BounceScale: 1 @@ -67,9 +66,6 @@ LightmapSettings: m_LightmapParameters: {fileID: 0} m_LightmapsBakeMode: 1 m_TextureCompression: 1 - m_FinalGather: 0 - m_FinalGatherFiltering: 1 - m_FinalGatherRayCount: 256 m_ReflectionCompression: 2 m_MixedBakeMode: 2 m_BakeBackend: 1 @@ -104,7 +100,7 @@ NavMeshSettings: serializedVersion: 2 m_ObjectHideFlags: 0 m_BuildSettings: - serializedVersion: 2 + serializedVersion: 3 agentTypeID: 0 agentRadius: 0.5 agentHeight: 2 @@ -117,7 +113,7 @@ NavMeshSettings: cellSize: 0.16666667 manualTileSize: 0 tileSize: 256 - accuratePlacement: 0 + buildHeightMesh: 0 maxJobWorkers: 0 preserveTilesOutsideBounds: 0 debug: @@ -155,7 +151,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 5 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} @@ -296,7 +291,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 3 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -308,6 +302,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 815358592} m_Modifications: - target: {fileID: 257019179550429270, guid: 426f9dcb510530d4dbfa678c9f727ec1, @@ -360,6 +355,11 @@ PrefabInstance: propertyPath: m_text value: Aetheric Field Tester objectReference: {fileID: 0} + - target: {fileID: 7131821417072393581, guid: 426f9dcb510530d4dbfa678c9f727ec1, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 7131821417165215474, guid: 426f9dcb510530d4dbfa678c9f727ec1, type: 3} propertyPath: m_Enabled @@ -481,6 +481,9 @@ PrefabInstance: value: Properties objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 426f9dcb510530d4dbfa678c9f727ec1, type: 3} --- !u!114 &401779935 stripped MonoBehaviour: @@ -505,6 +508,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 815358592} m_Modifications: - target: {fileID: 9194050151789067659, guid: 5c3a7b786419db74a8c1d17039fd4f67, @@ -663,6 +667,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 5c3a7b786419db74a8c1d17039fd4f67, type: 3} --- !u!224 &415327856 stripped RectTransform: @@ -713,7 +720,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1112533349} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 180} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -812,13 +818,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 699508825} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_RootOrder: 5 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &815358588 GameObject: @@ -896,7 +902,9 @@ Canvas: m_OverrideSorting: 0 m_OverridePixelPerfect: 0 m_SortingBucketNormalizedSize: 0 + m_VertexColorAlwaysGammaSpace: 0 m_AdditionalShaderChannelsFlag: 25 + m_UpdateRectTransformForStandalone: 0 m_SortingLayerID: 0 m_SortingOrder: 0 m_TargetDisplay: 0 @@ -919,7 +927,6 @@ RectTransform: - {fileID: 415327856} - {fileID: 87891009} m_Father: {fileID: 0} - m_RootOrder: 3 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} @@ -931,6 +938,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 0} m_Modifications: - target: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, @@ -1008,7 +1016,39 @@ PrefabInstance: propertyPath: m_LocalEulerAnglesHint.z value: 0 objectReference: {fileID: 0} + - target: {fileID: 2212592724187910978, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + propertyPath: m_Enabled + value: 0 + objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026487} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026488} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026489} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026490} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026492} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026491} m_SourcePrefab: {fileID: 100100000, guid: fe47fadcdb5ae5c4caca516b4af6fde6, type: 3} --- !u!1 &909026486 stripped GameObject: @@ -1024,9 +1064,17 @@ MeshCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 909026486} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 4 + serializedVersion: 5 m_Convex: 1 m_CookingOptions: 30 m_Mesh: {fileID: -5495902117074765545, guid: dd6644c1aa6c16c47bb9fea9822ce805, type: 3} @@ -1043,11 +1091,9 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Camera: {fileID: 1153356615} + FrontTwist: 0 + RearTwist: 0 TestMagnitude: 0.5 - Throttle: - x: 0 - y: 0 - ThrottleDecay: 5 FlowSpeed: 2 FlowSpeedThrottleExponent: 1 WaveThrottleExponent: 0 @@ -1084,8 +1130,8 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 52538a3309c36fc4583c964a1c0b705f, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: --- !u!114 &909026490 MonoBehaviour: m_ObjectHideFlags: 0 @@ -1096,8 +1142,40 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: d8f4aa77146e46a2a4b28d9fefb98496, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: +--- !u!114 &909026491 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 909026486} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fdca6a832d764b6e89fc83fdcf7b42d7, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldInterceptor + Envelope: {fileID: 909026492} + PanelPrototype: {fileID: 1087039186} + MaxLivePanels: 12 + PanelRadius: 2 + EnergyScale: 1 + ReuseFraction: 1 + DeathMargin: 0.25 + PatternByDamageType: +--- !u!114 &909026492 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 909026486} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e25c67ee83c01364fb121caeda952606, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldEnvelope --- !u!1 &1012179276 GameObject: m_ObjectHideFlags: 0 @@ -1138,13 +1216,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1012179276} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_RootOrder: 4 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1012179280 MonoBehaviour: @@ -1186,11 +1264,14 @@ MonoBehaviour: type: 3} m_DeselectOnBackgroundClick: 1 m_PointerBehavior: 0 + m_CursorLockBehavior: 0 + m_ScrollDeltaPerTick: 6 --- !u!1001 &1080184620 PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 0} m_Modifications: - target: {fileID: 105552333169325610, guid: 4a3db6090789ca24cb57c43666754017, @@ -1275,7 +1356,101 @@ PrefabInstance: objectReference: {fileID: 0} m_RemovedComponents: - {fileID: 7577392600424441103, guid: 4a3db6090789ca24cb57c43666754017, type: 3} + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 4a3db6090789ca24cb57c43666754017, type: 3} +--- !u!1 &1087039185 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1087039188} + - component: {fileID: 1087039187} + - component: {fileID: 1087039186} + m_Layer: 0 + m_Name: Panel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!114 &1087039186 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 1281fe44325f9b548aa42dc331b17266, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::Prototype +--- !u!114 &1087039187 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c1b180b85c234a4488cf0662b75ac77f, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldField.ShieldPanel + tiling: 3 + panelRadius: 5 + cellSize: 0.35 + mergeRhombs: 1 + takeDual: 0 + seed: 1337 + sim: {fileID: 7200000, guid: 1d17c100121b5ba47af9c75de451a2f1, type: 3} + panelShader: {fileID: 4800000, guid: 6a7f034931432c2418a0f0f0b9af0b93, type: 3} + growDuration: 0.35 + wavefrontSpeed: 8 + delayJitter: 0.06 + waveSpeed2: 26 + damping: 1.6 + stressClamp: 4 + substeps: 6 + fixedSubstepDt: 0.0025 + echoDelaySteps: 5 + echoReflect: -0.65 + tensileStrength: 0.9 + temperInit: 0.55 + temperErosion: 2.2 + storedEnergyGain: 0.8 + damageGain: 6 + breakAt: 0.75 + crackStart: 0.18 + breakBudgetFraction: 0.02 + radialKick: 1.6 + lateralKick: 2.2 + normalKick: 1.1 + spinKick: 7 + shardLife: 2.5 + shardDrag: 0.7 + maxShards: 6 + conduitGain: 2.5 +--- !u!4 &1087039188 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1112013867 GameObject: m_ObjectHideFlags: 0 @@ -1300,6 +1475,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1112013867} + serializedVersion: 2 m_LocalRotation: {x: 0.05948526, y: -0.910067, z: 0.14047916, w: 0.38536373} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -1307,7 +1483,6 @@ Transform: m_Children: - {fileID: 1153356611} m_Father: {fileID: 0} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 17.55, y: -134.1, z: 0} --- !u!114 &1112013869 MonoBehaviour: @@ -1353,7 +1528,6 @@ RectTransform: m_Children: - {fileID: 602735389} m_Father: {fileID: 815358592} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 1, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1387,13 +1561,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1153356610} + serializedVersion: 2 m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} m_LocalPosition: {x: 0, y: 0, z: -50} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1112013868} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1153356612 MonoBehaviour: @@ -1492,9 +1666,17 @@ Camera: m_projectionMatrixMode: 1 m_GateFitMode: 2 m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 m_SensorSize: {x: 36, y: 24} m_LensShift: {x: 0, y: 0} - m_FocalLength: 50 m_NormalizedViewPortRect: serializedVersion: 2 x: 0 @@ -1564,9 +1746,17 @@ BoxCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1424996955} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 1 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Size: {x: 1, y: 1, z: 1} m_Center: {x: 0, y: 0, z: 0} --- !u!4 &1424996958 @@ -1576,13 +1766,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1424996955} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: -1.4908371, y: -143.55145, z: 0.8193531} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1618381683 GameObject: @@ -1617,7 +1807,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1691,3 +1880,15 @@ CanvasRenderer: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1618381683} m_CullTransparentMesh: 1 +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 909026485} + - {fileID: 1424996958} + - {fileID: 1112013868} + - {fileID: 815358592} + - {fileID: 1012179279} + - {fileID: 699508827} + - {fileID: 1080184620} + - {fileID: 1087039188} From c6430c93e44503446236cd68be97a882d09b9194 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:08:34 +0200 Subject: [PATCH 113/116] Make AetherDb the agent's data tool in project doctrine Missing catalog or save capabilities are added to AetherDb as commands, not written as scratch projects, loose scripts or byte readers. Records the serializer options and the CultLib pin gate an agent hits on first use. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..16fe9b214 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# Aetheria agent doctrine + +## Data tooling: AetherDb + +`tools/AetherDb` is the agent's tool for reading and writing Aetheria's CultCache stores (`GameData/Aetheria.cc`, +`run.cc`, `player.cc`). The simulation lives in `Aetheria.Shared` (the headless build of +`Assets/Scripts/ServerShared`) and needs no Unity; Unity is only the shell for graphics and input. + +- When a task needs a catalog or save capability AetherDb lacks (authoring or cloning designs, setting fields, + fixtures for a play smoke, a query), add it to AetherDb as a command. Do not write a scratch console project, + a loose `.cs` script, a Python or PowerShell reader of the `.cc` bytes, or a Unity editor script instead. + Loose scripts were retired in `9f9149bd` because they ran against stale schemas. +- A command dry-runs by default and writes only with `apply`, validates with `CultRecordRefs.Validate` and + commits in one `CultCache.Commit` batch, like the existing migrations in `tools/AetherDb/Program.cs`. +- Deep copies of records use CultCache's own options, + `CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly)`; MessagePack's standard options + cannot serialise `CultMath` types or `CultRecordRef`. +- The headless build refuses to compile unless CultLib is a clean checkout at the revisions pinned in + `Directory.Build.props` (`Directory.Build.targets` enforces it). When `F:\Projects\CultLib` is elsewhere, point + `CULTLIB_ROOT` and `CULTMATH_ROOT` at detached worktrees of those revisions; never move the shared checkout. +- CultCache Studio is the operator's GUI over the same files. It is not an agent path. From f935c29b4ba5d609f89e33037fa81c262248798f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 01:04:11 +0200 Subject: [PATCH 114/116] Fix console text input and route give through ship commissioning - ConsoleView appended the control characters text input delivers (\b, \r), so Backspace inserted a glyph and Enter's \r got every command refused since d503f076 made the parser refuse instead of strip. Control characters are no longer text; Backspace deletes like Delete. - give reports an unknown name or a full cargo bay. A ship hull is refused unless docked, and when docked is commissioned through ActionGameManager.CommissionShip, the primitive TradeMenu.Buy now uses: a bare player ship moored at the docked entity, no docking bay. Non-ship hulls are refused. - Record play smoke results for steps 3-6 and the docking bay finding. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 32 ++++++++++++- Assets/Scripts/UI/ConsoleView.cs | 6 ++- Assets/Scripts/UI/Menu/TradeMenu.cs | 3 +- docs/merge-to-master-checklist.md | 47 ++++++++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 50f2ff2f5..c62d980e4 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -518,10 +518,29 @@ public class ActionGameManager : MonoBehaviour var itemName = string.Join(" ", args); var item = ItemManager.ItemData.GetAll() .FirstOrDefault(itemData => string.Equals(itemData.Name, itemName, StringComparison.InvariantCultureIgnoreCase)); - if (item != null) + if (item == null) { - _currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f))); + ConsoleController.Instance.AppendLogLine($"No equippable item named \"{itemName}\""); + return; } + if (item is HullData hull) + { + if (hull.HullType != HullType.Ship) + { + ConsoleController.Instance.AppendLogLine($"Refused: {item.Name} is a {hull.HullType} hull, not a ship"); + return; + } + if (DockedEntity == null) + { + ConsoleController.Instance.AppendLogLine($"Refused: dock to take delivery of a {item.Name}"); + return; + } + CommissionShip(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f)) as EquippableItem); + ConsoleController.Instance.AppendLogLine($"{item.Name} moored at {DockedEntity.Name}"); + return; + } + if (!_currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f)))) + ConsoleController.Instance.AppendLogLine($"Refused: no cargo space for {item.Name}"); }); ConsoleController.AddCommand("trackmissile", @@ -874,6 +893,15 @@ public class ActionGameManager : MonoBehaviour } } + // A ship hull acquired while docked becomes a bare player ship moored at the docked entity. It takes no docking + // bay: mothballed ships do not need one. Buying from the trade menu and the give command both land here. + public Ship CommissionShip(EquippableItem hull) + { + var ship = new Ship(ItemManager, Zone, hull, ItemManager.GameplaySettings.DefaultEntitySettings) { IsPlayerShip = true }; + ship.SetParent(DockedEntity); + return ship; + } + private void DoDock(Entity entity, EquippedDockingBay dockingBay) { TradeMenu.Inventory = entity.CargoBays.First(); diff --git a/Assets/Scripts/UI/ConsoleView.cs b/Assets/Scripts/UI/ConsoleView.cs index fcde87e29..64e0a4597 100644 --- a/Assets/Scripts/UI/ConsoleView.cs +++ b/Assets/Scripts/UI/ConsoleView.cs @@ -50,7 +50,9 @@ public class ConsoleView : MonoBehaviour { Keyboard.current.onTextInput += c => { - if (!Visible || c == '`') return; + // Text input also delivers control characters (backspace as '\b', enter as '\r'); editing keys are + // handled in Update, so only printable characters are text. + if (!Visible || c == '`' || char.IsControl(c)) return; InputString += c; }; InputString = ""; @@ -81,7 +83,7 @@ public class ConsoleView : MonoBehaviour { if (!Visible) return; - if(keyboard.deleteKey.wasPressedThisFrame && InputString.Length>0) + if((keyboard.backspaceKey.wasPressedThisFrame || keyboard.deleteKey.wasPressedThisFrame) && InputString.Length>0) { InputString = InputString.Substring(0, InputString.Length - 1); } diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 9bc285b75..3b1643dab 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -374,8 +374,7 @@ public class TradeMenu : MonoBehaviour { if (hullData.HullType != HullType.Ship) throw new ArgumentException("Attempted to buy non-ship hull from station, WTF are you doing?!"); - var ship = new Ship(GameManager.ItemManager, GameManager.Zone, item as EquippableItem, GameManager.ItemManager.GameplaySettings.DefaultEntitySettings) { IsPlayerShip = true }; - ship.SetParent(GameManager.DockedEntity); + GameManager.CommissionShip(item as EquippableItem); GameManager.Credits -= data.Price; UpdateCreditsLabel(); diff --git a/docs/merge-to-master-checklist.md b/docs/merge-to-master-checklist.md index 0848b4b2d..b9559a50b 100644 --- a/docs/merge-to-master-checklist.md +++ b/docs/merge-to-master-checklist.md @@ -111,3 +111,50 @@ Only what the maps name. - CultMath bounded least squares is locomotion Cut 2, not in this stack (`locomotion-cut.md:508`, `:360`). It gates nothing here. - Unrecorded: a Unity batchmode compile at the tip `4b594e11`. 12.2 and 12.3 record clean compiles at their own heads. - **Soul status at merge time: Self fills in.** + +## 8. Results: play smoke, 2026-09-30 (operator plays, agent records) + +Tree: `codex/fire-control-12` at `113164fa` plus the fixes listed below. `run.cc` and `player.cc` moved to +`GameData/stale-2026-09-22/` before launch. + +**Compile.** The Unity 6000.3.24f1 batchmode compile at `113164fa` was clean (exit 0, no `error CS`). The four +never-compiled files needed no fix. One warning, `EntityInstance._destroyed` assigned but never read, is legacy +(`729ab0e8`). + +**Changes made during the smoke.** +- `e0a80208`: `AetheriaInput.cs` regenerated; Cycle Target Item bound through `Input.Player.CycleTargetItem`, + retiring the by-name `FindAction` stopgap. Batchmode recompile clean. +- `97edf48c`: `ARPG.unity` Debug Info Panel and `FieldShieldTest.unity` shield-panel Cut 4 rig committed. +- Console: control characters from text input are no longer appended, and Backspace deletes (see step 6 finding). +- `give` (operator ruling 2026-09-30): an unknown name and a full cargo bay now say so instead of doing nothing. A + ship hull is refused unless docked; when docked it goes through `ActionGameManager.CommissionShip`, the same + primitive `TradeMenu.Buy` now uses, and moors a bare player ship at the docked entity with no docking bay + ("mothballed ships do not require docking bays"). Non-ship hulls are refused. This is the step 7 path to a + restored hull. +- Scratch weapons authored in place in `GameData/Aetheria.cc` (not committed; restore with + `git checkout -- GameData/Aetheria.cc`): `Smoke Proximity` (Proximity, blast 8), `Smoke Delayed` (Delayed, + blast 4, penetration 2 cells), `Smoke Refused` (Proximity, blast 30, range 20). Each is a Spectra clone, energy, + 1 column by 2 rows, to fit the test ship's only energy hardpoint. + +| Step | Result | Evidence | +|---|---|---| +| 3 Launch to main menu | pass | Operator reached play through step 6 with no reported Addressables errors. | +| 4 New Game | pass | As above. | +| 5 Addressables, Use Asset Database | partial | Charged weapon (ChargeBlast SG) unprovable: the test ship's only energy hardpoint is 1x2 and ChargeBlast SG is 2x2. Instant weapon not separately reported. | +| 6 Provenance in play | partial | Properties panel correct for starting gear and for a purchased item. **Tier colours missing everywhere**: known defect, `settings-globals-cut.md:150` (Unity-serialised `RarityTier.Color` loads as zero); fixed by settings-globals Cut 1, not part of this merge. `give Lamp` was first refused (console finding), then silently did nothing: `Lamp` is a test-fixture name, absent from the live catalog, and `give` reported no miss (now it does). No-Manufacturer check still open with a real unbranded design. Loot brand not yet reported. | + +**Finding, console (not fire control).** Every console command was refused with "commands take only letters, +digits, spaces and hyphens", and Backspace inserted a glyph. Cause: `Keyboard.onTextInput` delivers control +characters (`\b`, `\r`), and `ConsoleView` appended them to the input line. Enter's `\r` rode into every command; +`d503f076` (2026-09-14, on master) correctly changed the parser from stripping disallowed characters to refusing +them, which exposed it. Backspace had never been handled (only Delete), which is legacy (`7006a6b0`). + +**Feel notes.** Engaging an AI Djinni: the player was destroyed decisively. "Game is hard." + +**Finding, ship purchase and docking bays (design gap, not a merge gate).** The intended UX is that a purchased +ship is assigned its own docking bay. The code does not do that: +- `TradeMenu.Buy` (`TradeMenu.cs:373-381`) creates a bare `Ship` parented to the docked station and assigns it no + bay. It also charges `data.Price` rather than the lot's quality price that `GetPrice` computes for other items. +- `LoadoutGenerator` equips exactly one docking bay per station (`LoadoutGenerator.cs:84-92`), so Zenith has one. +- The inventory panel's Current button (`InventoryPanel.cs:110-120`) sets the new ship as current and overwrites + the single bay's `DockedShip`; the previous ship stays a station child with no bay. From d3075eb06db54d737b019613058005a1757d5b6f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 01:15:06 +0200 Subject: [PATCH 115/116] Record the play smoke stop, operator direction and stale checklist expectations Co-Authored-By: Claude Opus 5.5 --- docs/merge-to-master-checklist.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/merge-to-master-checklist.md b/docs/merge-to-master-checklist.md index b9559a50b..4feec9edb 100644 --- a/docs/merge-to-master-checklist.md +++ b/docs/merge-to-master-checklist.md @@ -158,3 +158,15 @@ ship is assigned its own docking bay. The code does not do that: - `LoadoutGenerator` equips exactly one docking bay per station (`LoadoutGenerator.cs:84-92`), so Zenith has one. - The inventory panel's Current button (`InventoryPanel.cs:110-120`) sets the new ship as current and overwrites the single bay's `DockedShip`; the previous ship stays a station child with no bay. + +**Smoke stopped after step 6 (operator, 2026-09-30).** The operator rejected the script as a monolithic play order +that does not respect how the game works: step 7 alone meant spawning and fitting a whole Djinni by catalog name. +Direction: operator checks become a list of behaviours verified over time, each set up by an authored, retained +scenario selectable from the main menu (a Eureka cut). Steps 7-20 and handoff checks A-F are unplayed. The operator +prefers to merge now, with those as open entries to verify through scenarios; Self rules on the merge. + +**Stale expectations found in this checklist.** Step 6 transcribes `item-provenance-cut.md:529-537` (written +2026-09-17 03:38, `7425ff1b`). "Starting-ship items show tier colour" was falsified by that day's smoke and recorded +at `settings-globals-cut.md:150` (`db420981`, 17:36); the provenance map was never corrected and this checklist +copied it despite its own section 5. "`give Lamp`" names a test fixture (`LoadoutTests.cs:289-312`), never a live +catalog design, and `give` could not have run at all between `d503f076` (09-14) and `f935c29b`. From 65c634953733004a9cd2cba71a3008d4ac6b1ed4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:11:09 +0200 Subject: [PATCH 116/116] Map the scenario cut: authored arenas launched from the main menu Imagination pass 1 against dcd7bbc5, designed from the operator's own words (quoted in the map). Cuts 0-4; four operator questions open (Q1 arena in a real run, Q2 who records results, Q3 test-only designs, Q4 which ad hoc affordances retire). Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 674 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 docs/scenarios-cut.md diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md new file mode 100644 index 000000000..d7354ecbc --- /dev/null +++ b/docs/scenarios-cut.md @@ -0,0 +1,674 @@ +# Scenarios: Cut Map + +Date: 2026-09-30, 02:10 CEST. Imagination pass. + +Status: cut map. Nothing has landed. The target lives in section 0 until Self splits it out into +`docs/scenarios-target.md`; this document owns the means. + +Anchors are against Aetheria `origin/master` `dcd7bbc5` (the fire-control merge), read in the clone +`C:\ws72-fuse5` with `git show`/`git grep`. The operator's tree `F:\Projects\Aetheria` was not touched. No +build ran and no Yggdrasil job ran. **(read)** marks a claim read from source at the anchor. **(probe)** marks a +claim measured against a real file. + +--- + +## 0. Target + +### 0.1 The request, in the operator's words (2026-09-30) + +> "I feel like this would be much more manageable as a list of things to verify over time rather than a +> monolithic smoke script that doesn't respect how the game works. Move gear onto Djinni? That's a lot of items +> to spawn, and am I expected to have the catalog memorized to spawn them? If you want me to test a bunch of +> behaviors under specific conditions, find a way to spawn me in an arena where those conditions are present. If +> you want me to kill a guy and loot him, spawn me next to a ship with no armor or weapons, that sort of thing. I +> wouldn't mind a scenario affordance in the main menu for this sort of job, that way the test fixtures can be +> authored, retained and available. But that sounds like a Eureka cut." + +What those words settle, so no question below re-asks them: +- **A scenario is an arena whose conditions are present at spawn.** The player's ship and every other entity are + already fitted and placed. The operator never spawns gear by catalog name and never fits a ship by hand. +- **The fixtures are authored and retained,** and the main menu lists them. +- **Verification is a list of behaviours checked over time,** not a play order. + +### 0.2 Ends + +- The main menu lists scenarios. Picking one starts a run in its arena. +- A scenario is typed catalog data. It is authored once, kept in git, and replayed at any later revision. +- Every operator check is a typed **check** record: what to do, what to observe, where the rule came from, and + the scenario that sets it up. Results accumulate as typed records carrying the revision they were observed at. +- Checks A-F and checklist steps 7-20 are the first consumers (section 5). + +### 0.3 Invariants + +- **One owner decides what a new run starts with.** A plain New Game and a scenario launch share the menu path, + the galaxy build, `StartGame` and the staging owner. Scenario launch is New Game with a scenario as input; there + is no second boot path. A plain New Game is the absent-scenario case. +- **One primitive admits an entity into a zone.** Zone construction, staging, warp arrival and undock all admit + through it, and agents are created there and nowhere else. +- **A scenario sets conditions and nothing else.** Once staged, its entities are ordinary run state. The rules + under test run through the same code whether or not a scenario started the run. No scenario-only branch exists + in simulation code. +- **Fixtures are typed state.** Scenario, loadout presets and test designs are CultCache catalog records. Checks and + results are CultCache records. No load-bearing JSON, text scripts or prose checklists. +- **A scenario that cannot stage is refused before anything is cleared,** with its failures listed. +- **The catalog stays read-only at runtime** (`AetheriaStores.cs:13-21`). + +### 0.4 Not in scope + +- The full run lifecycle owner (`Run`) of `docs/headless-playground-cut.md` Cut 1 (warp, dock, save, death as one + ServerShared owner) and its `AetherDb play` interpreter (Cut 3). This map takes over only the new-run staging + half. The playground map is stale (anchors `1e647953`, fork S superseded by settings-globals) and must be + re-anchored before Hands. When it lands, its `new` command stages a `Scenario` record instead of reading a + `.play` text line. +- Brokkr. It is not needed for any cut here: agents author scenarios headless through `AetherDb`, and the operator + launches them from the menu. Brokkr's Unity actions (`setEditorPlayState`, `captureEditorView`, scene edits; + `F:\Projects\Brokkr` `1357705`, read) have no way to pick a scenario in the menu. Agent-driven Unity play would + need an Aetheria install cut plus a "launch scenario" intent. Recorded as a follow-up, not a fork. +- The ship-purchase docking-bay gap (checklist section 8). It is a design gap in `TradeMenu.Buy` and + `CommissionShip`, not a verification affordance. `CommissionShip` stays; `TradeMenu.Buy` is its consumer. +- Tier colours (settings-globals Cut 1). The check is recorded as a known failure (section 5). +- Balance and feel tuning. A check can record a feel call; it does not tune. + +--- + +## 1. Body findings + +### 1.1 The new-run path today (read) + +| Concern | Anchor | Notes | +|---|---|---| +| Menu | `MainMenu.cs:92-184` | Continue `:103-108`; New Game `:110-176`; Settings; Quit. The same component runs in-game (`InGame`, `:96`), so the menu reappears on death (`ActionGameManager.cs:1171`). | +| Clear the run | `MainMenu.cs:113` | `RunSave.Clear` at click time, before the galaxy is built. | +| Galaxy | `MainMenu.cs:124-175` | `TutorialPassed` is never set anywhere, so New Game always takes the prelude branch `:146-175`. The background noise position comes from `UnityEngine.Random` (`:128,153`). The seed defaults to the clock (`Galaxy.cs:106,158`). | +| Handoff | statics `ActionGameManager.CurrentGalaxy`, `IsTutorial` (`:96-97`) | read by `StartGame`, `SectorMap`, and nulled by `Die` (`:1173`). | +| Start | `StartGame` `:796-834`, called from `Start` `:491` | New run `:803-815`: `PopulateLevel(Entrance)`, `LoadoutGenerator.GenerateShipLoadout` filtered by `GameSettings.StartingHullName` (`GameSettings.cs:13`, authored `LonginusX`), unpack, `Zone.Entities.Add`, `Activate`, `BindToEntity`. Continue `:817-833`. | +| Zone entry | `PopulateLevel` `:710-744` | generates the pack lazily (`:714-722`), moves the pilot (`:731-737`). | + +### 1.2 What a scenario can reuse (read, one probe) + +- **Loadout presets already exist.** `aetheria.loadout` (`Loadout.cs:13-20`) holds a hull design, per-cell slot + designs with rotation, and weapon groups. It is a catalog type (`AetheriaStores.cs:9`), authored in Studio or by + the editor console command `capturepreset` (`ActionGameManager.cs:624-662`, `Loadouts.Commit` `Loadout.cs:70-87`). + `Loadouts.Materialize` (`:96-159`) builds a live `Ship` all-or-nothing, listing every failure. + - **No game path calls `Materialize`** (`LoadoutGenerator.cs:152-153` says so; only `LoadoutTests.cs:310,899` + call it). + - **(probe)** The live catalog holds no preset: the schema name `aetheria.loadout` does not occur in + `GameData/Aetheria.cc` (12,698,108 bytes at `dcd7bbc5`), while every populated type's name does (for example + `aetheria.hulldata`, `aetheria.weaponitemdata`). Method: `grep -a -o` over the file. + - `Materialize` always builds a `Ship` (`:131`). A turret hull cannot be materialized. + - It resolves every design through a product (`Resolve`, `:103-115`). A design with no product fails. +- **Unbranded lots exist.** `ItemManager.CreateLot(design, maker, quality)` (`ItemManager.cs:161`) with no maker is + how `give` made its items (`ActionGameManager.cs:538,542`). Provenance's "no Manufacturer row" check depends on + them. +- **Economy exclusion follows from products.** Generation iterates products and requires + `product.Manufacturer.IsSet()` and `design.Price > 0` (`LoadoutGenerator.cs:126-133`). A design with no product + is never generated, stocked or sold. Loot is what a killed entity carries (`EntityInstance.cs:302-321`), so it + cannot leak through loot either unless a scenario fits it. +- **Prelude galaxies offer every product.** `IsAvailable` returns true when `Galaxy.IsPrelude` + (`LoadoutGenerator.cs:154-156`). A preset materialized in a prelude arena never fails on faction availability. +- **Agents are made only at zone construction.** `Zone`'s constructor gives every packed non-player `Ship` a + `Minion` (`Zone.cs:120-131`, `CreateAgent` `:135-142`). An entity added later gets none. A `Minion` targets the + first visible enemy and fights (`Minion.cs`). +- **An entity joins a zone in four other places,** each by hand: `spawnturret` (`ActionGameManager.cs:587`), + `PopulateLevel` (`:735`), `StartGame` (`:813`) and `Entity.TryUndock` (`Entity.cs:1006`). +- **IFF overrides are entity-local and unsaved.** `Entity.SetIff(other, bool?)` (`Entity.cs:529-533`) decides + `IsHostileTo` ahead of faction rules (`:535-552`). A `Materialize`d ship has no faction, so it is neutral unless + overridden. +- **A target that dies and a target that leaves are the same case in fire control.** + `targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target)` (`FireControl.cs:610`), and death + removes the entity from `Zone.Entities` (`Zone.cs:93`). Check A.3 ("the target warps away") is therefore set up by + killing a fragile target while a fused round is in flight. No departure machinery is needed. +- **An orbital entity with no orbit stays where it is put** (`OrbitalEntity.cs` `Update`: position follows the + orbit only when one is set). That is how `spawnturret` placed turrets (`ActionGameManager.cs:583-588`). +- **Headless tests already open the live catalog and the authored settings** + (`RestoredHullsTests.cs`: `AuthoredSettings.Load`, `OpenReadOnlyRealCatalog`). A staging test can generate a + prelude galaxy, build the arena zone and stage a scenario with no Unity. + +### 1.3 Debug affordances a scenario replaces (read) + +| Affordance | Anchor | Added for | +|---|---|---| +| `give ` | `ActionGameManager.cs:515-544` | ad hoc items; the hull route through `CommissionShip` was added tonight in `f935c29b` for step 7 | +| `spawnturret` | `:564-589` | a hostile turret next to the player | +| Neutral wanderers | `ZoneGenerator.cs:342-356`, `EligibleWandererFactions` `:361-365`, `ZoneGenerationSettings.NeutralWandererCount` (`Settings.cs:156`), tests `IffAndCombatTests.cs:329-350` | commit `f732cff9` (2026-09-17): "Spawn neutral wanderers so combat has something non-hostile to target". The code comment calls it a "Testing affordance". Every generated zone carries two. | + +Staying, because no scenario replaces them: `iff` (flip a live target's stance mid-play), `trackmissile` (camera), +`revealzones` (map), `tow` (a verb bound to the console), `capturepreset` (the in-play authoring tool for presets). + +### 1.4 Collisions with open branches (read) + +- `codex/mining` rewrites `Zone.cs` (+239/-). Cut 1 edits the constructor loop and adds the admission primitive. + Land Cut 1 after mining merges, or re-anchor. +- `codex/moddable-ships` edits `AetheriaStores.cs` (line 9, the catalog type list). Cuts 1 and 3 edit the same + lines. +- `codex/item-provenance` carries the narrative map, which edits `MainMenu.cs:132,160` and the `Galaxy` + constructors. Cut 2 edits the same New Game block. + +--- + +## 0b. Identity, lifecycle, authority + +No cut is mapped against an empty cell. + +| Kind | What names it | Lifecycle | Who decides | +|---|---|---|---| +| **Scenario** (`aetheria.scenario`, catalog) | `CultName` `Name`; key `scenario:`, derived like `Loadouts.KeyOf` | Authored, then edited in place. A rename is a new record; the old one is deleted, and the checks that pointed at it are repointed in the same commit. A result never points at a scenario (it points at a check), so history survives. A scenario whose presets stop materializing is caught by the staging test (Cut 4), not at play time. | Authored by agents through `AetherDb` or by the operator in Studio. The operator owns what an arena contains. | +| **Loadout preset** (`aetheria.loadout`, existing) | key `loadout:` (`Loadout.cs:62`) | Unchanged: authored, replaced only explicitly (`Commit(..., replace)`). | Unchanged. | +| **Test design** (catalog `ItemData`, no product) | name; key minted by `Upsert` | Authored with the scenario that needs it. Lives as long as a preset references it. Per Q3. | Operator (Q3) on whether they exist at all. | +| **Check** (`aetheria.check`, ledger store) | `CultName` `Slug`; key `check:` | Authored. Its statement is edited in place when a ruling changes (git keeps the history). It is retired, never deleted, once any result references it: `Retired` plus a reason. | The rule's owner (a ruling or a map) decides what it says. Agents transcribe it with a source pointer. | +| **Check result** (`aetheria.checkresult`, ledger store) | key `checkresult::` | Append-only. Never edited or deleted. A correction is a new result whose note says what it corrects. The latest by `At` is the current status. Order is carried in the data (`At`), not in delivery. | The operator's observation. Who writes the record is Q2. | +| **Pending scenario** (runtime) | `ActionGameManager.PendingScenario`, a static | Written once by the menu's launch, read and nulled once by `StartGame`'s new-run branch. Null for Continue. Never saved. | Command-only transport. It decides nothing. | +| **Staged entities** (runtime, then run state) | ordinary entities | Born at staging. From then on they are run state and saved with the zone like any other entity. Two things are not saved: whether a ship was left unpiloted, and scenario IFF overrides. After Continue, an unpiloted scenario ship gets a `Minion` by the constructor rule and its stance reverts to faction rules (neutral, since presets carry no faction). A scenario run is meant to be played through in one sitting. | `RunStart` at birth; the zone afterwards. | + +--- + +## 2. Target shape + +### 2.1 Types (names and rules; bodies are Hands') + +`Assets/Scripts/ServerShared/Scenario.cs`, catalog: + +- `Scenario` (`aetheria.scenario` v1): + - `Name` (`CultName`). + - `Brief`: the conditions in one or two lines, shown in the menu. + - `Seed` (`uint`): the arena galaxy and the background noise both derive from it, so the arena is the same layout + every launch. Combat rolls are already zone-seeded (`Zone.cs:84`). Lot quality still comes from the + clock-seeded `ItemManager.Random` (`ItemManager.cs:20`), so stats vary slightly per launch. Accepted. + - `Ambient` (`bool`): true keeps the arena zone as generated. False keeps planets, orbits and stations but drops + generated ships and turrets, so nothing wanders into the conditions. + - `Player` (`ScenarioShip`). + - `Entities` (`List`). +- `ScenarioShip`: `Loadout` (`CultRecordRef`), `Position` (`float2`, zone xz), `Direction` (`float2`), + `Cargo` (`List>`, stored in the first cargo bay). +- `ScenarioEntity : ScenarioShip`: `Stance` (`Neutral | Hostile`, applied both ways through `SetIff`) and `Piloted` + (`bool`: a `Minion`, or no agent at all). +- A preset's hull type picks the entity class: a ship hull gives a `Ship`; a turret hull gives an orbital entity + with no orbit, stationary at its position. A station hull is refused as a staging failure. + +`Assets/Scripts/ServerShared/Checks.cs`, ledger store (`GameData/Checks.cc`, committed, LFS by the existing +`GameData/*.cc` rule): + +- `Check` (`aetheria.check` v1): `Slug` (`CultName`), `Statement` (what to do and what must be observed), + `Source` (the ruling or map pointer, `doc:line`), `Scenario` (`CultRecordRef`, optional), `Setup` + (text, only when there is no scenario: "FieldShieldTest scene", "CultCache Studio", "Addressables play mode Use + Existing Build"), `Retired` and `RetiredReason`. +- `CheckResult` (`aetheria.checkresult` v1): `Check` (ref), `Result` (`Pass | Fail | Partial | Blocked`), + `Revision` (the commit the operator played, plus `+dirty` when the tree had changes), `At` (UTC), `Note`, + `Recorder`. + +Naming: "check", not "behaviour". `Behavior` is already the gear-behaviour type family +(`Assets/Scripts/ServerShared/Behaviors/`), and a document named `Behaviour` beside it reads as a typo. + +Where they live, and why: +- Scenarios, presets and test designs are design-side fixtures. They go in the catalog: presets are already + catalog data, cross-type refs resolve inside one store, and Studio edits them side by side. +- Checks and results are the verification ledger. They go in their own store. The shipped catalog carries no + history, results never trigger a catalog write, and "one commit lands in one store" holds: recording a result + touches the ledger only. + +### 2.2 The staging owner + +**Owner: `RunStart`, a static class in `Assets/Scripts/ServerShared/RunStart.cs`.** It decides what a new run +starts with: how the arena zone is generated, the player ship, the other entities, their stance, pilot and cargo. +It holds no Unity type. + +- **Inputs:** `ItemManager` (and through it the catalog), the `Galaxy`, `ZoneGenerationSettings`, the optional + `Scenario`, and, for the absent-scenario case, the default hull filter and faction `StartGame` uses today + (`:805-809`). +- **Outputs:** + - the arena's `ZonePack`, with the `Ambient` rule applied inside generation (`ZoneGenerator.GenerateZone` + gains the parameter), not generated and then pruned; + - the admitted player `Ship`, flagged `IsPlayerShip`; + - the admitted scenario entities; + - a failure list. Staging is all-or-nothing, as `Materialize` is. +- **A check entry** that validates a scenario without a zone (every preset materializes, every cargo design + exists). The menu calls it before `RunSave.Clear`; the staging tests call it on every catalog scenario. +- **Derived state:** `ActionGameManager.CurrentEntity` is bound to the returned player. `PendingScenario` is + transport only. +- **Forbidden writers:** `StartGame` may not generate, unpack or admit the player ship. `MainMenu` may not stage. + No console command admits entities (`give` and `spawnturret` die, Q4). +- **Shared paths:** New Game and scenario launch both run menu, galaxy, `StartGame`, `RunStart`. Continue does not + stage. +- **Deletion line:** `StartGame` `:805-814` (generator, unpack, add, activate). The only surviving line is the + bind. + +**Admission primitive: `Zone.Admit(Entity entity, bool piloted)`.** It adds the entity to `Entities`, activates it, +and, when `piloted`, gives it the zone's `Minion` (the `CreateAgent` rule, `Zone.cs:135-142`). It is the only writer +of `Entities.Add` and the only creator of agents. +- The constructor loop (`:120-131`) calls it with `piloted: ship && !IsPlayerShip`. It keeps its own rule that + moves a ship packed at the origin (`:129-130`); that is generation placement, not admission. +- `PopulateLevel` (`ActionGameManager.cs:735`), `Entity.TryUndock` (`Entity.cs:1006-1007`) and `RunStart` call it. + `spawnturret` (`:587`) is deleted. +- The `Death` subscription (`Zone.cs:93`) stays on `ObserveAdd` and still covers every join. + +**`Loadouts.Materialize` gains two rules** (`Loadout.cs:96-159`): +- A design with **no product at all** is built unbranded, through `CreateLot(design, default, quality)` at one + named quality constant (0.95, the quality `give` used). A design whose products are all unavailable still fails, + as today. The rule turns "no manufacturer makes it" into "outside the economy", which generation already obeys. +- The **hull type picks the entity class** (section 2.1). +- The comment at `LoadoutGenerator.cs:150-153` ("No game path materializes a preset yet") is rewritten to name + `RunStart`. + +### 2.3 The Unity lowering + +- `MainMenu.ShowMain` (`:92-184`) gains **Scenarios**, shown when `Debug.isDebugBuild` (editor and development + builds). It opens a panel with one button per catalog scenario, ordered by name. Under each button is its brief + and its checks, each with its latest result and revision, read from the ledger (display only). A scenario whose + check entry fails shows the failures and is disabled. +- **One launch body** is extracted from the New Game lambda (`:110-176`). New Game calls it with no scenario; a + scenario button calls it with one. With a scenario, it runs the prelude branch (`:146-175`) with `Seed`, derives + the noise position from `Seed` instead of `UnityEngine.Random`, sets `PendingScenario`, and loads `ARPG`. The + check entry runs first; `RunSave.Clear` (`:113`) moves after it, so a refused scenario leaves the saved run intact. +- `StartGame`'s new-run branch (`:803-815`) calls `RunStart`, binds the returned player and nulls + `PendingScenario`. The Continue branch asserts it is null. +- `AetheriaStores.Open` gains an optional ledger path. The game attaches `GameData/Checks.cc` read-only; `AetherDb` + attaches it writable for the record commands (Cut 3). + +### 2.4 Authoring + +- **Agents author headless through `AetherDb`.** A seed command (Cut 4) declares the first set in C#. It writes a + record only when the key is absent. When the key exists and differs from what it would write, it refuses and + reports; that means Studio edited it, and Studio wins. When the key exists and matches, it does nothing. It + validates every scenario through the `RunStart` check entry before committing. So the catalog is the authority, + and the seed code never clobbers it. +- **The operator authors in Studio and in play:** Studio for scenario fields; `capturepreset` for a ship fitted by + hand in play. +- Results are recorded per Q2. + +--- + +## 3. Cuts + +Build budget, every cut: +- `Aetheria.Shared` (netstandard2.1, the Unity-independence check), `tests/Aetheria.Shared.Tests`, `tools/AetherDb` + (net10.0), Debug. +- CultLib is unchanged; the pins are those in `hands-fuse8/run.sh`. +- No new project, target, package or daemon. One new store file (`GameData/Checks.cc`), three new schemas. +- **Build host:** Yggdrasil builds the headless projects (`run.sh `). `Assembly-CSharp` is proven only by a + Unity 6000.3.24f1 batchmode compile on Starfire, with the editor closed by the operator. Never kill Unity. +- `run.sh` mounts a pinned `data/Aetheria.cc` over the tree's LFS file. Any cut that changes the catalog (Cut 4) + must refresh that pin, or its content tests read the old catalog. Cut 4's tests that read the ledger mount it the + same way. Yggdrasil's disk is saturated: one job per cut, `--artifacts-path` in the container. + +Order: 0, 1, 2, 3, 4. Cut 0 is subtraction alone, so Soul can falsify it alone. Cuts 1 and 3 are headless. Cut 2 +is the only Unity-side cut. Cut 4 is content. + +### Cut 0. Retire the ad hoc test affordances (Q4) + +- **Repo/branch:** Aetheria, `codex/scenarios` from `master` `dcd7bbc5`. +- **Deletes first** (per Q4 A; B keeps the item branch of `give` and the wanderers): + - `ActionGameManager.cs:515-544`, `give` (30 lines). + - `ActionGameManager.cs:564-589`, `spawnturret` (26 lines). + - `ZoneGenerator.cs:342-356`, neutral wanderers (15 lines), and `EligibleWandererFactions` `:361-365` (5). + - `Settings.cs:156`, `NeutralWandererCount` (1). It is absent from `Settings.asset` (settings-globals `:72`), so + no asset edit is needed. + - `IffAndCombatTests.cs:329-350`, the two wanderer tests (22). +- **Keeps:** `CommissionShip` (`:898-903`) and its consumer `TradeMenu.Buy`; the console fixes in `f935c29b`. +- **Docs:** `docs/merge-to-master-checklist.md` section 8, the `give` bullet: add "retired by the scenarios cut" + with the commit. `docs/settings-globals-cut.md:72,348` get a one-line history note. +- **Verification:** + - builds: `Aetheria.Shared`, the test project. Batchmode compile (`ActionGameManager.cs` changed). + - tests: the full suite is green. The locomotion "real enemy-count check" (`95394f78`) counts generated ships; + if it counted wanderers, fix its expectation and say so. + - negative: `rg -n '"give"|"spawnturret"' Assets/Scripts` is empty; + `rg -n "NeutralWanderer|EligibleWandererFactions" Assets tests tools` is empty. Both patterns were checked to + match only these sites today. + - operator: none. There is nothing to see until Cut 2. +- **Ledger:** about -99 lines; 0 added. + +### Cut 1. `RunStart`, `Zone.Admit`, and the scenario type (headless) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cut 0. Re-anchor `Zone.cs` if mining has merged. +- **First:** with `AetherDb hardpoint-fit` and a scratch `Materialize` against the live catalog, confirm that + `Djinni`, `LonginusX` and one turret hull each have a product and materialize bare. If one has no product, stop + and report: the Q3 rule would make it unbranded, which may not be intended for a real hull. +- **Deletes first:** + - `ActionGameManager.cs:805-814`, the new-run spawn (it moves into `RunStart`; this cut leaves `StartGame` + calling `RunStart` with no scenario, so behaviour is unchanged). + - The four hand-written joins: `ActionGameManager.cs:735-736`, `Entity.cs:1006-1007`, `Zone.cs:124-125`, and the + agent add at `Zone.cs:128`. Each becomes `Admit`. +- **Adds:** `Scenario.cs`, `RunStart.cs`, `Zone.Admit`, the `ambient` parameter on `ZoneGenerator.GenerateZone` + (`:38-43`; the ship loop `:334-340` and `PlaceTurrets` calls `:309,331` honour it), `Scenario` in + `AetheriaStores.CatalogTypes` (`:9`), and the two `Materialize` rules (`Loadout.cs:103-115,131`). +- **Authority map:** section 2.2. Owner `RunStart`; admission `Zone.Admit`; deletion line as above. +- **Verification:** + - builds: `Aetheria.Shared`, tests, `AetherDb`. Batchmode compile (`ActionGameManager.cs`, `Entity.cs` changed). + - tests (new `RunStartTests`: live catalog, authored settings, a prelude galaxy at a fixed seed). Each rule + must fail under its own mutation: + - With no scenario, staging yields a player of the `StartingHullName` hull, `IsPlayerShip`, admitted, at the + origin. This pins that New Game is unchanged. + - With a scenario, the player is the preset's hull and fit at the authored position and direction, and its + cargo holds the listed designs. + - A piloted entity has exactly one agent in `Zone.Agents`; an unpiloted one has none and stays put over 5 s of + `Zone.Update`. + - `Hostile` makes both `IsHostileTo` directions true. `Neutral` makes both false. + - `Ambient: false` leaves no ship or turret in the arena pack that the scenario did not place. Stations remain. + - A turret preset stages as a stationary orbital entity. A station preset is a staging failure. + - A scenario naming a missing design fails with that design named. The zone is unchanged, and no + `RunSave` call was made (the check entry touches no store). + - A product-less design materializes unbranded; a design whose only product is unavailable still fails. + This is two tests, because the second case is the existing contract. + - `Admit` is the only agent creator: after a warp (`PopulateLevel`'s join through `Admit(pilot, false)`) the + pilot has no agent. The constructor's pack path still gives every non-player ship a `Minion`. + - Stryker on `RunStart.cs`, `Zone.cs` (`Admit` and the constructor loop), `Loadout.cs` (`Materialize`), scoped + to the diff. Triage survivors by name. + - negative: `rg -n "Entities\.Add\(" Assets/Scripts --glob '!**/ZoneGenerator.cs'` matches only + `Zone.cs` (once, inside `Admit`), `Sensor.cs:169` and `Entity.cs:242` (both are `VisibleEntities`/pings, not + zones). `rg -n "new Minion\(" Assets/Scripts` matches only `Zone.cs`. + - operator: none beyond the Cut 2 smoke. +- **Ledger:** `Scenario.cs` +60; `RunStart.cs` +110; `Zone` +12 -8; `Loadout.cs` +20; `ZoneGenerator` +4; + `ActionGameManager` -11 +4; `Entity` ±2; `AetheriaStores` ±1; tests +300. + +### Cut 2. The main menu launches scenarios (Unity) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cut 1. +- **Deletes first:** the duplicated body of the two galaxy branches in the New Game lambda (`MainMenu.cs:124-175`) + collapses into one launch body. The standard branch (`:124-145`) is live code only when `TutorialPassed` is + true, and it is kept. Only the duplication goes. +- **Adds:** the Scenarios panel, `PendingScenario`, the seed-derived noise, the check entry before + `RunSave.Clear`. `StartGame` passes `PendingScenario` to `RunStart`. +- **Authority map:** + - Owner of what the run starts with: `RunStart` (unchanged). + - `MainMenu` owns which scenario was picked and when the run is cleared. `PendingScenario` is a command-only + transport, consumed once. + - Forbidden: `MainMenu` stages nothing and admits nothing. `StartGame` reads `PendingScenario` only in the + new-run branch. + - Shared paths: both buttons run one launch body. In-game menu (after death) and title menu run the same one. +- **Verification:** + - builds: batchmode compile, editor closed. + - negative: `rg -n "RunSave\.Clear" Assets/Scripts/UI/MainMenu.cs` appears once, after the check entry. + `rg -n "PendingScenario\s*=" Assets/Scripts` matches the launch body and `StartGame`'s null only. + - operator (Unity, Starfire): + 1. Title menu: Scenarios is listed. New Game still starts a LonginusX at the entrance with a Zenith station + (the Cut 1 test pins the ship; this proves the lowering). + 2. Launch any scenario. The arena matches its brief; launch it again and the layout is the same. + 3. Die in a scenario. From the in-game menu, relaunch the same scenario. + 4. Quit mid-scenario, then Continue. The run continues (the 0b rule: unpiloted ships now patrol). + 5. Break a scenario on purpose in a scratch catalog (a missing design). It is shown disabled with the failure, + and Continue still offers the previous run. +- **Ledger:** `MainMenu` +45 -25; `ActionGameManager` +4; one static. + +### Cut 3. The verification ledger (headless; Q2) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cut 1 (independent of Cut 2). +- **Adds:** `Checks.cs` (`Check`, `CheckResult`); `AetheriaStores.LedgerTypes` and the optional path; + `GameData/Checks.cc` (empty, committed). + - Under Q2 B, `AetherDb` gains two commands: + - `checks [scenario]`: each check with its latest result, its revision, and "never" when there is none. + - `record --revision [--note ]`. It refuses an unknown or + retired slug. It opens the ledger writable and the catalog read-only. + - Under Q2 A, the menu panel gains Pass/Fail/Partial and a note field per check. It writes through the same + ledger primitive, and the game attaches the ledger writable. +- **Authority map:** + - Owner of results: the ledger store. The only writer is the Q2 writer, which calls one append primitive, + `Ledger.Record`. + - Derived, display only: the menu's latest-status line and `checks`' output. + - Forbidden: nothing edits or deletes a `CheckResult`; nothing writes a result into the catalog. +- **Verification:** + - tests (new `LedgerTests`, temp stores): + - Recording appends and never replaces: two results for one check both survive a reopen, and the latest is + the one with the later `At`, even when written first. + - A retired or unknown slug is refused with nothing written. + - The catalog file is byte-identical after `record` (a catalog opened read-only cannot be written; this pins + that the command opened it that way). + - A result carries the revision it was given, and `record` without `--revision` is a usage error. + - Stryker on `Checks.cs` and the `AetherDb` record path. + - negative: `rg -n "CheckResult" Assets/Scripts --glob '!**/Checks.cs'` matches only the menu's read (and under + Q2 A its record call). +- **Ledger:** `Checks.cs` +50; `AetheriaStores` +4; `AetherDb` +90 (B) or `MainMenu` +50 (A); tests +120. + +### Cut 4. The first scenarios, checks and history (content) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cuts 2 and 3. Hands authors on the branch clone, never in + `F:\Projects\Aetheria`. +- **First:** tell the operator that the uncommitted smoke weapons in their tree's `GameData/Aetheria.cc` must be + restored (`git checkout -- GameData/Aetheria.cc`, their own command) before they pull. This cut recreates them as + committed designs. +- **Adds:** + - A transient `AetherDb scenario-seed [apply]` command, with the never-clobber rule of section 2.4. + - It writes the test designs (section 5.3), the presets (5.4), the scenarios (5.2) and the checks (5.1). + - It imports the 2026-09-30 smoke results (checklist section 8) as `CheckResult`s at revision `113164fa`, + recorder "operator (play), recorded by agent". + - It runs once, the data is committed, and the command is deleted in the same cut. The catalog is the authority + from then on. + - `ScenarioConditionTests` (live catalog; pinned catalog refreshed, see the build budget). Per scenario, stage it + and assert the conditions its checks need, not the behaviour, which existing suites own. For example: + - `Fused rounds`: the bare hull lies within the arc on the player's initial aim line and has no target + selected; the close hull is nearer than `Smoke Proximity`'s `BlastRadius`; `Smoke Refused` has + `Range < BlastRadius`; a direct-fire weapon can kill the fragile hull. + - `Duel`: the AI is piloted, hostile both ways, and carries a slow missile weapon; the player can see it. + - `Arcs`: the three bare hulls lie in the bow, beam and stern sectors of the player's mounts; the turret is + hostile. + - Every catalog scenario passes the check entry. This is the rot test. + - Every non-retired check with a scenario points at an existing one. Every check without a scenario has a + `Setup`. + - Docs: `docs/merge-to-master-checklist.md` sections 2 and 8 get one line: steps 7-20 and handoff checks A-F are + now checks in `GameData/Checks.cc`, listed by `AetherDb checks`. The prose steps stay as history. +- **Verification:** + - tests: `ScenarioConditionTests`, with mutation by editing a fixture. Moving the close hull outside the blast + radius, dropping the AI's missile weapon, or flipping a stance must each fail its condition. This is a one-off + Soul probe, not a committed suite. + - `AetherDb checks` lists every check in section 5.1, with the imported results on 3, 4, 5, 6 and "never" + elsewhere. + - operator: play each scenario and report per check. That report is the first real use of the ledger. +- **Ledger:** seed command +300 then -300; catalog +5 designs, +11 presets, +10 scenarios; ledger +44 checks, +7 + results; tests +250; docs ±4. + +--- + +## 4. Authority map (whole campaign) + +- **Owner of a new run's contents:** `RunStart`. It reads the scenario (or its absence) and writes the arena pack + and the admitted entities. +- **Owner of zone membership and agents:** `Zone.Admit`. +- **Owner of fixtures:** the catalog (`Scenario`, `Loadout`, test designs), written by the seed, Studio or + `capturepreset`, and read-only at runtime. +- **Owner of verification history:** the ledger store. It is append-only and written by the Q2 writer. +- **Derived:** menu status lines; `AetherDb checks`; `PendingScenario` (transport). +- **Forbidden writers:** `StartGame` spawning ships; `MainMenu` staging; console commands admitting entities; + anything editing a result; any catalog write at runtime. +- **Shared paths:** New Game and every scenario launch: one launch body, `StartGame`, `RunStart`, `Admit`. Warp, + undock, construction and staging: `Admit`. +- **Deletion line:** Cut 0's affordances; `StartGame` `:805-814`; the four hand-written joins; the duplicated New + Game branches; the transient seed command. + +--- + +## 5. The first consumers + +Sources: `docs/merge-to-master-checklist.md` section 2 (steps 7-20) and section 8; checks A-F from +`F:\Projects\HANDOFF-aetheria-play-smoke-2026-09-29.md` section 3. That file sits outside the repo, so the checks +below restate A-F and the ledger becomes their home. The rulings behind A-F are in `fire-control-cut.md`, in the +block "Ruled (operator, 2026-09-30)" (`:2625-2690`). + +### 5.1 Checks + +| Slug | Statement (abridged; the record carries the full text) | Source | Setup | +|---|---|---|---| +| `restored-hull-flies` | Thrusters move and turn the Djinni. | step 7; `locomotion-cut.md:501` | Djinni shakedown | +| `restored-hull-speed-cap` | Take the hull offline with thrusters firing; speed does not run away. | step 7; `:377` | Djinni shakedown | +| `weak-reactor-starves-legibly` | A reactor too small for the loadout shows starvation, not a cooked reactor. | step 8; `stats-and-power-cut.md:705-706` | Starved reactor | +| `brownout-degrades` | Brownout reads as degradation, not breakage. | step 9; `:774,990` | Starved reactor | +| `brownout-refill-stutters` | A weapon refilling under brownout stutters rather than firing at full rate. | step 9; `:729-730` | Starved reactor | +| `memory-flat-across-zones` | Several zones with kills; memory does not climb. | step 10; `:672-673,980-982` | Long haul | +| `side-mounts-fire-abeam` | Side-mounted weapons fire at the beam target. | step 11; `fire-control-cut.md:397-398` | Arcs | +| `turret-tracks-round` | The hostile turret tracks the player all the way round. | step 11 | Arcs | +| `no-fire-through-hull` | Nothing fires at the stern target through the hull. | step 11 | Arcs | +| `shots-roll` | Rolled impacts and misses; a miss reads as a near-miss; damage matches the HUD. | step 12; `:659-661` | Duel | +| `subsystem-aim` | Reveal and selection work; aiming at a revealed subsystem concentrates damage. | step 12 | Duel | +| `kill-drops-loot` | The kill drops its cargo; pickup stores it; the tractor beam pulls. | step 12; `headless-playground-cut.md:789-794` | Duel | +| `loot-keeps-brand` | Picked-up loot shows its manufacturer. | step 6; `item-provenance-cut.md:528-539` | Duel | +| `target-vanish-safe` | The target dies with shots in flight: no crash, no frozen ship. | step 12; `fire-control-cut.md:1464-1470` | Duel | +| `beam-reads-higher` | The HUD `hull` factor reads higher from the beam than from the bow. | step 13; `:2380-2383` | Duel | +| `armour-face-missile` | Turn the armoured face into the AI's slow missile; it lands there. | step 13 | Duel | +| `ai-engages` | The AI still engages normally. | D; `:2658-2660` | Duel | +| `launcher-edge-pulse` | Launcher into the bow, then a flank: the schematic pulses the facing edge only. | step 14; `:2519-2520` | Launcher angles | +| `guided-explodes-on-detonation` | A guided round's explosion shows only when the simulation detonated. | E | Launcher angles | +| `guided-target-left-fades` | Kill the target while a guided round flies: the round fades with no explosion. | E | Launcher angles | +| `guided-hit-at-target` | A guided hit plays its effect at the target. | E | Launcher angles | +| `fused-clear-line-max-range` | Nothing selected, clear line: the round bursts at max range along the arc-clamped aim. | A.1; `:2625,2636-2642` | Fused rounds | +| `fused-first-hull-stop` | Nothing selected, aim at the bare hull: the round stops and detonates on it. | A.2; `:2632-2634` | Fused rounds | +| `fused-target-left-no-burst` | Fire at the fragile target, kill it with the direct weapon before arrival: no burst. | A.3; `:2636-2642`, `FireControl.cs:610` | Fused rounds | +| `fused-out-of-arc` | Select the stern target: the round fires and bursts at its range along the arc-clamped aim. | A.4; `:2643-2645` | Fused rounds | +| `fused-arming-pushout` | Select the close hull: the burst is pushed out to `BlastRadius` from the ship's centre. | A.5; `:2646-2653` | Fused rounds | +| `airburst-at-model` | A proximity airburst beside the LonginusX lands damage where the model shows the blast. | step 15; `:3094-3097` | Fused rounds | +| `penetrator-reaches-interior` | A delayed penetrator into the LonginusX nose detonates inside. | step 15 | Fused rounds | +| `hud-forecast-fused` | The HUD shows Direct, Burst at N m or Refused for fused weapons, and a percentage for direct weapons. | C; `:2661-2663` | Fused rounds | +| `unbranded-no-manufacturer` | An unbranded smoke weapon shows no Manufacturer row. | step 6 | Fused rounds | +| `refused-shot-free` | A refused shot costs no ammo, energy, heat, sound, visibility or cooldown. | B; `:2664-2666` | Refused rounds | +| `refused-beam-no-flicker` | A refused beam never starts. | B | Refused rounds | +| `refused-charge-never-charges` | A refused charged weapon never starts charging. | B | Refused rounds | +| `instant-and-charged-fire` | Autocannon and ChargeBlast SG fire; effects, particles and action-bar icons show. | step 5; `addressables-cut.md:362-366` | Weapon feel | +| `burst-cadence` | Burst cadence is unchanged. | F | Weapon feel | +| `charge-release-press` | A fast release-and-press on a charged weapon starts no second charge. | F | Weapon feel | +| `existing-build-play` | Under "Use Existing Build", the Weapon feel checks hold (GUID keys in the player catalog). | step 20; `addressables-cut.md:367-370` | Weapon feel, Setup: play mode Use Existing Build | +| `ai-never-fires-refused` | The hostile AI whose only weapon is refused never fires it. | D | AI fused discipline | +| `ai-never-bomb-fishes` | The neutral AI carrying a fused weapon never fires it. | D | AI fused discipline | +| `continue-keeps-items` | Warp, quit, Continue: same items, same tier and brand. | step 16; `item-provenance-cut.md:536-537` | Long haul | +| `death-ends-run` | Die: Continue is disabled and `run.cc` holds no ledger. | step 17; `:540-541` | Long haul | +| `shield-panel-rig` | The numbered shield-panel look, including the Cut 5 dicing and break-through. | step 18; `shield-panel-cut.md:683-700,737-739` | Setup: FieldShieldTest scene | +| `studio-*` (5 checks) | The fire-control union renders; the schematic underlay; restored hull records; the fuse fields edit; the provenance ledger renders from a real `run.cc`. | step 19 | Setup: CultCache Studio | +| `addressables-duplicates` | Analyze the duplicate dependencies: 5 known leftovers. | step 20 | Setup: Addressables Analyze | +| `new-game-start`, `purchase-keeps-brand`, `tier-colour` | New Game; buying keeps the brand; tier colours show. | steps 3, 4, 6 | Setup: New Game | + +Imported results (checklist section 8, revision `113164fa`, 2026-09-30): +- `new-game-start`: pass. +- `purchase-keeps-brand`: pass. +- `instant-and-charged-fire`: partial ("charged weapon unprovable: 1x2 hardpoint"). +- `tier-colour`: fail ("known defect, `settings-globals-cut.md:150`; fixed by settings-globals Cut 1"). +- `unbranded-no-manufacturer`: blocked ("`give Lamp` named a test fixture"). +- `loot-keeps-brand`: no result ("not yet reported"). + +### 5.2 Scenarios + +All use `Ambient: false` except Long haul. Positions are authored in the seed and pinned by the condition tests. + +| Scenario | Player | Entities | Checks | +|---|---|---|---| +| Djinni shakedown | Djinni, full fit | none | 2 | +| Starved reactor | a combat hull with an undersized reactor, energy weapons and a shield | 1 unpiloted bare hull ahead, neutral | 3 | +| Arcs | a hull with side mounts | 3 unpiloted bare hulls (bow, beam, stern), neutral; 1 turret preset, hostile | 3 | +| Duel | combat fit | 1 piloted LonginusX, hostile, carrying a slow missile weapon, cargo holding a branded item | 8 | +| Launcher angles | GT 3K or pswarm plus a direct weapon | 1 unpiloted LonginusX, hostile; 1 fragile bare hull | 4 | +| Fused rounds | Smoke Proximity, Smoke Delayed, a direct weapon | unpiloted: LonginusX mid range, hostile; bare hull on the clear bearing; fragile bare hull; close bare hull inside `BlastRadius`; stern target | 9 | +| Refused rounds | Smoke Refused (instant, beam, charged) plus one working weapon | 1 unpiloted bare hull | 3 | +| Weapon feel | a hull with a 2x2 energy hardpoint: ChargeBlast SG, Autocannon, a burst weapon | 1 unpiloted bare hull | 4 | +| AI fused discipline | a sturdy hull | 1 piloted neutral with a fused weapon; 1 piloted hostile whose only weapon is refused | 2 | +| Long haul | standard combat fit, `Ambient: true` | none | 3 | + +### 5.3 Test designs (Q3) + +None has a product. They are unbranded and outside the economy. +- `Smoke Proximity`: Proximity, blast 8. +- `Smoke Delayed`: Delayed, blast 4, penetration 2 cells. +- `Smoke Refused`: Proximity, blast 30, range 20. + +These three are the operator's own designs from tonight, Spectra clones, energy, 1x2. +- `Smoke Refused Beam` and `Smoke Refused Charge`: a constant weapon and a charged weapon with a fuse and + `Range < BlastRadius`. +- All must pass `CultRecordRefs.Validate` (R-heat, stat modifiers, roles). + +### 5.4 Presets + +About eleven, named after their scenarios. The seed author picks designs with `AetherDb hardpoint-fit`. The only +hard constraints are those the condition tests pin. + +--- + +## 6. Subtraction ledger + +| Cut | Removed | Added | Targets, schemas, stores | +|---|---|---|---| +| 0 | `give` 30, `spawnturret` 26, wanderers 20, setting 1, tests 22 | 0 | no change | +| 1 | `StartGame` spawn 10, joins 6 | `Scenario.cs` 60, `RunStart.cs` 110, `Admit` 12, `Materialize` 20, `GenerateZone` 4; tests 300 | +1 schema (`aetheria.scenario`) | +| 2 | duplicated New Game body about 25 | menu panel and launch body about 45; 1 static | none | +| 3 | none | `Checks.cs` 50, stores 4, `AetherDb` 90 (or the menu 50); tests 120 | +2 schemas, +1 store file | +| 4 | the seed command, once spent (300) | seed 300; condition tests 250; catalog and ledger data | catalog content, ledger content | + +Net production code is about +250. That buys: +- the main-menu scenario affordance; +- typed checks with history; +- one staging owner and one admission primitive (from five hand-written joins); +- the first game consumer of the presets that already existed. + +It removes three ad hoc test affordances and the wanderers that ran in every zone. + +--- + +## 7. Operator questions + +Each question stands alone. The first is what the scenario request leaves open. + +**Q1. Is a scenario arena part of a real run?** +Your words settle what an arena holds. They leave open what surrounds it. Checks 16 and 17 (warp, save, +Continue, death) need a real run. Every other check needs only the arena. +- **A.** The arena is the entrance zone of a small prelude galaxy built from the scenario's seed. Stations, + planets and wormholes are there, and the run saves on warp and quit, continues and ends at death like any run. + Launching a scenario replaces your saved run, as New Game does. Scenario-only details (unpiloted ships, forced + stances) do not survive a Continue. +- **B.** A sealed arena: one zone, no galaxy, no saving. Launching never touches your saved run. Checks 16 and 17 + go back to plain New Game play. This is a second zone-entry path with no galaxy under it, which `PopulateLevel` + and the sector map do not support today. +- **Recommended: A.** It is the same New Game path with an input, so there is no second boot path. Every rule + under test then runs exactly as it does in a real run. Replacing the saved run seems harmless for a dev + affordance. +- **Depends on it:** Cut 2's launch body; the 0b lifecycle row for staged entities; whether Long haul exists. + +**Q2. Who records a check's result, and where?** +You play and observe. Something has to write "pass at `abc123`, note ...". +- **A.** In game. The Scenarios panel gets Pass/Fail/Partial and a note per check. The game writes the ledger file + in your working tree, and you commit it with your other work. +- **B.** The agent. You tell the session what you saw, and it records typed results with `AetherDb record` on a + branch and pushes. The menu shows each check's latest result, read-only. +- **C.** No typed results. Checks are typed, and results go in a prose ledger doc, as checklist section 8 does now. +- **Recommended: B.** It matches how tonight's smoke already worked: you play, the agent records. Nothing in the + game writes a file that git tracks, and no agent needs to touch your tree. The menu still shows what is stale. + A can be added later on the same append primitive. +- **Depends on it:** Cut 3's writer and whether the game attaches the ledger writable. + +**Q3. Where do test-only item designs live?** +Checks A-C need fused weapons the catalog does not ship. Tonight they were authored in place and left +uncommitted. +- **A.** Committed catalog designs with no product. With no product nothing makes them, so they are never + generated, stocked, sold or looted. A preset builds them unbranded (a new `Materialize` rule). Studio lists them + beside real content. +- **B.** Committed designs with a product and price 0. Generation skips price 0 today, but only through a filter + written for another reason, so the exclusion is a coincidence. +- **C.** A scratch catalog swapped in per session, as checklist step 2a proposed. Scenarios would then depend on + which catalog is loaded, and the fixtures would not be retained. +- **Recommended: A.** +- **Depends on it:** the `Materialize` rule in Cut 1; section 5.3. + +**Q4. Which ad hoc test affordances do scenarios retire?** +`give` got its hull route tonight (your ruling: "mothballed ships do not require docking bays"), and step 7 was its +reason. `spawnturret` places a turret. Neutral wanderers spawn two non-hostile ships in every generated zone, "so +combat has something non-hostile to target" (`f732cff9`). +- **A.** Delete all three. Scenarios cover ships, cargo, turrets and neutral targets. `CommissionShip` stays for + purchases. +- **B.** Delete the hull route and `spawnturret`. Keep `give` for items (ad hoc poking outside scenarios) and keep + the wanderers. +- **C.** Keep everything. +- **Recommended: A.** Each one is a second way to create what scenarios now author, and the wanderers change every + zone of real play for a testing reason. `iff`, `trackmissile`, `revealzones`, `tow` and `capturepreset` stay. +- **Depends on it:** Cut 0. + +Defaults Self took, which you may overrule: +- Scenarios show only in editor and development builds. +- The ledger is its own committed store, `GameData/Checks.cc`, not the catalog. +- "Check", not "behaviour", because `Behavior` is the gear type family. +- Brokkr is not installed in this campaign (section 0.4). +- The scenario record holds no "start docked" field. No first consumer needs one. + +--- + +## 8. Follow-ups outside this campaign + +- Re-anchor `docs/headless-playground-cut.md`. Its Cut 1 now builds on `RunStart` and `Zone.Admit`; its `new` + command stages a `Scenario`. +- Brokkr install plus a "launch scenario" intent, if agents should drive Unity play. +- A durable agent authoring command for presets (fit by design name with validation), instead of transient seed + commands. +- `AetherDb` carries spent one-shot migration commands (`targeting-catalog-6c`, `-6d`, `firing-arc-migrate`, and + others; `Program.cs` is 1,477 lines). This is a subtraction pass of its own. +- `ItemManager`'s clock seed (`ItemManager.cs:20`) makes lot quality vary per scenario launch. It is recorded, + not fixed here.