Skip to content

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 or progLocked (progress portal + !BossDown). If portalAlwaysOpen() (incoming≤0 & window≤0) an idle pad auto-promotes straight to open (the gateway — current behavior).
  • incomingtimer -= dt (from incoming); emit portalIncoming pulses every portalIncomingCd (recruit=false, player-warning sonar); at timer≤0open, timer = window, reassign guards, banner + shake.
  • opentimer -= dt unless portalInfiniteOpen() (window≤0); board logic (A3); board-start loud ping once (extractCall); sustained portalOpen pulses every portalOpenCd; 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 in RaidController.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 (sets PlayerChannelFrame/Progress/IsExtract like the slice does, cyan ring); out of zone → decay prog -= dt*0.6.
  • Board-start: first frame in-zone fires one loud extractCall ping (_boardPinged latch).
  • 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 ≤guardCap calm (alert<0.5), non-boss, non-stationary mobs within a range → stamp EnemyController._guardPortal.
  • EnemyController patrol gains a guard-ring mode: when _guardPortal set, 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

  1. ExtractionService + Portal state machine + un-defer config (idle/incoming/open/close, gateway default). Verify: default = step-in extract unchanged; set incoming=8,window=30 → ritual runs.
  2. Board mechanics (passive/force/decay) + stall-on-hit + board/incoming/open noise pulses + banners.
  3. Guards (assign + ring patrol + release).
  4. Safe-count parity + safeAway spawn bias.
  5. Rival multi-portal extract + intercept banner.
  6. PortalView Shapes visuals (escape vs descend) + PortalStyle config.
  7. 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.