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 byuseModPanelviauseDraft. tryPlace(col, row)— tap-to-place / tap-on-existing-cell to remove. Resolves hits by walking the template’scellsmask relative to the placed mod’s origin.destroyAll()clears the placement grid.equipLive()stripscol/rowfromPlacedModand calls the sandbox API.- Constants
CELL_PX = 32,GRID_COLS = 4,GRID_ROWS = 4, and theEDITABLE_STAT_KEYSwhitelist (hpMax,shieldMax,shieldRegenRate,damageReduction,maxSpeed,thrust,turnSpeed,weaponDamagePct,fireRatePct,magnetRange,luck).
READS FROM
../../data/mod-templates—MOD_TEMPLATES,MOD_TEMPLATES_BY_ID,RARITY_ORDER,RARITY_COLORS, and the typesModTemplate,ModInstance,ModRarity.../../services/modGridService—canPlace,computeOccupancy, and thePlacedModtype../PlaygroundShared—Panel,BTN_STYLE,LABEL_STYLE,EditableTextRow,EditableNumberRow,PushButton,useDraft, and theTabPropstype.- React
useState,useMemo,useCallback. - A
ModTemplate’scellsmask 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.tsfile for that mod template.missionRef.current?.sandboxEquipMods(mods)— applies the current grid live to the running sandbox ship.modsis aModInstance[](uid, templateId, rarity) with grid coordinates stripped.
DOES NOT
- Does not persist
placedbetween 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_KEYSand what already exists in the template’sstats. - Does not enforce rarity rules on the grid (rarity is purely tag-on-instance).
- Does not own grid placement math —
canPlaceandcomputeOccupancylive inmodGridService. - Does not allocate uids that survive reload;
uidCounteris 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)runscanPlace, and on success appends aPlacedMod{ 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 fromRARITY_COLORS. EQUIP LIVE: buildsModInstance[]fromplacedand callsmissionRef.current?.sandboxEquipMods(...).DESTROY ALL: emptiesplaced.- On the base side, selecting a mod button sets
selectedEditModand remountsModPanelBody(keyed by id) so the draft resets cleanly. - Editing a field in
ModPanelBody: updatesdraftviasetDraft;isDirtyfromuseDrafttoggles theRESETbutton. RESET: callsuseDraft’sresetto restoreoriginal.PushButtononClick: awaitspushStats({ target: 'mods', id, patch: draft })and returns the booleanok.
Entry points
- Default export
ModsTab({ missionRef, side = 'instance' }: TabProps)— the only export. Rendered by the playground host screen withsideset to'instance'(left) or any other value (right / base editor). - Internal:
ModPanelBody({ t })is the per-template stat-editor body;useModPanel(t)wrapsuseDraft+pushStatsfor one template.
Pattern notes
- Two-sided tab driven by a single
sideprop —'instance'returns the placement UI (MOD GRID+MOD PALETTEpanels); any other value returns theEDIT MOD STATSpanel. - Placement rendering uses an
inline-gridfor the 4×4 cell backdrop plus absolutely-positioned mod tiles (position: 'absolute'withleft/topderived fromm.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
cells2D mask, not just its bounding box, so non-rectangular mods only react on their filled cells. ModInstanceis the canonical wire shape — grid coordinates (col,row) live onPlacedModonly and are stripped before calling the sandbox.EDITABLE_STAT_KEYSis a typedas constwhitelist; the panel further filters withk in original.statsso each mod only shows the stats it actually has.- Step size for
EditableNumberRowadapts:0.01when the saved value’s magnitude is below 1, else1. ModPanelBodyis keyed byselectedEditModon the base side so switching templates fully resets the draft instead of carrying stale fields across.useModPanelrecomputesoriginalviauseMemo([t])so swapping the template handsuseDrafta fresh baseline.nextUid()is module-scoped (uidCounteroutside the component) — uids are unique within a session/bundle but not stable across reloads. Acceptable because placement is ephemeral.Panelis rendered withopen={true}and a no-oponToggle, i.e. always-open on this tab.pushStatserrors are swallowed: thepushcallback returnsfalseon throw rather than surfacing the error.