PURPOSE

v4 hull stat editor for the Ship Playground. Renders both panels of the workbench: the instance-side (left) ship picker with sprite preview, rarity selector, and clickable star row, and the base-side (right) five-column stat grid where every stat has explicit ★1 and ★5 endpoints with linearly interpolated ★2-4 values. Edits live-patch the running mission via the bridge so the designer feels each change; the SAVE button persists ★1 and ★5 sidecars (plus pinned mid-star overrides) through the pushStats dev endpoint.

OWNS

  • React state for the working stat block: hullClass, starStats (Record<StarIdx, Record<string, number>> for stars 1-5), pinned (set of s<star>.<key> strings flagging explicit mid-star overrides).
  • React state for non-numeric ship-def fields: heatCurve, rotates, fixedAngleDeg, dragCurve, accelCurve, startingWeaponId.
  • Preview-star state pair: previewStar (rendered) plus previewStarRef (read inside patchStar without re-creating the callback on every star change).
  • Panel open/close state: hullOpen, statsOpen, sectionOpen (per stat section).
  • Stat schema: STAT_SECTIONS (Loadout, Survivability, Movement, Heat, Combat, Meta, Physics, Visual, Feel) and the flattened ALL_STAT_KEYS derived from it.
  • Star-column color palette STAR_COLORS (green/blue/purple/magenta/amber matching rarity tiers).
  • Curve label maps: DRAG_CURVE_LABELS, ACCEL_CURVE_LABELS, HEAT_CURVE_LABELS.
  • Engine-to-ShipDef field-name map ENGINE_TO_SHIPDEF used to translate combat-stat keys (hpMax, maxSpeed, thrust, heatBurnRate, …) to the sidecar field names (hp, speed, acceleration, heatBuildup, …) at PUSH time.
  • The QuintStatRow sub-component (five numeric inputs per stat plus a =★ match-★1 flatten button).
  • The pullAllStars, patchStar, applyStarStats, applyStarToMission, switchHull, and pushStats callbacks.
  • An effect that re-pulls stats and resets pins when the external shipId prop changes hull.

READS FROM

  • ../../data/shipsHULL_CLASSES, getShipDef, toShipCombatStats, RARITY_COLORS, SHIP_STATS_S1, SHIP_STATS_S5, and the HeatCurve, DragCurve, AccelCurve types.
  • ../../data/weaponsWEAPON_ORDER, WEAPON_MAP (starting-weapon dropdown).
  • ../../engine/rendering/ships-v4-loadergetShipV4SpritePath for the preview sprite.
  • ./PlaygroundSharedPanel, Section, PushButton, SaveStatusPanel, recordSaveStatus, the BTN_STYLE, LABEL_STYLE, VALUE_STYLE style constants, and the TabProps type (missionRef, shipId, restartMission, side).
  • ../../services/playgroundPushpushStats as pushStatsRequest.
  • Combat stats via toShipCombatStats(def) with fallback to the raw ShipDef for fields that only live there (weaponSlots, etc.) inside getStatsForStar.

PUSHES TO

  • missionRef.current?.patchShipStats(...):
    • Numeric-stat patches for the currently previewed star inside applyStarToMission and from inline edits via patchStar.
    • Single-field patches for heatCurve, rotates, fixedAngleDeg, dragCurve, accelCurve from the curve / toggle / slider buttons in the Feel and Heat sections.
  • pushStatsRequest({ target: 'ship', hull, stars }) — POSTs the full ★1 and ★5 stat blocks plus any pinned ★2-4 fields and non-numeric settings (heatCurve, rotates, fixedAngleDeg, dragCurve, startingWeapons) to the dev save endpoint.
  • recordSaveStatus({ label, result, clientTimestampMs }) — appends the SAVE outcome (ok or fail) into the shared SaveStatusPanel log.
  • fetch('/__dev/sprites/update-ship-rarity', { method: 'POST', body: { hullName, rarity } }) from both rarity pickers; on success calls window.location.reload() so the new rarity propagates through the module-load-time read in ships.ts.
  • restartMission?.(hull, 'common') inside switchHull, followed by a 100 ms timeout that re-applies ★1 stats to the new mission.
  • console.log of the save summary (★1-5 saved (N mid-star pins)) with the optional commit SHA.
  • alert(...) calls when a rarity update fails to ship a non-2xx response or throws.

DOES NOT

  • Does not own the canvas, mission lifecycle, input handling, HUD, or any rAF loop — those live on ShipPlaygroundScreen.
  • Does not write directly to disk or the data tables; all persistence goes through pushStatsRequest (stats sidecars) or the /__dev/sprites/update-ship-rarity endpoint (rarity map).
  • Does not re-pull from ships.ts after a successful SAVE. Vite HMR delivers the new module asynchronously; the in-memory starStats is treated as the authoritative post-save state to avoid clobbering the user’s just-saved edits.
  • Does not auto-flatten or auto-pin mid-star cells unless the designer edits them. ★2-4 default to lerp(★1, ★5, t) until the cell is touched.
  • Does not send unpinned ★2-4 fields at PUSH time. Only pinned mids are written so the file stays small and re-interpolation continues to work on unchanged stats.
  • Does not validate the hull dropdown selection against legality — every entry in HULL_CLASSES is offered, including the current hull (filtered out only in the base-side “Override all stats from…” picker).
  • Does not manage upgrade slots; the comment in STAT_SECTIONS notes that every ship has infinite upgrade slots now and the row has been removed.
  • Does not handle the right-panel-only “Override all stats from…” dropdown when side !== 'base'.

