merge-system.ts

PURPOSE

Implements the weapon merge mechanic: two level-20 non-legendary weapons combine into a single Legendary weapon determined by the unordered pair of their damage tags. The resulting legendary starts at level 1 and scales 1 to 20 through the normal level-up pool. Merging is single-layer — legendary weapons cannot merge further. Merge cards are surfaced through the level-up reward pool whenever the ship holds two or more eligible weapons.

OWNS

  • MERGE_ELIGIBLE_LEVEL constant (value 20) — the weapon level at which merging becomes available.
  • MergeCandidate interface — an unordered pair of owned weapons plus their definitions and the legendary id they would produce.
  • findMergeCandidates(ship) — enumerates every unordered pair of owned, level-20, non-legendary weapons with a damageTag.
  • mergeWeapons(ship, game, weaponIdA, weaponIdB) — mutates the ship’s weapon array: removes both parents, inserts the legendary at the leftmost parent’s slot, and records the legendary in game.tracking.weaponsFound.

READS FROM

  • WEAPON_MAP from ../../data/weapons — looks up WeaponCoreSpec by weapon id, checks isLegendary, damageTag, fireRate.base, and defaultAngle.
  • getLegendaryForPair(tagA, tagB) from ../../data/weapons — resolves the legendary id for a pair of WeaponTag values.
  • ship.weapons — the ship’s weapon instance array; each entry has id, level, and slot position.
  • GameState type from ../core/types — imported for game.tracking access.

PUSHES TO

  • ship.weapons array — splices out both parent weapons and splices in the new legendary instance at the lower of the two parent indices.
  • New weapon instance fields written: id, cooldown, cooldownMax, fireTimer (randomized to Math.random() * cooldown), level (1), defaultAngle.
  • game.tracking.weaponsFound — appends the legendary id; lazily initializes the array if absent.

DOES NOT

  • Does not render UI, generate cards, or open the level-up panel — that is the level-up/reward system’s job; this module only enumerates candidates and executes the merge.
  • Does not validate that the legendary id from getLegendaryForPair exists beyond a WEAPON_MAP lookup guard (returns null on miss).
  • Does not multi-tier merge — legendaries are filtered out of findMergeCandidates and rejected by mergeWeapons.
  • Does not require matching weapon ids; two copies of the same weapon at level 20 are a valid pair (distinct slot indices, not distinct weapon ids).
  • Does not preserve any state from the parent weapons (no level, fire timer, or upgrade history carries over).
  • Does not refund XP, emit events, or play VFX/SFX directly.

Signals

  • Return value of findMergeCandidates: array of MergeCandidate objects, consumed by the reward/card system to inject merge cards into the level-up pool.
  • Return value of mergeWeapons: { legendaryId } on success, null on any failure (missing ship, missing slot, legendary parent, missing tag, unresolved legendary id).
  • Side effect on game.tracking.weaponsFound — used by tracking/telemetry to record that the legendary was acquired this run.

Entry points

  • findMergeCandidates(ship) — called by the level-up reward pool builder to detect merge opportunities.
  • mergeWeapons(ship, game, weaponIdA, weaponIdB) — called when a merge card is picked, with the two parent weapon ids from the chosen MergeCandidate.
  • MERGE_ELIGIBLE_LEVEL export — referenced wherever the merge-eligibility threshold is needed outside this module.

Pattern notes

  • Eligibility filter is strict: weapon must be in WEAPON_MAP, must not be isLegendary, must have a damageTag, and (w.level ?? 1) must be at least MERGE_ELIGIBLE_LEVEL.
  • Pair enumeration is unordered (for j = i + 1) so each pair appears exactly once regardless of weapon-id equality.
  • Distinct-slot lookup in mergeWeapons: B’s index is found by scanning for a slot whose id === weaponIdB and whose index is not idxA. This is what allows two same-id weapons to merge.
  • Splice order matters: higher index is removed first so the lower index stays valid for the second splice.
  • The legendary is inserted at lo (the leftmost parent’s slot) rather than appended, so the HUD weapon bar keeps a stable left-to-right order and the merge cinematic lands the new icon where the leftmost parent used to sit.
  • Fire timing on the new legendary: cooldown = 1 / fireRate.base (fallback 0.5 if fireRate is missing or zero), fireTimer is randomized within [0, cooldown) to avoid synchronized first shots.
  • Weapon instances are loosely typed as any in this module; the canonical specs come through WeaponCoreSpec from the data layer.
  • WEAPON_MAP[legendaryId] is guarded — an unresolved legendary id aborts the merge with null rather than crashing.