PURPOSE

Playground tab for the Mod Grid. The left (instance) side is a 4×4 placement testbed for picking a rarity, placing mods, and pushing the current grid live to the running sandbox ship via EQUIP LIVE. The right (base) side is a base-stats editor for mod templates — edits to name, size, and stats push to the per-mod .ts source files. Uses the v5.64+ ModInstance { uid, templateId, rarity } shape with no credits and no shop.

OWNS

  • Local placement state for the 4×4 grid (placed: PlacedMod[]).
  • Selected template id for the palette (selectedTemplateId).
  • Selected rarity to place (selectedRarity: ModRarity, default 'common').
  • Selected mod id for the base stat editor (selectedEditMod).
  • A monotonic in-module uid counter and nextUid() for newly placed mod instances (e.g. pg_1).
  • Per-template editable draft (ModDraft { name, size: {w,h}, stats: Record<string,number> }) managed by useModPanel via useDraft.
  • tryPlace(col, row) — tap-to-place / tap-on-existing-cell to remove. Resolves hits by walking the template’s cells mask relative to the placed mod’s origin.
  • destroyAll() clears the placement grid.
  • equipLive() strips col/row from PlacedMod and calls the sandbox API.
  • Constants CELL_PX = 32, GRID_COLS = 4, GRID_ROWS = 4, and the EDITABLE_STAT_KEYS whitelist (hpMax, shieldMax, shieldRegenRate, damageReduction, maxSpeed, thrust, turnSpeed, weaponDamagePct, fireRatePct, magnetRange, luck).

READS FROM

  • ../../data/mod-templatesMOD_TEMPLATES, MOD_TEMPLATES_BY_ID, RARITY_ORDER, RARITY_COLORS, and the types ModTemplate, ModInstance, ModRarity.
  • ../../services/modGridServicecanPlace, computeOccupancy, and the PlacedMod type.
  • ./PlaygroundSharedPanel, BTN_STYLE, LABEL_STYLE, EditableTextRow, EditableNumberRow, PushButton, useDraft, and the TabProps type.
  • React useState, useMemo, useCallback.
  • A ModTemplate’s cells mask is read to determine which grid cells a placed mod actually occupies (used for hit-testing on remove).

PUSHES TO

  • pushStats({ target: 'mods', id, patch }) from ../../services/playgroundPush — writes the per-template draft (name, size, stats subset) back to the source .ts file for that mod template.
  • missionRef.current?.sandboxEquipMods(mods) — applies the current grid live to the running sandbox ship. mods is a ModInstance[] (uid, templateId, rarity) with grid coordinates stripped.

DOES NOT

  • Does not persist placed between mounts; placement state is local to the tab.
  • Does not query or modify shop, credits, inventory, or saved profile state.
  • Does not validate stats against a schema beyond filtering by EDITABLE_STAT_KEYS and what already exists in the template’s stats.
  • Does not enforce rarity rules on the grid (rarity is purely tag-on-instance).
  • Does not own grid placement math — canPlace and computeOccupancy live in modGridService.
  • Does not allocate uids that survive reload; uidCounter is module-scoped and resets when the bundle reloads.
  • Does not render anything on the right side except the base-stats editor; the right-side mod grid/palette referenced in the file header is rendered by the instance side.

Signals

  • Tapping an empty cell on the 4×4 grid: tryPlace(col, row) runs canPlace, and on success appends a PlacedMod { uid: nextUid(), templateId, rarity, col, row }.
  • Tapping a filled cell of an existing placement: removes that placement from placed.
  • Tapping a rendered placed mod tile: removes that placement (filter by uid).
  • Selecting a palette button: sets selectedTemplateId.
  • Selecting a rarity button: sets selectedRarity; rarity colors come from RARITY_COLORS.
  • EQUIP LIVE: builds ModInstance[] from placed and calls missionRef.current?.sandboxEquipMods(...).
  • DESTROY ALL: empties placed.
  • On the base side, selecting a mod button sets selectedEditMod and remounts ModPanelBody (keyed by id) so the draft resets cleanly.
  • Editing a field in ModPanelBody: updates draft via setDraft; isDirty from useDraft toggles the RESET button.
  • RESET: calls useDraft’s reset to restore original.
  • PushButton onClick: awaits pushStats({ target: 'mods', id, patch: draft }) and returns the boolean ok.

Entry points

  • Default export ModsTab({ missionRef, side = 'instance' }: TabProps) — the only export. Rendered by the playground host screen with side set to 'instance' (left) or any other value (right / base editor).
  • Internal: ModPanelBody({ t }) is the per-template stat-editor body; useModPanel(t) wraps useDraft + pushStats for one template.

Pattern notes

  • Two-sided tab driven by a single side prop — 'instance' returns the placement UI (MOD GRID + MOD PALETTE panels); any other value returns the EDIT MOD STATS panel.
  • Placement rendering uses an inline-grid for the 4×4 cell backdrop plus absolutely-positioned mod tiles (position: 'absolute' with left/top derived from m.col * (CELL_PX + 2) + 6). Tile size accounts for the 2px gap: tpl.size.w * CELL_PX + (tpl.size.w - 1) * 2.
  • Hit testing on remove uses each template’s cells 2D mask, not just its bounding box, so non-rectangular mods only react on their filled cells.
  • ModInstance is the canonical wire shape — grid coordinates (col, row) live on PlacedMod only and are stripped before calling the sandbox.
  • EDITABLE_STAT_KEYS is a typed as const whitelist; the panel further filters with k in original.stats so each mod only shows the stats it actually has.
  • Step size for EditableNumberRow adapts: 0.01 when the saved value’s magnitude is below 1, else 1.
  • ModPanelBody is keyed by selectedEditMod on the base side so switching templates fully resets the draft instead of carrying stale fields across.
  • useModPanel recomputes original via useMemo([t]) so swapping the template hands useDraft a fresh baseline.
  • nextUid() is module-scoped (uidCounter outside the component) — uids are unique within a session/bundle but not stable across reloads. Acceptable because placement is ephemeral.
  • Panel is rendered with open={true} and a no-op onToggle, i.e. always-open on this tab.
  • pushStats errors are swallowed: the push callback returns false on throw rather than surfacing the error.