PURPOSE

Imperative DOM-manipulation engine that drives the S.I.G. Storefront V32 card-pull reveal sequence. Owns the 3D card spawn animation, per-card reveal cadence, the legendary “supernova” deep-dive stage, the rainbow auto-continue bar, and the z-arc outro that flicks pulled cards into the collection tab. React manages the surrounding shop layout; this engine is the overlay layer. The engine is a direct port of vanilla JS from test.html and intentionally avoids React because the sequence is imperative and DOM-heavy.

OWNS

  • Module-level engine state: currentPullResults, currentRevealIdx, totalCollected, isBonusPullActive, engineRoot, callbacks, allRevealed, activePopover, pausedBarRemaining, barStartedAt.
  • All reveal timers/intervals: autoRevealTimer, solarInterval, legendaryTimeoutId, autoContinueTimerId, preBarTimerId.
  • Card grid DOM construction inside #pull-grid — wrappers, faces, fronts, backs, badges, glass-style CSS custom properties, NEW tag.
  • Bloom-cell layer (#pull-bloom) mirroring the card grid for blurred glow merging.
  • Perimeter projectile VFX (spawnPerimeterProjectiles), solar inflow particles (startSolarInflow), chest burst particles (spawnChestBurstParticles), and bonus-bar flare particles (spawnFlareParticle).
  • Legendary deep-dive overlay swap into #pull-solo-stage / #solo-container, plus the whiteout flash, screen shake, and persona-timer fill.
  • Bonus chest intro phases (hover → flash → crack → whiteout) on #bonus-chest-intro / #bonus-chest-sprite.
  • Auto-continue rainbow bar (#pull-auto-continue / #auto-continue-fill) with pause/resume against popover state.
  • Stat popover overlays appended to revealed cards (.card-stat-popover).
  • The XP-gain card variant keyframes injected once into document.head as #xpcard-keyframes.

READS FROM

  • @starship-survivors/data/shipsShipRarity string type.
  • @starship-survivors/engine/rendering/ships-v4-loadergetShipV4SpritePath(hullClass) for card-front ship images.
  • @starship-survivors/data/ship-progressionSTAR_XP_THRESHOLDS for the duplicate-pull XP bar math.
  • @starship-survivors/engine/rendering/medical-canvas-paletteMedicalPalette.magenta and MedicalPalette.accentBright for legendary supernova, perimeter projectiles, and chest burst tints.
  • DOM elements queried by ID inside the scoped engineRoot: pull-grid, pull-bloom, pull-result-stage, pull-solo-stage, solo-container, overlay-spree, shop-spree, whiteout, main-nav, nav-home, nav-shop, nav-collection, collection-badge, persona-ui-container, persona-timer-fill, bonus-chest-intro, bonus-chest-sprite, ui-blocker, pull-auto-continue, auto-continue-fill, plus dynamic ids pull-card-${i}, bloom-cell-${i}, and overlay-spree milestone nodes .m-${tier}.
  • The EngineCallbacks interface — getBonusPoints, shiftPendingBonus, getPendingBonusCount (read-only queries into the caller).

PUSHES TO

  • EngineCallbacks.onCollectionTick() once per card during the z-arc outro, after the card lands on the collection tab.
  • EngineCallbacks.onPullComplete() after the full sequence (including any chained bonus pulls) finishes, or immediately for mission-reward mode when no collection tab exists.
  • EngineCallbacks.onBonusAdvance() before every non-bonus card reveal (both standard grid reveals and legendary triggers) to advance the bonus bar by one point.
  • document.body for transient particles, in-flight outro cards, and the whiteout overlay (cards are temporarily reparented to body so the z-arc animation isn’t clipped).
  • The collection tab DOM — #collection-badge text and #nav-collection counter-pulse class — directly during the outro.
  • Inline style mutations on overlay-spree, main-nav, nav-home, nav-shop, ui-blocker, pull-solo-stage, pull-result-stage, persona-ui-container, persona-timer-fill, whiteout.

DOES NOT

  • Does not import or read any Zustand store, save state, or inventory. The caller (ShopScreen.tsx / pullService.ts) computes results and passes them in via PullCard[], including the v4 XP-gain snapshot (oldXp, newXp, oldStar, newStar) captured before inventory mutation.
  • Does not use CollectibleCard.tsx — card HTML is hand-generated imperatively to match the V32 liquid-glass style.
  • Does not handle navigation, route changes, or post-pull modals beyond firing onPullComplete.
  • Does not produce React elements. No JSX, no hooks, no virtual DOM.
  • Does not validate PullCard payloads — assumes the caller supplied a legal rarity (1–5) and matching XP fields when unlocked === false.
  • Does not own bonus-tier logic — shiftPendingBonus / getPendingBonusCount / getBonusPoints are queried; the caller decides what rewards to enqueue.
  • Does not progress the bonus bar during bonus pulls — isBonusPullActive suppresses onBonusAdvance calls.
  • Does not play audio; sound is left to the caller / other systems.
  • Does not draw via Canvas/WebGL. Everything is DOM + CSS animations + Web Animations API.

Signals

  • EngineCallbacks.onCollectionTick: () => void — one per card collected.
  • EngineCallbacks.onPullComplete: () => void — sequence finished, control returns to React.
  • EngineCallbacks.onBonusAdvance: () => void — advance bonus meter by 1.
  • EngineCallbacks.getBonusPoints: () => number — engine reads current value for flare placement.
  • EngineCallbacks.shiftPendingBonus: () => number | null — engine asks for the next queued bonus rarity after a main pull completes.
  • EngineCallbacks.getPendingBonusCount: () => number — engine reads pending count.
  • DOM click event on #pull-result-stage — overlay tap → dismiss popover or skip to outro.
  • DOM click events on each #pull-card-${i} — toggle stat popover.

Entry points

Public exports:

  • initPullEngine(root: HTMLElement, cbs: EngineCallbacks): void — mount the engine against a scoped .sig-shop root and wire callbacks. Resets totalCollected and injects XP-card keyframes.
  • startPull(cards: PullCard[]): void — kick off the main reveal sequence with the supplied card list (1–15 cards expected; layout assumes a 5-column grid up to 3 rows).
  • closeLegendary(): void — manually close the legendary deep-dive stage (for an explicit CONTINUE button).
  • spawnBonusFlare(bonusPoints: number): void — spawn a flare particle on the bonus bar at the given progress percentage.
  • flashNode(tier: number): void — flash the milestone node for the given tier on #overlay-spree .m-${tier}.
  • destroyPullEngine(): void — clear all timers/intervals, drop the root and callbacks. Called on shop unmount.

Public type:

  • PullCard{ rarity: 1..5; hullClass?: string; customFrontHtml?: string; isNew?: boolean; unlocked?: boolean; oldXp?: number; newXp?: number; oldStar?: number; newStar?: number }. customFrontHtml is used by mission rewards. unlocked === false selects the duplicate-pull XP-gain card variant.

Helper export:

  • rarityToNum(rarity: string): number — string → numeric rarity lookup.

Pattern notes

  • Scoped queries via engineRoot. All DOM lookups go through q(selector) and qId(id) which scope to the caller-provided root, so the engine cannot accidentally collide with other shop instances or stray globals. Transient particles, in-flight outro cards, and the whiteout overlay are the only exceptions — they attach to document.body to escape clipping.
  • Front-HTML selection priority in startPullSequence: customFrontHtml (mission rewards) > unlocked === false (XP-gain variant) > default ship-unlock card. The engine has no store dependency because the caller already derived the snapshot.
  • Rarity is numeric 1..5 internally. String rarities arrive on the ShipRarity boundary and are mapped via RARITY_STRING_TO_NUM / NUM_TO_RARITY. Five parallel constant maps drive visuals: RARITY_GLOW_COLORS, RARITY_EDGE_COLORS, RARITY_INNER_GLOW, RARITY_OUTER_GLOW, RARITY_BORDER_COLOR, RARITY_BADGE_COLOR, RARITY_BADGE_LABEL (D/C/B/A/S), RARITY_SHIP_OUTLINE. Adding a rarity tier means updating every map.
  • CSS-custom-property handoff. cardFrontStyle(rarityNum) emits a single inline style string with --card-edge-color, --card-glow-inner, --card-glow-outer, --card-border-color. Card CSS in the shop stylesheet consumes those tokens — JS only sets vars.
  • Card cap at ★5. The XP-gain variant uses MAX_STAR = 5; at cap it renders a full bar with “MAX ★5” instead of advancing toward a non-existent next threshold.
  • Reveal pacing. REVEAL_DELAY_MS = 400 between non-legendary card reveals. After the last card, a PRE_BAR_PAUSE_MS = 2500 admire window then the AUTO_CONTINUE_MS = 2500 rainbow bar. Popovers pause both timers; resume restarts the pre-bar phase or finishes the remainder of the bar.
  • Legendary path is asymmetric. processNextCard short-circuits to triggerLegendarySequence for rarity 5: shrinks the spree bar, raises the wrapper to z-index: 9999, flashes white over 450ms, swaps content into #pull-solo-stage, fires two perimeter bursts 300ms apart, starts the solar inflow particle stream, runs a 2.5s persona timer, then closeLegendaryStage after 2500ms continues the queue.
  • Bonus chain. When triggerAutoOutro finishes, checkPendingBonusRewards calls callbacks.shiftPendingBonus(). A non-null rarity triggers playBonusChestIntro (1.0s hover → 0.3s flash → 0.6s crack with burst → whiteout → startBonusPull for a 1-card forced pull). isBonusPullActive = true suppresses onBonusAdvance during bonus pulls.
  • Mission-reward fallback. When #nav-collection / #collection-badge are absent (RevealScreen mode), triggerAutoOutro skips the z-arc flight, fades the overlays, and fires onPullComplete immediately.
  • Bloom layer separation. A parallel #pull-bloom grid sits behind the real cards. Activating a bloom-cell-${i} background to the rarity glow color and applying CSS filter: blur() to the layer produces merged whole-layer bloom without flattening the real grid’s transform-style: preserve-3d.
  • Animation strategy. Most motion uses the Web Animations API (element.animate(...)) with explicit cleanup timeouts. CSS transitions handle simple opacity/transform fades (whiteout, persona fill, auto-continue bar) so they can be paused mid-flight by re-reading and re-applying computed.transform.
  • Idempotent keyframe injection. ensureXpCardKeyframes guards against double-insertion by checking for #xpcard-keyframes before appending the <style> block.
  • Cleanup is centralized. destroyPullEngine cancels every named timer/interval, drops engineRoot and callbacks, and resets activePopover / allRevealed. The caller must invoke it on shop unmount to avoid leaks.
  • Legendary fixed to hullClass 'Bulwark'. triggerLegendarySequence calls cardFrontHtml(5) without a hull class, so the giant legendary deep-dive card always shows the Bulwark sprite. (Caller passes the actual hull only in the small grid card.)