PURPOSE

Playground tab that exposes the run-level modifier (upgrade) system in two modes selected by the side prop. The instance (LEFT) side renders 14 modifier rows with +/- buttons to grant in-run upgrade levels plus RESET ALL / MAX ALL shortcuts. The base (RIGHT) side renders a modifier picker grid above an editable panel for the selected modifier’s metadata (name, description, icon) and per-effect base/k numeric fields, with a PUSH button that writes back to modifiers.ts.

OWNS

  • React component UpgradesTab (default export) implementing the TabProps interface from PlaygroundShared.
  • Local helper component ModifierPanelBody that hosts the edit form for one ModifierTypeDef.
  • Local hook useModifierPanel that wires draft state, dirty tracking, reset, and the async push callback for a modifier.
  • Local helpers toDraft (clones a ModifierTypeDef into an editable ModifierDraft) and readCurrentLevels (snapshots game.upgradeCounts for all known modifiers).
  • Local constants MIN_LEVEL (0) and MAX_LEVEL (20) bounding the per-modifier grant range.
  • Local UI state: grantOpen, baseOpen (collapsible panel toggles), a tick counter used to force re-renders after mutating game.upgradeCounts, and selectedMod for the base-side picker.

READS FROM

  • MODIFIER_TYPES and MODIFIER_TYPE_MAP from ../../data/modifiers — the canonical modifier catalog and id lookup.
  • Types ModifierTypeDef and ModifierEffect from ../../data/modifiers.
  • game.upgradeCounts from ../../engine/core/state — the live in-run upgrade level dictionary.
  • Shared playground primitives from ./PlaygroundShared: Panel, BTN_STYLE, LABEL_STYLE, EditableTextRow, EditableNumberRow, PushButton, useDraft, plus the TabProps type.

PUSHES TO

  • Mutates game.upgradeCounts[modId] directly to grant, reset, or max upgrade levels for the current run. Initializes game.upgradeCounts to {} if absent before writing.
  • Invokes pushStats from ../../services/playgroundPush with target: 'modifiers', the modifier id, and a patch containing { name, description, icon, effects } to persist edits to modifiers.ts.

DOES NOT

  • Does not modify the modifier catalog (MODIFIER_TYPES) in memory; only the source file via pushStats.
  • Does not recompute or apply modifier effects on the ship — it only sets the level counts; effect application lives elsewhere in the upgrade pipeline.
  • Does not touch the missionRef (received as _missionRef) or restart the mission.
  • Does not persist the granted in-run levels across reloads; upgradeCounts is run-scoped state.
  • Does not validate effect stat or mode strings; only base and k are editable numerically, and stat/mode are shown read-only.
  • Does not clamp pushed base/k values; clamping only applies to per-modifier level input via MIN_LEVEL/MAX_LEVEL.

Signals

  • useState tick (setTick) drives a manual rerender callback after each mutation of game.upgradeCounts, since the store is mutated outside React.
  • useDraft (from PlaygroundShared) supplies draft, setDraft, reset, and isDirty for the base-edit form; a RESET button is conditionally rendered when isDirty is true.
  • PushButton receives the async push callback; success reflects the boolean returned by pushStats(...).ok. Exceptions are swallowed and reported as false.
  • useMemo recomputes the original draft snapshot whenever the selected ModifierTypeDef instance changes.
  • ModifierPanelBody is keyed by m.id so switching the selected modifier remounts the panel and resets draft state.

Entry points

  • Default export UpgradesTab is registered by the playground shell and rendered with side: 'instance' for the left column and side: 'base' for the right column.
  • Selecting a modifier button in the base-side picker updates selectedMod, which drives the MODIFIER_TYPE_MAP[selectedMod] lookup and the rendered ModifierPanelBody.

Pattern notes

  • Split-panel pattern: a single component returns different JSX depending on the side prop, sharing state declarations between branches but only one branch’s UI at a time.
  • Direct mutation of game.upgradeCounts with a tick-bump rerender is the established playground convention for editing engine state from React.
  • The instance branch reads levels via readCurrentLevels() each render so writes to game.upgradeCounts made elsewhere also reflect.
  • Editable rows in the base panel compare value against savedValue to highlight dirtiness; the dirty state is owned by useDraft, not by row-level diffing.
  • The push payload is restricted to the four editable fields (name, description, icon, effects); id and any other ModifierTypeDef fields are not sent.
  • + and - buttons are disabled at the bounds (level <= MIN_LEVEL / level >= MAX_LEVEL) and dim their opacity for visual feedback.
  • Modifier-row icons render as text glyphs prefixed to the name; no image assets are loaded here.