data/ships.ts

Ship roster — the v4 player-ship data table. One ShipDef row per (hull_class, star) pair, generated from a single BASELINE_STATS template plus per-hull-and-per-star overrides. Hull keys are the EXACT v4 sprite filenames (case + spaces preserved) so the case-sensitive ships-v4-loader and getShipDef share one identifier. Stat scaling between stars is interpolated, not stored row-by-row. Engine consumers reach this module through getShipDef(hull, star)toShipCombatStats / toShipMetaStats.

λ — Charge across the river

ExportKindPurpose
Factiontype'angel_corp' | 'crystal_casino' | 'solaris' | 'wrongsiders'
ShipRaritytype'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
HeatCurvetype'linear' | 'front_loaded' | 'back_loaded' | 'explosive_tail' | 'flat_plateau' | 'surge_pulse'
DragCurvetype'linear' | 'exponential' | 'front_loaded' | 'back_loaded' | 'overshoot'
AccelCurvetype'linear' | 'ease_in' | 'ease_out' | 'instant'
ShipDefinterfaceThe contract for a single (hull, star) row — see schema below
RARITY_COLORSRecord<ShipRarity, {color, accent}>Frame + accent hex per rarity (UI)
RARITY_NAMESRecord<ShipRarity, string>Title-cased label per rarity
RARITY_GRADESRecord<ShipRarity, string>Single-letter grade (C / B / A / S / L)
HULL_CLASSESstring[]All hull keys, sourced from SHIPS_V4_RARITY
SHIPSShipDef[]The 300-row pre-baseline roster (every hull × ★1-★5)
SHIP_STATS_S1..S5Record<string, Partial<ShipDef>>Per-hull per-star overrides keyed by hull
displayHullName(hull)(string) => stringStrips faction prefix + replaces _ with spaces
getShipDef(hull, star)(string, number) => ShipDefResolves baseline → S1/S5 lerp → exact-star override
toShipCombatStats(ship)(ShipDef) => ShipCombatStatsEngine adapter — combat / movement / VFX subset
toShipMetaStats(ship)(ShipDef) => ShipMetaStatsEngine adapter — metagame subset (luck, currencyBonus, derived objectiveSpeed)

BASELINE_STATS, LERP_FIELDS, STAR_STATS, SHIPS_BY_ID, lerpStat are module-private.

Κ — The registry shape

ShipDef schema

A ShipDef is a flat record. Fields group into seven bands:

BandFields
Identityid ({hull}_s{star}), name, hull_class, faction, rarity, star, passiveId, shipClass, desc, color, accent
Combat statshp, shield, armor, speed, acceleration, turnRate, weaponSlots, upgradeSlots, weaponDamagePct, fireRatePct, meleeMult, startingWeapons
Movement feeldrag, heatBuildup, heatCooldown, heatCurve, burnoutSeverity
RegenshieldRegenRate, shieldRegenDelay, hpRegen
Vehicle feelrotates, fixedAngleDeg, accelCurve, dragCurve, stopShakeIntensity, heatShakeIntensity, heatShakeThreshold, stopSpringAmt
Metaluck, magnetRange, currencyBonus
Physics / collisionshipScale, ramSpeedBleed, contactSpeedBleed, terrainRestitution, terrainFriction, ramThreshold, ramDamageLo, ramDamageHi, pushRatio, enemySolidity, contactDecel, contactCooldown
Sprite filterspriteHue, spriteSaturation, spriteContrast, spriteBrightness

id carries a literal-field comment (Track-B regex requires literal id: field) — do not rename or reformat.

The 300-row generator

Module load builds the entire roster at import time:

  1. HULL_CLASSES = Object.keys(SHIPS_V4_RARITY) — hulls come from the rarity table.
  2. For each hull and star ∈ [1, 5], push a ShipDef populated from BASELINE_STATS with three deviations: id = ${hull}_s${star}, name = ${hull} ★${star}, rarity = SHIPS_V4_RARITY[hull], and color / accent = RARITY_COLORS[rarity].
  3. SHIPS_BY_ID[s.id] = s indexes the 300 rows for getShipDef.

Everything in step 2 lives at module scope — SHIPS is final once the file loads. Star-level differences are NOT baked into SHIPS; they only appear through getShipDef’s lerp pass.

Rarity / hull / faction sourcing

ConceptWhere it livesNotes
Hull raritySHIPS_V4_RARITY in data/ships-v4-rarity.tsVision-tagged outline color on each sprite — single source of truth
Hull listHULL_CLASSES = Object.keys(SHIPS_V4_RARITY)Order matches insertion order in the rarity map
Hull naming<Family>_<Hull> (e.g. Backwater_Lizard, Prism_Crystal, 'Backwater_Killer Croc')Family prefix is the human-readable family group; displayHullName strips it for UI
Faction (mechanical)ShipDef.factionDefault 'solaris' from BASELINE_STATS.faction; per-hull override allowed via SHIP_STATS_S1
Visual frame colorRARITY_COLORS[rarity]Bound at roster build, NOT recomputed by getShipDef

Family prefix is a UI/grouping convention only — it has no type. Faction is the union type for mechanical alignment and is independent of the family prefix.

μ — Star interpolation

