PURPOSE

Playground tab that exposes both a per-run loadout swap (LEFT/instance) and an authoring surface for weapon base stats and scaling curves (RIGHT/base). The side prop selects which half renders; both render inside a collapsible Panel. Instance side hot-swaps the live ship loadout for the current run only. Base side edits the canonical WeaponCoreSpec values and pushes them through pushStats to rewrite the source data tables.

OWNS

  • Local equip-slot state: a four-entry SlotState[] (each { weaponId, level }), seeded with rifle@1 and three EMPTY_SLOTs.
  • Per-side open/closed state (instanceOpen, baseOpen).
  • Picker state on the base side: selectedWeapon (defaults to WEAPON_ORDER[0]) and previewLevel (1..20).
  • Pending override map baseOverrides: Record<weaponId, Record<dottedKey, number>> capturing unsaved slider edits keyed by "damage.base", "fireRate.scaling", or a flat legendary key.
  • The two top-level callbacks: applyWeapons (instance APPLY) and pushWeaponStats (base PUSH).
  • Inline WeaponSlotEditor subcomponent and the helpers weaponStatValue / weaponStatScaling.
  • Static authoring metadata: SCALING_CURVES, BASE_STATS, LEGENDARY_KNOBS.

READS FROM

  • ../../data/weaponsWEAPON_MAP, WEAPON_ORDER, getWeaponStatAtLevel, resolveWeaponRarity, and the types WeaponCoreSpec / ScalingCurve.
  • ./PlaygroundSharedPanel, StatRow, PushButton, BTN_STYLE, LABEL_STYLE, and the shared TabProps type.
  • WEAPON_MAP[selectedWeapon] for preview values, family/curve display, damage tags, behavior, legendary flags, merge parents, and cadence pattern (read via (w as any) to reach optional fields).
  • getWeaponStatAtLevel for the previewed damage / fire rate / range at previewLevel.

PUSHES TO

  • missionRef.current?.setWeapons(weapons) — bridge call that swaps the live loadout. Only fires when weapons.length > 0; empty slots are filtered out.
  • pushStats({ target: 'weapon', id: selectedWeapon, patch }) from ../../services/playgroundPush, where patch is the flat override map translated into { path: string[], value } entries (dotted keys split into nested paths for the source rewriter).
  • On a successful push (r.ok), clears baseOverrides[selectedWeapon] for that weapon.

DOES NOT

  • Does not persist instance state across renders, sessions, or runs — slot edits live in component state only.
  • Does not mutate WEAPON_MAP, WEAPON_ORDER, or WeaponCoreSpec records directly; all writes go through pushStats.
  • Does not write to the player’s saved profile, account, or any Zustand store.
  • Does not bake the previewed level into the live loadout — previewLevel is base-side only and never flows into APPLY.
  • Does not validate weapon IDs beyond the <select> options sourced from WEAPON_ORDER.
  • Does not interact with the engine loop, rendering, or input pipelines outside the missionRef bridge.
  • Does not edit the active scaling curve — the curve buttons on the base side render the current selection but have no onClick wired up.
  • Does not surface the LEGENDARY_KNOBS or BASE_STATS rows for fields the selected weapon does not define; both lists filter to the actually-present keys.
  • Does not handle async errors verbosely — pushWeaponStats swallows thrown errors and returns false.

Signals

  • APPLY click (instance side, applyWeapons): collects non-empty slots into { id, level }[] and calls missionRef.current?.setWeapons(...). The click handler also calls e.stopPropagation() so the Panel header does not toggle.
  • PUSH click (base side, via <PushButton onClick={pushWeaponStats} />): translates pending overrides for selectedWeapon into nested-path patches and awaits pushStats; clears overrides for that weapon on success.
  • <select> and <input type="number"> onChange handlers update slot weapon/level; level is clamped [1, 20] and NaN is coerced to 1.
  • All key events on weapon-picker <select>, slot <select>, and slot level <input> are explicitly stopped with e.stopPropagation() plus e.nativeEvent.stopImmediatePropagation() so playground hotkeys do not steal them.
  • Base-stat StatRow onChange writes into baseOverrides[selectedWeapon][{sub}]; onReset deletes that key.
  • Legendary-knob StatRow onChange writes flat baseOverrides[selectedWeapon][key]; onReset deletes that key.
  • Preview-level range input updates previewLevel, which re-computes the displayed damage / fire rate / range via getWeaponStatAtLevel.

Entry points

  • Default export WeaponsTab(props: TabProps) — rendered twice by the playground shell, once with side="instance" and once with side="base" (default "instance" per the destructured default).
  • WeaponSlotEditor({ slot, index, onChange }) — internal subcomponent used by the instance branch only.

Pattern notes

  • Single-component, dual-mode rendering keyed on side: the instance branch returns early; the base branch is the default return.
  • Stats are read uniformly via the { base, scaling } object shape; legendary knobs are read as flat number fields, and BASE_STATS / LEGENDARY_KNOBS rows are filtered to only those a weapon actually defines.
  • Dotted override keys ("damage.base", "fireRate.scaling") are flattened during editing and split into nested paths only at push time, matching the source rewriter’s expected patch shape.
  • Optional WeaponCoreSpec fields (scalingCurve, damageTag, secondaryDamageTag, behavior, isLegendary, mergeParents, cadencePattern, cadenceStepSec, legendary knobs) are accessed through (w as any) to avoid widening the public type.
  • Number inputs use step per slider, clamp client-side, and the preview values display rounded damage / range and a toFixed(2) fire rate.
  • The scaling-curve button row is presentational only — selection state visually reflects (w as any).scalingCurve ?? 'linear', but the buttons have no click handler.
  • Panel extra slot is used for the per-side action button (APPLY on instance, <PushButton> on base) and click events stop propagation so the panel does not collapse.