From c53910b6e3009c81135cdd4f7579fa6dd804faf6 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 11:25:52 -0400 Subject: [PATCH] Step 10A.12: show the tutorial tradeoff and scripted contracts Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 + apps/web/src/components/AwaySummary.tsx | 9 ++ apps/web/src/screens/TutorialBoard.tsx | 81 +++++++--- apps/web/test/tutorial-board.test.tsx | 196 +++++++++++++++--------- memory-bank/progress.md | 2 +- 5 files changed, 195 insertions(+), 98 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index aec0596..4d27bea 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -558,3 +558,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The intro card and "Suggested" highlight show only before the first run** (`runsCompleted === 0`); after that the board is a plain choice. - **The suggested contract is the board's first slot,** which the v2 generator always fills with the light tier (fastest, most XP per minute). - **Story names live in client copy:** "Halvorsen Lunar Freight" is presentation text, not game data, so it is not in content. + +## Step 10A.12 + +- **XP shown on a tutorial contract is sim `deliveryXp` at the current distance**, the same formula resolution uses, so the light-versus-heavy tradeoff the player sees is the real one. +- **Scripted contracts show their content name** ("Unmarked crates") in a distinct colour with a short "double pay, no questions asked" line; the summary looks up the flavor message by the summary's `scriptedId`. diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx index eb2df3d..c93b236 100644 --- a/apps/web/src/components/AwaySummary.tsx +++ b/apps/web/src/components/AwaySummary.tsx @@ -1,3 +1,4 @@ +import { content } from "@deadhead/content"; import { useState } from "react"; import { formatCredits, locationName } from "../format"; import { useGameAction, useLoadedPlayer } from "../state/actions"; @@ -46,6 +47,14 @@ export function AwaySummary() { +{s.pilotXp} XP + {s.scriptedId && ( +

+ { + content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId) + ?.flavorMessage + } +

+ )} {s.discoveredLocationId && (
Discovered {locationName(s.discoveredLocationId)}! diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx index f52b829..9fd2e03 100644 --- a/apps/web/src/screens/TutorialBoard.tsx +++ b/apps/web/src/screens/TutorialBoard.tsx @@ -1,10 +1,10 @@ import { content } from "@deadhead/content"; -import { loanerModel } from "@deadhead/sim"; +import { deliveryXp, loanerModel } from "@deadhead/sim"; import { useQuery } from "@tanstack/react-query"; import { useApi } from "../api/context"; import { useClock } from "../clock/clock"; import { formatCredits, formatDuration, locationName, cargoName } from "../format"; -import { tripRealMs } from "../game"; +import { distanceAt, tripRealMs } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const; @@ -66,40 +66,79 @@ export function TutorialBoard() { {board.data.contracts.map((contract, index) => { // On the first run, suggest the lightest (fastest) contract. const suggested = firstRun && index === 0; + const scripted = contract.scriptedId + ? content.scriptedTutorialContracts.find((s) => s.id === contract.scriptedId) + : undefined; + const tripMs = tripRealMs( + loaner, + contract.originId, + contract.destinationId, + contract.massTonnes, + now, + ); + const xp = deliveryXp( + distanceAt(contract.originId, contract.destinationId, now), + contract.tier, + content.config, + ); + const border = scripted + ? "border-fuchsia-500 bg-fuchsia-500/10" + : suggested + ? "border-amber-400 ring-1 ring-amber-400" + : "border-slate-800"; return (
  • - {locationName(contract.originId)} → {locationName(contract.destinationId)} + {scripted ? ( + {scripted.name} + ) : ( + <> + {locationName(contract.originId)} → {locationName(contract.destinationId)} + + )} {suggested && ( Suggested )} - - {formatCredits(contract.payout)} + + {cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
    -
    - - {cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "} - - {formatDuration( - tripRealMs( - loaner, - contract.originId, - contract.destinationId, - contract.massTonnes, - now, - ), - )} - - + {scripted && ( +

    + {locationName(contract.originId)} → {locationName(contract.destinationId)} · + double pay, no questions asked +

    + )} +
    +
    +
    +
    Pay
    +
    + {formatCredits(contract.payout)} +
    +
    +
    +
    Time
    +
    + {formatDuration(tripMs)} +
    +
    +
    +
    XP
    +
    + +{xp} +
    +
    +