Extraction (#3) + In-raid Polish (#8) — Feature Plan¶
Goal: Port the web's full Arc-style multi-phase extraction system to Unity — including the
"parked" complexity (call ritual, timed window, guards, rival contention), shipped config-disabled
so today's always-open behavior is unchanged — plus distinct Shapes portal visuals (escape vs
descend). Then work the #8 in-raid-polish grab-bag. Every value comes from GameConfig; no magic
numbers. Emoji literals stay in strings (they render as boxes until a fallback font lands — fine).
Source of truth: game.js extraction block (callExtraction:3198, updateExtraction:3224,
assignPortalGuards:2592, botExtract:3066, drawExtractMarkers:4552, spawn genMap:643-700) and
config.js extraction + noise groups.
Part A — Multi-phase extraction (#3)¶
A0. Guiding principle — full machine, parked defaults¶
The web ships the full state machine but with extraction.incoming=-1 and extraction.window=-1,
which portalAlwaysOpen() collapses into "renders open from start, step in to extract." We port the
whole machine and keep those defaults, so the live game behaves exactly as today; flipping the two
config values turns on the call→incoming→open→close ritual. This is the web's own parked design.
A1. Portal state machine — Portal.cs rewrite (web updateExtraction)¶
Per-portal phases driven by config, one ExtractionService ticking all portals (mirrors the web's
single updateExtraction loop over G.extracts; a service avoids N MonoBehaviours racing on shared
guard assignment):
- idle → stand on the pad (contact, see A10) →
Call()unless tutorial-locked orprogLocked(progress portal +!BossDown). IfportalAlwaysOpen()(incoming≤0 & window≤0) an idle pad auto-promotes straight to open (the gateway — current behavior). - incoming →
timer -= dt(fromincoming); emitportalIncomingpulses everyportalIncomingCd(recruit=false, player-warning sonar); attimer≤0→ open,timer = window, reassign guards, banner + shake. - open →
timer -= dtunlessportalInfiniteOpen()(window≤0); board logic (A3); board-start loud ping once (extractCall); sustainedportalOpenpulses everyportalOpenCd; stall-on-hit (A7);prog≥1→ extract;timer≤0(finite window) → idle, release guards, banner. - extract:
kind=="progress"→ descend a stage (EndRaid(extracted,descended:true)); else bank (descended:false). Already wired inRaidController.EndRaid.
progLocked/sealed handling already exists on the slice Portal — keep it.
A2. Config — un-defer + wire every value (GameConfig.Extraction + Noise)¶
Drop the DEFERRED tooltips and read each at its use-site (current values, verbatim from config.js):
incoming(-1), window(-1), boardPassive(9), boardForce(3), boardBot(9, already used),
guardCap(4), boardStall(0.6), tilesPerExit(30), minSafe(1), maxSafe(4); noise
portalIncoming(880), portalOpen(600), portalIncomingCd(3.0), portalOpenCd(4.0),
extractCall(1160, already used). (tutorialIncoming/tutorialBoard wait for FTUE — keep as config,
unused for now with a note.)
A3. Board mechanics (web updateExtraction open branch)¶
- In zone, free to act → passive board at
dt/boardPassive; rooted+channeling →dt/boardForce(setsPlayerChannelFrame/Progress/IsExtractlike the slice does, cyan ring); out of zone → decayprog -= dt*0.6. - Board-start: first frame in-zone fires one loud
extractCallping (_boardPingedlatch). - The cyan board ring + "EXTRACTING…" already exist via
PlayerVitalsView.
A4. Stall-on-hit (web p.noHitT < boardStall)¶
Add a last-hit timestamp to the player: PlayerController subscribes to Health.Damaged
(already does, for heal-cancel) → stamp Time.time. Expose SecondsSinceHit. Boarding pauses while
SecondsSinceHit < boardStall. (Reusable later for HP-regen delay, A-#8.5.)
A5. Guards (web assignPortalGuards:2592 + patrolMove ring)¶
ExtractionService.AssignGuards(): clear stale_guardPortal; for each active (incoming/open) portal, take the nearest ≤guardCapcalm (alert<0.5), non-boss, non-stationary mobs within a range → stampEnemyController._guardPortal.EnemyControllerpatrol gains a guard-ring mode: when_guardPortalset, wander targets ride a wide ring (95–185 world-px → /U) around the portal, leaving a slip-in pocket; faster repath cadence.- Release on portal close. (Tutorial: never repurpose lesson enemies — gated when FTUE lands.)
A6. Safe vs progression spawn parity (web genMap:643-672)¶
MapGenerator.PlacePortals already spawns one Descent (progress) + named safe exits with max-spread
+ pad clear. Bring it to web parity:
- Count: nSafe = clamp(round(mapW / tilesPerExit), minSafe, maxSafe) safe exits plus the one
progression portal (when def.descentPortal) — replacing the fixed def.portalCount.
- safeAway bias: safe exits push hard away from the progression portal (radius mapW*ts*0.45) so
you're never forced past the guardian to bail.
A7. Rival extraction (web botExtract:3066)¶
Generalize RaiderController.BotExtract from the single FindFirstObjectByType<Portal>() to the
multi-portal flow: pick nearest non-closed portal, re-pick every ~4s (only if not boarding & a new one
is meaningfully closer), Call("rival") an idle pad, fight its way to the pad if a hostile is near,
board at dt/boardBot, boarding stalls while a hostile pressures it (player can deny), and fire
the one-time "⚠ A rival is extracting at X — intercept them!" banner + loud ping. Reuses the same
Portal/ExtractionService API as the player.
A8. Portal visuals — Shapes PortalView (the explicit ask; web drawExtractMarkers)¶
Replace the cylinder with a Shapes-built view per portal, escape distinct from descend:
- Descend (progress): violet #c071ff. Sealed (progLocked): violet dashed ring + glyph
(👑 boss floor / 🌀 normal). Open: violet glow disc + bright swirl ring, "DESCEND · name".
- Safe (escape): idle green dashed dormant rune ("tap/step to call"); incoming amber
#ffc846 forming ring + contracting inner ring (1 - timer/incoming); open green #5af082
glow + swirl + pulse (slow breathe if gateway, fast if freshly called).
- Shared: closing countdown text below (⌛ closes in M:SS / ⌛ stays open); board progress ring
meter above; all colours/sizes from config or a small PortalStyle config block (NO inline hexes —
add GameConfig.PortalStyle mirroring the web palette).
- Build with the established Shapes pattern (Disc/Line, component mode); billboarded; reuse the
ViewPool/—Views— grammar if it fits, else an owned view like ChannelHalo.
A9. Map markers (web exitDotCol)¶
MapController gains exit markers: violet (progress), green (safe), amber (<60s to close), gray
(closed) — on both minimap + tactical map. (Marker infra already exists for POIs/chests.)
A10. Call interaction — contact-based [DECISION]¶
The web calls via tap-target + standing on the pad. Unity already uses contact for chest-crack / gate-force / boarding, so extraction call is contact too: stand on an idle, callable pad → it calls. Consistent, no new input path. (Recorded as a deviation; the math/states are identical.)
Implementation order (Part A) — each verified in play; default config keeps current behavior¶
ExtractionService+Portalstate machine + un-defer config (idle/incoming/open/close, gateway default). Verify: default = step-in extract unchanged; setincoming=8,window=30→ ritual runs.- Board mechanics (passive/force/decay) + stall-on-hit + board/incoming/open noise pulses + banners.
- Guards (assign + ring patrol + release).
- Safe-count parity +
safeAwayspawn bias. - Rival multi-portal extract + intercept banner.
PortalViewShapes visuals (escape vs descend) +PortalStyleconfig.- Map exit markers.
Part B — In-raid polish (#8) inventory (after Part A)¶
Each item: web ref → config → Unity target / blocker. Built after extraction; detailed per-feature investigation happens at implementation. Listed so nothing is missed.
| # | Feature | Web / config | Unity target & notes |
|---|---|---|---|
| 1 | HP regen | player.hpRegenDelay(20), hpRegen(0.005) (live) |
Trickle on Health after SecondsSinceHit ≥ delay (reuses A4's last-hit stamp). Cosmetic; items are the real heal. Unblocked. |
| 2 | Enemy power-scaling | enemyScale.hpPerPow(0.7), dmgPerPow(0.5), powerRef |
Scale creature hp/dmg by (playerPower/powerRef). Needs a player Power number → partly blocked by gear (#1 of the migration list); use a placeholder Power until gear lands, wire the formula now. |
| 3 | PvP power-gap | combat.powCheckExp(0.35), powCheckMin(0.55), powCheckMax(1.7) |
Damage ×= clamp((atkPow/defPow)^exp, min, max) for rival↔player. Same Power-calc dependency as #2. |
| 4 | Backstab crit + rear 🗡 tell | web backstabArc/crit |
Silent backstab already ported; add the crit multiplier + the range-gated 🗡 rear-approach tell on the target. Mostly unblocked (combat config). |
| 5 | Threat nametags | web nameTag ⚡power + green/red HP | Extend WorldLabel enemy tag with a power/HP read. Cosmetic; power read blocked like #2. |
| 6 | Projectile velocity inheritance | web fireProjectile | Add shooter velocity to bullet heading in Projectile. Unblocked. |
| 7 | World events / escalation | run-timer storm / wildmagic | Timer-driven escalation banners + a final map-wide storm. Unblocked (new WorldEventSchedule). Config block likely needed (prog.*/new). |
| 8 | Corpse looting | loot.corpseMinItems(2) (parked: in-run-inventory) |
Parked — only the in-run-inventory path uses it. Default = loose spill (current). Ship the toggle, defer the body. Blocked by inventory (#1). |
| 9 | Gear perks | web perks | Blocked by gear/loadout (#1 migration item). |
| 10 | TMP emoji font | — | Deferred by request — keep 🗡/🔑/🟢/⚠/👑/🌀/⏳/⌛ literals in strings; render as boxes until the fallback sprite/font asset is added later. No code action now. |
Suggested #8 order (unblocked first): HP regen → projectile velocity inheritance → backstab crit + 🗡 tell → world-event escalation → (then power-dependent: enemy scaling, PvP power-gap, threat nametags) → (gear-blocked: perks, corpse loot) when gear lands.
Risks / decisions to confirm¶
- Contact-call (A10) instead of the web's tap-target — consistent with Unity's other interactions.
- Power-dependent #8 items (enemy scaling, PvP gap, threat nametags ⚡) need a player Power value; gear isn't ported yet. Wire the formulas now against a placeholder Power, finish when gear lands.
- Default config stays always-open so this whole port is invisible until a designer flips
incoming/window— exactly the web's parked stance.