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 ofs<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) pluspreviewStarRef(read insidepatchStarwithout 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 flattenedALL_STAT_KEYSderived 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_SHIPDEFused to translate combat-stat keys (hpMax,maxSpeed,thrust,heatBurnRate, …) to the sidecar field names (hp,speed,acceleration,heatBuildup, …) at PUSH time. - The
QuintStatRowsub-component (five numeric inputs per stat plus a=★match-★1 flatten button). - The
pullAllStars,patchStar,applyStarStats,applyStarToMission,switchHull, andpushStatscallbacks. - An effect that re-pulls stats and resets pins when the external
shipIdprop changes hull.
READS FROM
../../data/ships—HULL_CLASSES,getShipDef,toShipCombatStats,RARITY_COLORS,SHIP_STATS_S1,SHIP_STATS_S5, and theHeatCurve,DragCurve,AccelCurvetypes.../../data/weapons—WEAPON_ORDER,WEAPON_MAP(starting-weapon dropdown).../../engine/rendering/ships-v4-loader—getShipV4SpritePathfor the preview sprite../PlaygroundShared—Panel,Section,PushButton,SaveStatusPanel,recordSaveStatus, theBTN_STYLE,LABEL_STYLE,VALUE_STYLEstyle constants, and theTabPropstype (missionRef,shipId,restartMission,side).../../services/playgroundPush—pushStats as pushStatsRequest.- Combat stats via
toShipCombatStats(def)with fallback to the rawShipDeffor fields that only live there (weaponSlots, etc.) insidegetStatsForStar.
PUSHES TO
missionRef.current?.patchShipStats(...):- Numeric-stat patches for the currently previewed star inside
applyStarToMissionand from inline edits viapatchStar. - Single-field patches for
heatCurve,rotates,fixedAngleDeg,dragCurve,accelCurvefrom the curve / toggle / slider buttons in the Feel and Heat sections.
- Numeric-stat patches for the currently previewed star inside
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 sharedSaveStatusPanellog.fetch('/__dev/sprites/update-ship-rarity', { method: 'POST', body: { hullName, rarity } })from both rarity pickers; on success callswindow.location.reload()so the new rarity propagates through the module-load-time read inships.ts.restartMission?.(hull, 'common')insideswitchHull, followed by a 100 ms timeout that re-applies ★1 stats to the new mission.console.logof 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-rarityendpoint (rarity map). - Does not re-pull from
ships.tsafter a successful SAVE. Vite HMR delivers the new module asynchronously; the in-memorystarStatsis 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_CLASSESis 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_SECTIONSnotes 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(QuintStatRownumeric input) →patchStar(star, key, value):- Pins the cell when
staris 2-4 and not already pinned (endpoints auto-pin viapushStats). - On endpoint edits (
star === 1or5), re-derives non-pinned mid-stars bylerpVal(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.
- Pins the cell when
=★button (QuintStatRow) → firesonChange(s, values[1])fors ∈ {2,3,4,5}, which pins each mid via the normalpatchStarpath (flatten this stat).- Star buttons in the instance-side preview card →
applyStarStats(s)setspreviewStarand patches that star’s full stat block onto the live mission. - Heat / Accel / Drag curve buttons →
setX(c)plusmissionRef.current?.patchShipStats({ [field]: c }). - “Rotates?” toggle → updates state and patches
{ rotates }into the mission; whenrotates === falsethe fixed-angle range slider appears and patches{ fixedAngleDeg }on input. - Rarity dropdown change (both copies) →
fetch('/__dev/sprites/update-ship-rarity', ...)thenwindow.location.reload()(oralerton failure). - Hull dropdown change →
switchHull(hull). useEffect([shipId])— when the externalshipIddiffers from the localhullClass, re-pullsstarStats, clearspinned, 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
onKeyDownhandlers callstopPropagationandstopImmediatePropagationto prevent input keys from leaking into the global keyboard handler onShipPlaygroundScreen(which would interpret them as movement).
Entry points
- Default export
ShipsTab({ missionRef, shipId, restartMission, side })— rendered byShipPlaygroundScreeninto both the left (side='instance') and right (side='base') panels. The initial hull is derived fromshipIdby stripping the optional_s<digit>star suffix.
Pattern notes
- ★1 and ★5 are the canonical interpolation endpoints; ★2-4 are derived unless explicitly pinned.
pinnedis keyed ass<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. patchStarcouples edit propagation and pin tracking in one callback: it both stores the edited value intostarStatsand 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’sshipIdprop and the internaluseEffectreconciliation rather than via context. previewStarRefmirrorspreviewStarsopatchStarcan read the latest live star without re-binding itsuseCallbackwhenever 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 (andheatCurveis 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
ShipDeffield names (hpMax→hp,maxSpeed→speed,thrust→acceleration,turnSpeed→turnRate,heatBurnRate→heatBuildup,heatCoolRate→heatCooldown,damageReduction→armor,shieldMax→shield).pushStatsruns every engine key throughENGINE_TO_SHIPDEFbefore sending so the sidecar uses ShipDef-native names. - The rarity picker is intentionally separate from the SAVE pipeline: it edits
SHIPS_V4_RARITY.tsvia a dedicated endpoint and triggers a full page reload because rarity is read at module-load time insidegetShipDef. getStatsForStarpreferstoShipCombatStats(def)and falls back todef[key]for fields that exist only on the rawShipDef(e.g.weaponSlots).- The 5-column row defaults
parseFloat(e.target.value) || 0— empty strings collapse to 0 rather thanNaN. - The
=★flatten button writes throughonChangerather than directly mutating state, which routes each cell throughpatchStarand 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.