mission-postings.ts

Static job-posting data for the in-universe mission board. One posting per planet in PLANET_ORDER, plus the deterministic 2-card picker used by MissionBoardScreen. UI-free: planet names are resolved at render time through the PLANETS map.

Role in the system

Stage 1 of the mission-board UX overhaul (audit issue #5). Postings are positively framed — they describe what the operative will do, never a loss condition. The card surface shows planet name, hiring faction, difficulty pill, positive objective label; the in-run HUD surfaces “SURVIVE M:SS until extraction” using the timer derived here.

Downstream consumers:

  • MissionBoardScreen — renders the two-card pair.
  • assembleRunService.assembleRunDef — reads missionObjective to apply MISSION_OBJECTIVE_TUNING.
  • engine/bridge.ts — consumes runDef.node.weaponBoxCount (spawn loop) and runDef.node.timerSeconds (mission timer).
  • engine/rendering/mission-timer-hud.ts — renders the survive banner from game.missionTimer.

Union types

TypeMembersNotes
MissionFaction10 string literalsHiring authorities (e.g. Cygnus Freight Co., Sol Defense Authority, Voidstar Research Div.). Used as flavor copy and postedBy context.
MissionObjectiveLabelExplore | Find | Protect | BattleStage 7 collapsed 7 cosmetic verbs to 4 vibes with engine tuning.
MissionDifficultyRoutine | Standard | Hazardous | Critical | Black FlagDrives extraction timer and ordering.

Objective label collapse mapping (historical, baked into the postings):

  • Survey, Investigate → Explore
  • Recover → Find
  • Patrol → Battle
  • Escort → Protect

Objective tuning contract

MissionObjectiveTuning exposes two real engine levers per label:

  • enemyCountMult — multiplier layered on top of planet × rarity × challenge mults (multiplicative composition).
  • weaponBoxCount — number of weapon boxes placed at run start; caller-supplied params.node.weaponBoxCount wins over this default in assembleRunService.

The MISSION_OBJECTIVE_TUNING: Record<MissionObjectiveLabel, MissionObjectiveTuning> map is the canonical source. Values are conservative (1.0×–1.4× enemyCountMult, 0–2 boxes) so objective tuning never dominates the existing difficulty stack — it produces vibe, not difficulty.

Per-label intent:

  • Explore — baseline, nothing extra.
  • Find — calmer + more loot.
  • Protect — mild pressure + a weapon to defend with.
  • Battle — pure pressure, no extra loot.

Extraction-timer contract

EXTRACTION_TIMER_BY_DIFFICULTY: Record<MissionDifficulty, number> maps each difficulty to a survive-time-in-seconds. The map is declared exhaustive over MissionDifficulty — adding a new difficulty without adding an entry fails the TypeScript build.

Curve shape: monotonic with difficulty, fanning out by ±60s per step around the 4-minute standard. The chosen timer is copied into MissionPosting.extractionTimerSeconds, which engine/bridge.ts writes into game.missionTimer at run start.

MissionPosting interface

FieldTypeNotes
idstringStable unique identifier (posting-N).
planetIdPlanetIdKey into the PLANETS map; resolved to a display name at render time.
factionMissionFactionHiring authority.
objectiveLabelMissionObjectiveLabelDrives tuning via MISSION_OBJECTIVE_TUNING.
objectiveBlurbstringOne-line in-universe flavor, positively framed.
difficultyMissionDifficultyDrives extraction-timer lookup.
payoutLinestringPrefixed with PAYOUT:. Pure UI copy, no engine effect.
postedBystringIn-universe author tag at card bottom.
extractionTimerSecondsnumberMirror of EXTRACTION_TIMER_BY_DIFFICULTY[difficulty].

Authoring rule: keep loss conditions out of objectiveBlurb. The card surface stays positive; failure is only ever expressed at run-time, not at offer-time.

MISSION_POSTINGS table

A MissionPosting[] with one entry per PLANET_ORDER index. Difficulty loosely tracks planet progression (Routine in early indices, Black Flag at the late end). Each posting references PLANET_ORDER[N] directly, so adding/removing planets requires re-authoring or pruning entries here.

Selection helpers

Both helpers use a stable bit-mixing hash on the input seed (Math.abs(((seed ^ (seed >>> 16)) * 0x45d9f3b) ^ ((seed ^ (seed >>> 16)) >>> 16))) so the same seed always produces the same pair. Both default seed = Date.now() so the board varies per page visit when callers omit the seed.

pickTwoPostings(seed?)

Returns [MissionPosting, MissionPosting] of two distinct entries from MISSION_POSTINGS. Index B is derived by a second hash mod (count - 1) and shifted past index A to guarantee distinctness — no rejection loop.

pickTwoPostingsForPlanet(planetId, seed?)

Biased variant used when the player taps LAUNCH on a specific planet in the hub. Contract:

  • At least one of the two cards has posting.planetId === planetId.
  • The other card is drawn from postings whose id differs from card A — does not constrain its planet.
  • Edge case — zero matching postings: defensive fallback to pickTwoPostings(seed). Should not occur because the authored table covers every PLANET_ORDER entry.
  • Edge case — only one posting in the entire table: returns [cardA, cardA] (pathological; defensive only).

Selection is deterministic for (planetId, seed) so refresh/back-button on the board route does not reroll the offers.

Authoring a new posting

  1. Add a PlanetId to PLANET_ORDER (in planet-config.ts) — postings index against this array.
  2. Append a MissionPosting literal at the matching MISSION_POSTINGS index. Use PLANET_ORDER[N] for planetId.
  3. Choose objectiveLabel from the four vibe types; the engine tuning attaches automatically through MISSION_OBJECTIVE_TUNING.
  4. Choose difficulty and reference EXTRACTION_TIMER_BY_DIFFICULTY[difficulty] for extractionTimerSeconds — never hard-code a number.
  5. Keep objectiveBlurb positively framed (what the operative does, not what they avoid).
  6. payoutLine uses the PAYOUT: … prefix convention.

If a new MissionDifficulty is needed, also extend EXTRACTION_TIMER_BY_DIFFICULTY — the Record<MissionDifficulty, number> typing will otherwise fail the build.

  • code/data/planet-config — source of PLANET_ORDER and the PLANETS map.
  • code/services/assembleRunService — consumes missionObjective and the tuning record.
  • code/engine/bridge — consumes runDef.node.timerSeconds and runDef.node.weaponBoxCount.
  • code/engine/rendering/mission-timer-hud — surfaces the extraction-timer banner.
  • code/screens/MissionBoardScreen — renders the 2-card output of the pickers.

EXTRACT-CANDIDATE

  • The objective→tuning mapping (MISSION_OBJECTIVE_TUNING) is currently colocated with posting data but is a pure engine-tuning contract. If a second consumer needs the tuning without the posting data, extract into data/mission-objective-tuning.ts so assembleRunService can depend on the smaller module.
  • The bit-mixing hash (((x ^ (x >>> 16)) * k) ^ ((x ^ (x >>> 16)) >>> 16)) is duplicated across both pickers with different constants. If a third deterministic picker is added (e.g. weekly featured posting), extract a shared mixSeed(seed, salt) helper.
  • EXTRACTION_TIMER_BY_DIFFICULTY could move to a shared difficulty-config.ts if other systems (ship rewards, telemetry buckets) start keying off the same difficulty enum.