Signals

  • Stat-cell onChange (QuintStatRow numeric input) → patchStar(star, key, value):
    • Pins the cell when star is 2-4 and not already pinned (endpoints auto-pin via pushStats).
    • On endpoint edits (star === 1 or 5), re-derives non-pinned mid-stars by lerpVal(s1, s5, mid) so the preview matches what PUSH would write.
    • Calls applyStarToMission(live, newStats) whenever the edited or recomputed star is currently being previewed.
  • =★ button (QuintStatRow) → fires onChange(s, values[1]) for s ∈ {2,3,4,5}, which pins each mid via the normal patchStar path (flatten this stat).
  • Star buttons in the instance-side preview card → applyStarStats(s) sets previewStar and patches that star’s full stat block onto the live mission.
  • Heat / Accel / Drag curve buttons → setX(c) plus missionRef.current?.patchShipStats({ [field]: c }).
  • “Rotates?” toggle → updates state and patches { rotates } into the mission; when rotates === false the fixed-angle range slider appears and patches { fixedAngleDeg } on input.
  • Rarity dropdown change (both copies) → fetch('/__dev/sprites/update-ship-rarity', ...) then window.location.reload() (or alert on failure).
  • Hull dropdown change → switchHull(hull).
  • useEffect([shipId]) — when the external shipId differs from the local hullClass, re-pulls starStats, clears pinned, and refreshes all non-numeric ship-def fields from ★1.
  • “Override all stats from…” dropdown (base side only) → confirm(...), then pulls the source hull’s full 5-star block, pins every cell, copies all non-numeric fields, and patches the running mission.
  • SAVE button → pushStats()pushStatsRequest(...)recordSaveStatus(...).
  • All onKeyDown handlers call stopPropagation and stopImmediatePropagation to prevent input keys from leaking into the global keyboard handler on ShipPlaygroundScreen (which would interpret them as movement).

Entry points

  • Default export ShipsTab({ missionRef, shipId, restartMission, side }) — rendered by ShipPlaygroundScreen into both the left (side='instance') and right (side='base') panels. The initial hull is derived from shipId by stripping the optional _s<digit> star suffix.

Pattern notes

  • ★1 and ★5 are the canonical interpolation endpoints; ★2-4 are derived unless explicitly pinned. pinned is keyed as s<star>.<key> so the same set tracks all stats with O(1) lookup, and endpoints are treated as “always pinned” by the PUSH path without needing entries.
  • patchStar couples edit propagation and pin tracking in one callback: it both stores the edited value into starStats and re-interpolates the non-pinned mids when an endpoint changes, then live-patches the running mission only if the affected star is being previewed.
  • The instance side and the base side render two visually distinct UIs from the same component by branching on side. State is shared via the parent’s shipId prop and the internal useEffect reconciliation rather than via context.
  • previewStarRef mirrors previewStar so patchStar can read the latest live star without re-binding its useCallback whenever the star changes (avoiding stale closures and unnecessary re-creations).
  • Non-numeric ship-def fields (heatCurve, rotates, fixedAngleDeg, dragCurve, startingWeapons) are written to the ★1 sidecar (and heatCurve is duplicated onto ★5) because the interpolation layer ignores them; only ★1 needs to carry them at module-load time.
  • The engine combat-stat keys differ from the on-disk ShipDef field names (hpMaxhp, maxSpeedspeed, thrustacceleration, turnSpeedturnRate, heatBurnRateheatBuildup, heatCoolRateheatCooldown, damageReductionarmor, shieldMaxshield). pushStats runs every engine key through ENGINE_TO_SHIPDEF before sending so the sidecar uses ShipDef-native names.
  • The rarity picker is intentionally separate from the SAVE pipeline: it edits SHIPS_V4_RARITY.ts via a dedicated endpoint and triggers a full page reload because rarity is read at module-load time inside getShipDef.
  • getStatsForStar prefers toShipCombatStats(def) and falls back to def[key] for fields that exist only on the raw ShipDef (e.g. weaponSlots).
  • The 5-column row defaults parseFloat(e.target.value) || 0 — empty strings collapse to 0 rather than NaN.
  • The =★ flatten button writes through onChange rather than directly mutating state, which routes each cell through patchStar and pins ★2-5 by the normal mid-star auto-pin rule.
  • The Save Status Panel is rendered only on the base side and persists across HMR, so the last SAVE result (success or error string) remains visible after a hot reload.