PURPOSE

Zustand store backing the account-wide Mod Grid metagame system. Maintains the player’s mod inventory, the 4x4 grid of equipped mods, and credit-purchased cell unlocks. Implements merge-to-upgrade economy: three inventory copies sharing (templateId, rarity) fuse into one copy at the next rarity tier. Equipped mods never participate in merges. Provides placement validation against shape footprints, rotations, and unlocked cells. Persists snapshots to Supabase via debounced RPC.

OWNS

  • MOD_GRID_SCHEMA_VERSION constant (currently 3) used to invalidate incompatible persisted snapshots.
  • ModGridSnapshot shape: schemaVersion, inventory (unplaced ModInstance[]), equipped (record of uid to EquippedMod), purchasedCells (string keys of credit-bought cells).
  • EquippedMod interface: templateId, col, row, rotation (0..3), rarity driving run-assemble multipliers.
  • useModGridStore Zustand store: starter snapshot is empty inventory, empty equipped, empty purchasedCells.
  • uidCounter module-local incrementing counter; nextUid() generates ids of the form m<timestamp36><counter36>.
  • _persistTimer module-local debounce handle (300ms) for Supabase writes.
  • __resetModGridUidCounter() testing helper.

READS FROM

  • MOD_TEMPLATES_BY_ID from ../data/mods for template lookup during validation, placement, and load-time pruning.
  • _types exports: ModTemplate, ModInstance, ModRarity, DROP_RARITY, nextRarity, resolveShape.
  • ../data/mods/grid-unlock: cellKey, cellCost, isFreeCell, nextPurchasableCell define cell pricing and unlock sequencing.
  • useWalletStore for spendCredits during cell purchase.
  • Dynamic import of @metagame/services/supabase (invokeRpc) during persistModGrid.

PUSHES TO

  • Supabase RPC save_mod_grid with payload { schemaVersion, inventory, equipped_v2, purchasedCells, equipped }. The legacy equipped field is a uid {col, row} map kept for older servers; equipped_v2 is the full record current clients read.
  • useWalletStore.getState().spendCredits(cost) during purchaseCell when unlocking non-free cells.
  • console.warn on RPC failure (fire-and-forget; not rethrown).

DOES NOT

  • Does not gate cells by captain level. Cell unlocks are credit-purchased through the wallet.
  • Does not store free cells (the upper-left 2x2) in purchasedCells; freeness is computed via isFreeCell.
  • Does not count equipped mods toward merges. Only inventory entries are eligible.
  • Does not allow merging at legendary rarity (nextRarity returns null).
  • Does not support out-of-order cell purchases. Only the cell returned by nextPurchasableCell may be bought.
  • Does not synchronously block on Supabase writes. Persistence is debounced and dispatched asynchronously.
  • Does not preserve snapshots whose schemaVersion mismatches MOD_GRID_SCHEMA_VERSION; mismatched snapshots reset to starter.
  • Does not generate uids that collide between inventory and equipped sets (transitions move uids between collections rather than duplicating).
  • Does not validate rotation inputs as a tagged type beyond modulo normalization in setRotation.

Signals

  • equipFromInventory(uid, col, row, rotation) returns false if the uid is missing, its template is unknown, or canPlace rejects the candidate.
  • moveEquipped(uid, col, row) returns false if the uid is not equipped or the new position fails canPlace (ignoring the moving uid).
  • setRotation(uid, rotation) normalizes input to 0..3 and returns false when the rotated footprint no longer fits.
  • rotateEquipped(uid) advances rotation by +1 (90 degrees CW) via setRotation.
  • unequip(uid) returns false when the uid is not equipped; otherwise re-adds an inventory entry carrying the original rarity.
  • merge(uids) returns the new uid on success, null when any uid is missing, templateIds differ, rarities differ, or the rarity cannot advance.
  • purchaseCell(col, row) returns false on out-of-bounds, already-unlocked, non-sequential, zero-cost, or insufficient-credit conditions.
  • canPlace throws if templateId is unknown; otherwise returns boolean for bounds, unlocked-cell coverage, and overlap against other equipped mods.
  • addDrop throws on unknown templateId; otherwise returns the generated uid.

Entry points

  • useModGridStore — Zustand hook used by metagame UI (mod grid screen, inventory pane, merge interactions).
  • persistModGrid(snap) — exported debounced writer for callers that want to persist a snapshot directly without going through the store action.
  • MOD_GRID_SCHEMA_VERSION — exported constant for migration and validation checks elsewhere.
  • EquippedMod, ModGridSnapshot, ModGridState — exported types consumed by UI components and load pipelines.
  • loadFromSnapshot(snap) — rehydrates the store from a partial snapshot, pruning unknown templates and rejecting mismatched schema versions.
  • resetToStarter() — restores the empty starter snapshot.
  • persist() — action wrapper that calls persistModGrid with the current state.
  • __resetModGridUidCounter() — test-only counter reset.

Pattern notes

  • Inventory and equipped uids form disjoint pools; transitions (equipFromInventory, unequip) move the uid between collections rather than copying.
  • Inventory grouping (inventoryStacks) orders by templateId ascending and rarity in the explicit sequence common, uncommon, rare, epic, legendary.
  • canPlace performs two passes: a bounds-and-unlocked-cell sweep over the filled footprint cells, then an overlap sweep against every other equipped mod’s resolved shape (skipping ignoreUid to support in-place rotation and moves).
  • Cell unlock pricing tiers come from grid-unlock.ts: the 2x2 corner is free, the cells completing the 3x3 cost 1,000 credits, and the seven outer-rim cells cost 10,000 credits. Sequencing is enforced via nextPurchasableCell.
  • persistModGrid debounces at 300ms and writes both the modern equipped_v2 map and a legacy equipped (uid col/row only) map in the same payload for backward compatibility with older servers.
  • loadFromSnapshot prunes both inventory entries and equipped entries whose templateId no longer exists in MOD_TEMPLATES_BY_ID, preventing crashes after template removals.
  • Rarity advancement is delegated to nextRarity; legendary rarity intentionally has no successor and merges stop there.
  • The persistence path is fire-and-forget: failures are logged via console.warn and never propagated to callers.
  • uidCounter is wrapped to unsigned 32-bit (>>> 0) to bound growth across long sessions; the timestamp prefix preserves uniqueness across resets.