getShipDef(hull, star) is the only sanctioned read path. The lookup runs three passes:

  1. Exact-star override. If SHIP_STATS_S{star}[hull] has the field, use it verbatim.
  2. Endpoint lerp. Otherwise interpolate between SHIP_STATS_S1[hull][field] and SHIP_STATS_S5[hull][field] using t = (star - 1) / 4 (★1=0, ★2=0.25, ★3=0.5, ★4=0.75, ★5=1).
  3. Baseline fallback. If S1 or S5 doesn’t override the field, that endpoint defaults to BASELINE_STATS[field] (and S5 defaults to S1’s value when S5 isn’t overridden, collapsing to a flat curve).

Only fields enumerated in LERP_FIELDS participate in the lerp. Non-numeric / discrete fields (heatCurve, passiveId, shipClass, rotates, fixedAngleDeg, accelCurve, dragCurve, startingWeapons) read from the S1 override when present, otherwise fall through to the baseline row stored in SHIPS_BY_ID.

star=1 and star=5 are NOT special-cased — they pass through the same lerp with t=0 / t=1. The S1 and S5 records are therefore the “endpoint anchors”; S2/S3/S4 records exist only to pin off-curve mid-star values (the design note in the source calls this “pinning a non-linear curve without touching the endpoints”).

getShipDef throws on an unknown hull — bad data is a bug.

LERP_FIELDS

The interpolation whitelist is explicit. Every numeric ShipDef field is in this list except slot-count integers weaponSlots / upgradeSlots (these are listed too — they lerp and then become floats; callers that need integers must round). startingWeapons, passiveId, color, accent, desc, id, name, hull_class, faction, rarity, star, heatCurve, rotates, fixedAngleDeg, accelCurve, dragCurve, shipClass are deliberately excluded — they are categorical or already-resolved-at-build.

STAR_STATS index quirk

const STAR_STATS = [{}, SHIP_STATS_S1, SHIP_STATS_S2, SHIP_STATS_S3, SHIP_STATS_S4, SHIP_STATS_S5];

Index 0 is an empty sentinel so STAR_STATS[star] reads naturally for star ∈ [1, 5]. The array is Array<Record<string, Partial<ShipDef>>> (non-const) on purpose — the dev playground’s /__dev/push-stats upserts into these records, and getShipDef reads through the references on the next call.

Ι — Engine adapters

toShipCombatStats

Projects ShipDef onto ShipCombatStats (defined in data/run-config.ts). The adapter renames a handful of fields:

ShipDefShipCombatStats
hphpMax
shieldshieldMax
armordamageReduction
accelerationthrust
speedmaxSpeed
heatBuildupoverheatBurn
heatCooldownoverheatCool
turnRateturnSpeed

It also injects three constants the data table does NOT carry: shieldRegenFillTime = 2, heatSpeedTarget = 80, heatBoostMult = 2.5. These are engine-side constants, not ship-tunable — anything per-ship tuning must touch ShipDef.

toShipMetaStats

Trivial projection — luck, currencyBonus, plus a derived objectiveSpeed = Math.round(luck * 0.5). The 0.5 multiplier is hard-coded here, not in BASELINE_STATS.

Θ — HMR contract

The file ends with a import.meta.hot.accept block. The dev playground edits this file (via /__dev/push-stats) every time the designer hits SAVE, so the module needs to swap in place — otherwise Vite walks the dependency graph to the next non-accepting parent and full-reloads the page, killing the running mission. Because every consumer reads through getShipDef() at call time, the accept callback has no state to migrate; it logs a confirmation and returns. The playground separately calls patchShipStats on the live mission for the visual update.

EXTRACT-CANDIDATE

  • Header comment says “60 hull classes × 5 stars = 300 entries” but SHIPS_V4_RARITY currently exposes ~44 hulls. The 300-row count drifts with the rarity map; either update the comment or treat it as a target and add a hull-count invariant test.
  • BASELINE_STATS is module-private but every field shows up in every Partial<ShipDef> override anyway (the S1 records repeat all 35+ fields per hull). Exporting BASELINE_STATS and letting the playground emit minimal Partials would shrink the file dramatically; the current full-record overrides defeat the lerp design.
  • LERP_FIELDS includes weaponSlots and upgradeSlots, which are conceptually integers — interpolation produces floats. Either round at the getShipDef boundary or move slot counts out of the lerp pool and into the discrete-field branch.
  • getShipDef re-walks LERP_FIELDS and rebuilds a new object on every call — 35+ field reads per spawn. A per-(hull, star) memo keyed on id would amortize the cost; cache invalidation hooks into the existing HMR accept callback.
  • toShipMetaStats hard-codes luck * 0.5 for objectiveSpeed. Move the constant out (e.g. OBJECTIVE_SPEED_PER_LUCK) so the conversion ratio is greppable and traces to a named constant.
  • Faction union is declared but no UI/engine code currently distinguishes between 'crystal_casino' / 'angel_corp' / 'wrongsiders' / 'solaris' — every generated row gets 'solaris' from baseline. Either prune the union to what’s actually used or fan it through the per-hull S1 overrides.
  • The family prefix convention (Backwater_Lizard, Prism_Crystal, etc.) is informal — no Family type, no lookup helper to enumerate “all Prism ships”. A getFamily(hull): string + Family union derived from SHIPS_V4_RARITY keys would make grouping queries cheap.
  • SHIPS_BY_ID is module-private but getShipDef is the only caller. Either inline the map into getShipDef’s closure or expose SHIPS_BY_ID for read-only consumers (saves an O(n) .find call when tools need to validate ids).