fix: playtest pass 2 — bosses, brawl polish, sprite alpha, economy rebalance - #47
Merged
Conversation
Playtest 2026-07-25. M3.5 was unstartable. The briefing card renders ROTATED (the scene fakes landscape while the device is portrait), so its height is bounded by the screen's WIDTH — ~402pt. The rules list is variable: up to four gear-gated loadout lines, plus BEST RUN, plus CORP HEAT. With no scroll, a geared-up player pushed the RIDE button clean off the edge and could not enter the mission. Reproduced in the simulator: on a FRESH save RIDE sat at x≈51, barely on screen. The body now scrolls and RIDE is pinned outside the scroll view, so a longer list can never take the only way in. Bosses were pushovers. Not for want of AI — all four archetypes have aggressive, previously-tuned behaviour. A boss is one unit against a squad of four, so the party takes four actions to its one: it spent the fight walking and died on the approach. Boss archetypes now get a SECOND activation per enemy phase, which is what lets them close AND fire in the same round. Still one unit; the party keeps the edge. The M5 mech had two more problems on top. Its move budget was 2 while its autocannon would only fire inside range 6 — and it spawns at (3,8) with the party entering near the bottom, so on the turn it appeared it could not shoot at all, and never caught up. Move 2 -> 3 and reach 6 -> 8. Its stats were never the issue: deployBoss correctly builds bossMech (80 HP, armour 7), not the 34 HP rank-and-file combatMech. The boss splash was being raised correctly the whole time — BossIntroOverlay just dismissed on ANY tap with no grace period, and the reveal fires the instant the killing blow lands, so an inherited or eager follow-up tap killed the card inside a frame while it was still fading up over 0.9s. Taps are now ignored for 1.2s. Both reveal paths also present the card BEFORE appending the enemy, so it is already rising as the sprite materialises behind it — splash first, then the boss, as intended. Covers M3, M5 and M6. Tests: 126 -> 132. New tests/BossBehaviourTests.swift drives runEnemyAI directly and asserts a boss actually acts, that the mech threatens from its real spawn tile, that only boss archetypes get the extra activation, and that Sato's reveal raises the splash. Also adds a backtrack-spawn regression test covering M1 and M5 door-mirrored re-entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playtest 2026-07-25, second batch. 81 of 444 sprite frames had an OPAQUE WHITE BACKGROUND baked in instead of alpha — the M4.5 opponents (bodyguard/brawler/razorgirl/slugger, exactly half of each one's frames), every chase_* prop, and every shopicon_*. Removed by edge-connected flood fill so interior detail survives, then a second pass cleared white pockets ENCLOSED by the figure (the gap between razorgirl's arm and hip, bodyguard's arm and torso) in the fighter frames only — shop icons keep theirs, where white is design. Verified on a dark composite. Raze's own frames were already correct; the white was his opponents. The Cyber-Zombie is the juggernaut, and its art fills only 86% of its canvas, so at the default target height it rendered a ~60pt figure — the SMALLEST enemy on the board (guard 67, bruiser 70, riot 68, rigger 76) for "a walking wall of bolted chrome". Given its own entry at 99, landing ~85pt. Brawl attacks were a pure pose swap with no displacement, and the active beat is only 0.10-0.12s, so they read as a flicker rather than a strike. Fighters now coil back on wind-up, drive through on the hit and settle on recovery, with body tilt scaled to weight. Hit windows are untouched. The VS card was on screen for 0.3s on the first round and 0.7s after — not long enough to read the challenger or the patch-up. It now parks on a pulsing TAP TO FIGHT with a full-screen catcher, and a 12s backstop so a missed tap can't strand the brawl. Door lock state was computed ONCE per room and painted onto every door, but attemptTransition waives the cleared-room requirement for backtracking — so the door the player had just walked through rendered locked while being freely walkable. Lock state is now per-door. M4's mini-game already ran three escalating levels; they were just visually identical, so clearing one and starting the next read as the same board again. Each tier now has a name (OUTER SHELL / CIPHER LAYER / BLACK ICE CORE), an accent colour, and a callout on advance. Economy. Rank (to 1.6x) and heat (to 1.5x) stacked MULTIPLICATIVELY at 2.40x, which is how a 34,000 contract paid 94,800. Measured against the catalogue: one of every item is 111,000 and kitting all four runners is ~444,000, while a strong campaign banked ~570,000 — enough to buy everything for everyone. Rank and risk now stack additively (best case 1.60x) and base payouts are trimmed ~25%, landing a perfect run near 245,000: about half the full-kit cost, so gear stays a choice. Tests: 133 -> 135. Adds a payout-ceiling pair that fails if a campaign can ever fund the whole catalogue for the team, a backtrack-door lock test, and rewrites three economy tests to derive from basePayout/dataBonus rather than hardcoded figures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…te the mission unlock ## Drift gate (CI-blocking) Every PR was failing `xcodegen drift gate` with the ENTIRE pbxproj rewritten — 3,704 insertions, 3,704 deletions — while `xcodegen generate` locally produced no diff at all. XcodeGen derives its object IDs from the containing directory name. The GitHub repo was renamed ShadowrunGame -> Hexwire, so CI now checks out to `/Users/runner/work/Hexwire/Hexwire` while the local clone still sat at `~/ShadowrunGame`. Same content, different path, every ID different. Reproduced by copying the tree into a directory named `Hexwire` and regenerating: 7,879 diff lines, matching CI exactly. Local clone renamed to `~/Hexwire` and the project regenerated under that path, so generated IDs now match what CI produces. This was blocking every future PR, not just this one. ## Warnings: 30 -> 0 - `fireOverwatchShot` is called for its side effects at 18 sites and its count is never used; marked @discardableResult. Those 18 identical warnings were burying the real ones. - MusicManager's fade timer touches @mainactor state from a @sendable closure (4 warnings). `scheduledTimer` installs on the run loop it was created from and `fade` is only reachable from the @mainactor class, so it IS on main — asserted with `MainActor.assumeIsolated` rather than hopping, since a hop would land the fade tick a frame late and stutter the ramp. - SFXManager read the singleton's `targetVolume` from a @sendable notification closure to scale the SMG clip. Moved that resolution inside the existing main-actor hop, where the value is meaningful anyway. - Two deprecated `onChange(of:perform:)` call sites left as-is; they still behave correctly and the migration is not mechanical. ## Ship blocker: devUnlockAllMissions Was a plain `let devUnlockAllMissions = true` that a human had to remember to flip before cutting a release — a standing WP10 blocker, and the source of a "will never be executed" warning. Now `#if DEBUG` around both the constant and its use, so Release compiles the bypass out entirely and a release candidate CANNOT ship with progression disabled. Debug builds stay unlocked for playtesting, so this changes nothing for the current pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`updateTurnIndicator()` was never called, and the `turnOrder` array it read was never populated — its own `!turnOrder.isEmpty` guard would have returned early even if something had called it. `currentTurnIndex` existed solely to index that empty array. The live `.turnChanged` notification is posted by CombatFlowController, so nothing observable changes. Found by scanning for functions with zero production call sites. Worth removing rather than leaving: vestigial scaffolding is exactly what let the extraction blocker hide — `syntheticExtraction` was equally dead but had passing tests, so side contracts looked covered while being uncompletable. Related, NOT changed here: the `TurnManager` class is never instantiated either (only its static `requestTurnAdvance` forwarder is used), so its initiative machinery — turnOrder, rollAllInitiative, advanceTurn, currentActor, upcomingActors — is dead too. That file also holds the very live `Enemy` type and every enemy factory, so untangling it is a separate change worth doing deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The extraction fix earlier in this branch added a private `firstExtractionTile` to MissionSetupService without noticing that `GameState.firstExtractionTile` already existed — whose own comment reads "Moved from BattleScene so room-entry authority owns the lookup", i.e. this had already been consolidated once and I re-forked it. Both scanned top-left to bottom-right and returned the first match, so they agreed and nothing was broken. Removing it anyway: two implementations of "where is the extraction tile" is precisely how the backtrack-spawn bug happened — RoomManager and BattleScene each had their own answer, they disagreed, and the squad landed at the wrong end of the room. Mission setup and room entry now resolve the pad through the same function. StepOnSemantics / ReplayModeCertification / ContractBoard all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second device-playtest pass (2026-07-25). Two commits: boss/flow fixes, then art + balance.
Run blockers
M3.5 was unstartable. The briefing card renders ROTATED (the scene fakes landscape while the device is portrait), so its height is bounded by the screen's width — ~402pt. The rules list is variable: up to four gear-gated loadout lines, plus BEST RUN, plus CORP HEAT. With no scroll, a geared-up player pushed RIDE clean off the edge. Reproduced in the simulator: on a fresh save RIDE sat at x≈51, barely on screen. Body scrolls now; RIDE is pinned outside the scroll so a longer list can never take the only way in.
Bosses
Not a missing-AI problem — all four archetypes have aggressive, previously-tuned behaviour. A boss is one unit against a squad of four, so the party takes four actions to its one: it spent the fight walking and died on the approach. Boss archetypes now get a second activation per enemy phase, which is what lets them close and fire in the same round.
The M5 mech had two more problems on top: move budget 2 while its autocannon only fired inside range 6 — and it spawns at (3,8) with the party entering near the bottom, so on the turn it appeared it could not shoot at all. Move 2→3, reach 6→8. Its stats were never the issue:
deployBosscorrectly buildsbossMech(80 HP, armour 7), not the 34 HP rank-and-filecombatMech.The boss splash was being raised correctly the whole time.
BossIntroOverlaydismissed on ANY tap with no grace period, and the reveal fires the instant the killing blow lands — so an inherited or eager follow-up tap killed the card inside a frame while it was still fading up over 0.9s. Taps now ignored for 1.2s. Both reveal paths also present the card BEFORE appending the enemy, so it is already rising as the sprite materialises behind it.Art
81 of 444 frames had an opaque white background baked in instead of alpha — the M4.5 opponents (exactly half of each fighter's frames), every
chase_*prop, everyshopicon_*. Removed by edge-connected flood fill so interior detail survives, then a second pass cleared white pockets enclosed by the figure (the gap between razorgirl's arm and hip) in fighter frames only — shop icons keep theirs, where white is design. Raze's own frames were already correct.The Cyber-Zombie is the juggernaut, and its art fills only 86% of its canvas, so at the default target height it rendered a ~60pt figure — the smallest enemy on the board (guard 67, bruiser 70, riot 68, rigger 76). Given its own entry at 99 → ~85pt.
Feel
attemptTransitionwaives the cleared-room rule for backtracking — so the door you'd just walked through rendered locked while being freely walkable. Now per-door.Economy
Rank (to 1.6×) and heat (to 1.5×) stacked multiplicatively at 2.40× — that is how a ¥34,000 contract paid ¥94,800. Measured against the catalogue: one of every item is ¥111,000 and kitting all four runners is ~¥444,000, while a strong campaign banked ~¥570,000 — enough to buy everything for everyone.
Rank and risk now stack additively (best case 1.60×, both incentives intact) and base payouts are trimmed ~25%. A perfect run lands near ¥245,000 — about half the full-kit cost, so gear stays a real choice.
Tests: 126 → 135
BossBehaviourTests— drivesrunEnemyAIdirectly; asserts every boss acts, the mech threatens from its real spawn tile, only boss archetypes double-activate, and Sato's reveal raises the splash.basePayout/dataBonusinstead of hardcoded figures.Not device-verified yet. Two items still open and not in this PR: the M3 grimoire tile flash (already implemented — owner re-checking on a current build) and M5 SFX levels (measured; no per-mission volume exists in code and M5 is rifle-heavy so by asset levels it should be louder — needs to know which sounds felt quiet).
🤖 Generated with Claude Code