Step 10A.3: add tutorial board v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
957c54cc2c
commit
dd0b26813f
|
|
@ -496,3 +496,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
|
||||
- **`travelTimeGameHours` takes mass and config as arguments**, like the other sim functions, rather than reading a default bundle. Dispatch records use the contract's mass, or 0 for a reposition, so a reposition's time is exactly the old formula's. Arrival times already stored are unaffected (CR-001 §3).
|
||||
- **Client trip-time predictions pass the contract's mass,** so the dispatch sheet and tutorial board match what the server will record.
|
||||
|
||||
## Step 10A.3
|
||||
|
||||
- **The tutorial board has no randomness:** location, run number and period fully determine it, so the world-seed input was dropped. Contract ids are `tutorial:<location>:<run>:<period>:<slot>` (run is 1-based), unique per run and period.
|
||||
- **Slot order is light, medium, full;** a scripted contract keyed to the upcoming run (`runsCompleted + 1`) replaces the medium slot, keeps the medium mass, uses its own cargo, and pays `round(normal payout × tutorial share × pay multiplier)`.
|
||||
- **Contracts carry an optional `scriptedId`** (sim `Contract` and API `contractSchema`) so the client can style scripted contracts and look up their flavor message.
|
||||
- **`SimContent` includes `scriptedTutorialContracts`;** the content bundle already provides it.
|
||||
- **Temporary:** the server passes `runsCompleted: 0` until Step 10A.7 adds the counter, so every server-generated board is a first-run board for now.
|
||||
|
|
|
|||
|
|
@ -90,10 +90,9 @@ export async function tutorialBoard(
|
|||
tx,
|
||||
{ playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex },
|
||||
() =>
|
||||
generateTutorialBoard(
|
||||
{ loanerLocationId, periodIndex, worldSeed: player.worldSeed },
|
||||
content,
|
||||
),
|
||||
// Run counting arrives with the database change (Step 10A.7); until then
|
||||
// every board is the first run's.
|
||||
generateTutorialBoard({ loanerLocationId, runsCompleted: 0, periodIndex }, content),
|
||||
now,
|
||||
);
|
||||
return boardView(tx, playerId, contracts, periodIndex);
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ describe("tutorial dispatch", () => {
|
|||
});
|
||||
const travelMs =
|
||||
(travelTimeGameHours(
|
||||
content.shipModel("courier")!,
|
||||
content.shipModel("company-courier")!,
|
||||
dispatch.distanceMillionKm,
|
||||
contract!.massTonnes,
|
||||
content.config,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const board = {
|
|||
periodEndsAt: NOW + 3_600_000,
|
||||
};
|
||||
|
||||
/** The trip time the sim predicts for the Courier loaner, Earth→Moon, departing now. */
|
||||
/** The trip time the sim predicts for the Company Courier loaner, Earth→Moon, departing now. */
|
||||
function expectedTripTime(now: number, massTonnes: number) {
|
||||
const distance = routeDistance(
|
||||
content.location("earth")!,
|
||||
|
|
@ -39,7 +39,7 @@ function expectedTripTime(now: number, massTonnes: number) {
|
|||
realToGame(now, content.config),
|
||||
);
|
||||
const hours = travelTimeGameHours(
|
||||
content.shipModel("courier")!,
|
||||
content.shipModel("company-courier")!,
|
||||
distance,
|
||||
massTonnes,
|
||||
content.config,
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
|
|||
|
||||
- [x] **Step 10A.1 — Content: new config and loaner ship.** `purchasable` on ShipModel; "Company Courier" (not purchasable, 6M km/h, 0.3 h overhead, 20 t); config: `tutorialLoanerShipModelId`, `tutorialRunsBeforeDeal` 4, `tutorialBoardSize` 3, `tutorialMassTonnes` 5/12/20, `handlingGameHoursPerTonne` 0.02, `loanRepaymentShare` 0.3, `dealLoanAmount` 5000, `startingCredits` 500. "Unmarked crates" scripted contract (run 3, ×2) and six onboarding goals (`src/data/onboarding.ts`). Step 2.5 test replaced per CR §6; Market lists purchasable models only and the server will not sell the loaner.
|
||||
- [x] **Step 10A.2 — Sim: handling time.** `travelTimeGameHours(ship, distance, massTonnes, config)` adds `mass × handlingGameHoursPerTonne`; dispatch records pass the contract mass (0 for repositions); client predictions pass contract mass. Step 3.5 hand-calculated test updated.
|
||||
- [ ] Step 10A.3 — Sim: tutorial board v2
|
||||
- [x] **Step 10A.3 — Sim: tutorial board v2.** `generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex })`: light/medium/full Standard contracts at the tutorial share; a scripted contract for the upcoming run replaces the medium slot (`scriptedId` on the contract, at its pay multiplier). Loaner is `config.tutorialLoanerShipModelId`. Step 3.10 tests replaced. Server passes `runsCompleted: 0` until Step 10A.7.
|
||||
- [ ] Step 10A.4 — Sim: loan repayment
|
||||
- [ ] Step 10A.5 — Sim: onboarding goal evaluation
|
||||
- [ ] Step 10A.6 — Schema and database
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ export const contractSchema = z.object({
|
|||
offeredAt: timeSchema,
|
||||
expiresAt: timeSchema,
|
||||
firstContact: z.boolean(),
|
||||
/** Set on a scripted tutorial contract (decision 0.5.33). */
|
||||
scriptedId: idSchema.optional(),
|
||||
});
|
||||
export type Contract = z.infer<typeof contractSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import type { CargoType, GameConfig, Location, ShipModel, Skill } from "@deadhead/content";
|
||||
import type {
|
||||
CargoType,
|
||||
GameConfig,
|
||||
Location,
|
||||
ScriptedTutorialContract,
|
||||
ShipModel,
|
||||
Skill,
|
||||
} from "@deadhead/content";
|
||||
import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock";
|
||||
import { contractPayout, type Contract } from "./contract";
|
||||
import { routeDistance } from "./distance";
|
||||
|
|
@ -10,6 +17,7 @@ export interface SimContent {
|
|||
cargoTypes: readonly CargoType[];
|
||||
skills: readonly Skill[];
|
||||
shipModels: readonly ShipModel[];
|
||||
scriptedTutorialContracts: readonly ScriptedTutorialContract[];
|
||||
config: GameConfig;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export interface Contract {
|
|||
/** End of the board period the contract was offered in. */
|
||||
expiresAt: RealTime;
|
||||
firstContact: boolean;
|
||||
/** Set on a scripted tutorial contract (decision 0.5.33): its content id. */
|
||||
scriptedId?: string;
|
||||
}
|
||||
|
||||
export interface PayoutInput {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import type { Location, ShipModel } from "@deadhead/content";
|
||||
import { buildContract, standardCargo, type SimContent } from "./board";
|
||||
import type { Contract } from "./contract";
|
||||
import { createRng, deriveSeed, type Seed } from "./rng";
|
||||
|
||||
/** The tutorial loaner is the company's Courier. */
|
||||
/** The tutorial loaner: the content's company ship (decision 0.5.29). */
|
||||
export function loanerModel(content: SimContent): ShipModel {
|
||||
const courier = content.shipModels.find((s) => s.archetype === "courier");
|
||||
if (!courier) throw new Error("Content has no Courier ship model");
|
||||
return courier;
|
||||
const id = content.config.tutorialLoanerShipModelId;
|
||||
const loaner = content.shipModels.find((s) => s.id === id);
|
||||
if (!loaner) throw new Error(`Content has no loaner ship model "${id}"`);
|
||||
return loaner;
|
||||
}
|
||||
|
||||
export type TutorialLocationId = "earth" | "moon";
|
||||
|
|
@ -15,8 +15,9 @@ export type TutorialLocationId = "earth" | "moon";
|
|||
export interface TutorialBoardInput {
|
||||
/** Where the loaner currently is; contracts start here (decision 0.5.3). */
|
||||
loanerLocationId: TutorialLocationId;
|
||||
/** Tutorial runs already flown; the board is for run `runsCompleted + 1`. */
|
||||
runsCompleted: number;
|
||||
periodIndex: number;
|
||||
worldSeed: Seed;
|
||||
}
|
||||
|
||||
function location(id: string, content: SimContent): Location {
|
||||
|
|
@ -26,33 +27,47 @@ function location(id: string, content: SimContent): Location {
|
|||
}
|
||||
|
||||
/**
|
||||
* The tutorial "company board": Earth↔Moon only, Standard cargo sized to fit
|
||||
* the loaner, starting at the loaner's location. Each payout is the tutorial
|
||||
* share of the equivalent normal payout, rounded to whole credits.
|
||||
* The tutorial "company board" (CR-001, Step 10A.3): three Standard contracts
|
||||
* from the loaner's location to the other end of the Earth–Moon route, at the
|
||||
* light, medium and full mass tiers, each paying the tutorial share. A scripted
|
||||
* contract for the upcoming run (decision 0.5.33) replaces the medium slot.
|
||||
* The board is fully determined by its inputs.
|
||||
*/
|
||||
export function generateTutorialBoard(input: TutorialBoardInput, content: SimContent): Contract[] {
|
||||
const { loanerLocationId, periodIndex, worldSeed } = input;
|
||||
const { loanerLocationId, runsCompleted, periodIndex } = input;
|
||||
const { config } = content;
|
||||
const origin = location(loanerLocationId, content);
|
||||
const destination = location(loanerLocationId === "earth" ? "moon" : "earth", content);
|
||||
const cargoType = standardCargo(content);
|
||||
const maxMass = Math.min(config.contractMassTonnes.max, loanerModel(content).cargoCapacityTonnes);
|
||||
const rng = createRng(deriveSeed(worldSeed, `tutorial:${loanerLocationId}:${periodIndex}`));
|
||||
const tiers = config.tutorialMassTonnes;
|
||||
const run = runsCompleted + 1;
|
||||
const scripted = content.scriptedTutorialContracts.find((s) => s.run === run);
|
||||
|
||||
return Array.from({ length: config.tutorialBoardSize }, (_, i) => {
|
||||
const slot = (i: number, massTonnes: number): Contract => {
|
||||
const special = i === 1 ? scripted : undefined;
|
||||
const cargoType = special
|
||||
? content.cargoTypes.find((c) => c.id === special.cargoTypeId)
|
||||
: standardCargo(content);
|
||||
if (!cargoType) throw new Error(`Unknown cargo ${special?.cargoTypeId}`);
|
||||
const contract = buildContract(
|
||||
{
|
||||
id: `tutorial:${loanerLocationId}:${periodIndex}:${i}`,
|
||||
id: `tutorial:${loanerLocationId}:${run}:${periodIndex}:${i}`,
|
||||
origin,
|
||||
destination,
|
||||
cargoType,
|
||||
tier: 0,
|
||||
massTonnes: rng.int(config.contractMassTonnes.min, maxMass),
|
||||
massTonnes,
|
||||
firstContact: false,
|
||||
periodIndex,
|
||||
},
|
||||
content,
|
||||
);
|
||||
return { ...contract, payout: Math.round(contract.payout * config.tutorialPayoutShare) };
|
||||
});
|
||||
const share = contract.payout * config.tutorialPayoutShare * (special?.payMultiplier ?? 1);
|
||||
return {
|
||||
...contract,
|
||||
payout: Math.round(share),
|
||||
...(special ? { scriptedId: special.id } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
return [slot(0, tiers.light), slot(1, tiers.medium), slot(2, tiers.full)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,67 +2,128 @@ import { content } from "@deadhead/content";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
boardPeriodIndex,
|
||||
contractPayout,
|
||||
deliveryXp,
|
||||
generateTutorialBoard,
|
||||
loanerModel,
|
||||
MS_PER_HOUR,
|
||||
realToGame,
|
||||
routeDistance,
|
||||
travelTimeGameHours,
|
||||
type Contract,
|
||||
type TutorialBoardInput,
|
||||
} from "../src/index";
|
||||
import { buildContract } from "../src/board";
|
||||
|
||||
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config);
|
||||
const atEarth: TutorialBoardInput = { loanerLocationId: "earth", periodIndex, worldSeed: 99 };
|
||||
const atMoon: TutorialBoardInput = { ...atEarth, loanerLocationId: "moon" };
|
||||
const config = content.config;
|
||||
const START = Date.parse("2100-01-01T00:00:00Z");
|
||||
const periodIndex = boardPeriodIndex(START, config);
|
||||
const atEarth: TutorialBoardInput = { loanerLocationId: "earth", runsCompleted: 0, periodIndex };
|
||||
const atMoon: TutorialBoardInput = { ...atEarth, loanerLocationId: "moon", runsCompleted: 1 };
|
||||
const loaner = loanerModel(content);
|
||||
|
||||
describe("tutorial board", () => {
|
||||
it("offers only Earth→Moon when the loaner is at Earth", () => {
|
||||
for (const c of generateTutorialBoard(atEarth, content)) {
|
||||
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]);
|
||||
function distanceOf(contract: Contract): number {
|
||||
return routeDistance(
|
||||
content.location(contract.originId)!,
|
||||
content.location(contract.destinationId)!,
|
||||
realToGame(contract.offeredAt, config),
|
||||
);
|
||||
}
|
||||
|
||||
/** The normal (non-tutorial) payout for the same Standard cargo and route. */
|
||||
function normalPayout(contract: Contract): number {
|
||||
return contractPayout(
|
||||
{
|
||||
cargoType: content.cargoType("standard")!,
|
||||
tier: 0,
|
||||
massTonnes: contract.massTonnes,
|
||||
distanceMillionKm: distanceOf(contract),
|
||||
firstContact: false,
|
||||
},
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
/** Real minutes for the loaner to fly a contract, departing at its offer time. */
|
||||
function tripMinutes(contract: Contract): number {
|
||||
const hours = travelTimeGameHours(loaner, distanceOf(contract), contract.massTonnes, config);
|
||||
return (hours * MS_PER_HOUR) / config.timeCompression / 60_000;
|
||||
}
|
||||
|
||||
function xpPerMinute(contract: Contract): number {
|
||||
return deliveryXp(distanceOf(contract), contract.tier, config) / tripMinutes(contract);
|
||||
}
|
||||
|
||||
describe("tutorial board v2", () => {
|
||||
it("uses the Company Courier as the loaner", () => {
|
||||
expect(loaner.id).toBe("company-courier");
|
||||
});
|
||||
|
||||
it("has exactly three Standard contracts at three different masses within the loaner's capacity", () => {
|
||||
const board = generateTutorialBoard(atEarth, content);
|
||||
expect(board).toHaveLength(3);
|
||||
expect(board.map((c) => c.massTonnes)).toEqual([
|
||||
config.tutorialMassTonnes.light,
|
||||
config.tutorialMassTonnes.medium,
|
||||
config.tutorialMassTonnes.full,
|
||||
]);
|
||||
expect(new Set(board.map((c) => c.massTonnes)).size).toBe(3);
|
||||
for (const c of board) {
|
||||
expect(c.cargoTypeId).toBe("standard");
|
||||
expect(c.tier).toBe(0);
|
||||
expect(c.requiredSkill).toBeNull();
|
||||
expect(c.massTonnes).toBeLessThanOrEqual(loaner.cargoCapacityTonnes);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers only Moon→Earth when the loaner is at the Moon", () => {
|
||||
it("runs from the loaner's location to the other end of the Earth–Moon route", () => {
|
||||
for (const c of generateTutorialBoard(atEarth, content)) {
|
||||
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]);
|
||||
}
|
||||
for (const c of generateTutorialBoard(atMoon, content)) {
|
||||
expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("has the configured size, Standard cargo only, sized to fit the loaner", () => {
|
||||
const board = generateTutorialBoard(atEarth, content);
|
||||
expect(board).toHaveLength(content.config.tutorialBoardSize);
|
||||
for (const c of board) {
|
||||
expect(c.cargoTypeId).toBe("standard");
|
||||
expect(c.tier).toBe(0);
|
||||
expect(c.requiredSkill).toBeNull();
|
||||
expect(c.requiredLongHaulLevel).toBe(0);
|
||||
expect(c.firstContact).toBe(false);
|
||||
expect(c.massTonnes).toBeLessThanOrEqual(loanerModel(content).cargoCapacityTonnes);
|
||||
it("pays the tutorial share of the normal payout", () => {
|
||||
for (const c of generateTutorialBoard(atEarth, content)) {
|
||||
expect(c.payout).toBe(Math.round(normalPayout(c) * config.tutorialPayoutShare));
|
||||
}
|
||||
});
|
||||
|
||||
it("pays exactly the tutorial share of the equivalent normal payout", () => {
|
||||
for (const input of [atEarth, atMoon]) {
|
||||
for (const c of generateTutorialBoard(input, content)) {
|
||||
const normal = buildContract(
|
||||
{
|
||||
id: c.id,
|
||||
origin: content.location(c.originId)!,
|
||||
destination: content.location(c.destinationId)!,
|
||||
cargoType: content.cargoType("standard")!,
|
||||
tier: 0,
|
||||
massTonnes: c.massTonnes,
|
||||
firstContact: false,
|
||||
periodIndex,
|
||||
},
|
||||
content,
|
||||
);
|
||||
expect(c.payout).toBe(Math.round(normal.payout * content.config.tutorialPayoutShare));
|
||||
expect(c.payout).toBeLessThan(normal.payout);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic", () => {
|
||||
it("gives an identical board for the same inputs, and a new one per run", () => {
|
||||
expect(generateTutorialBoard(atEarth, content)).toEqual(
|
||||
generateTutorialBoard(atEarth, content),
|
||||
);
|
||||
const nextRun = generateTutorialBoard({ ...atEarth, runsCompleted: 2 }, content);
|
||||
expect(nextRun.map((c) => c.id)).not.toEqual(
|
||||
generateTutorialBoard(atEarth, content).map((c) => c.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("puts Unmarked crates in the medium slot at double pay with two runs completed, and never otherwise", () => {
|
||||
const [, crates] = generateTutorialBoard({ ...atEarth, runsCompleted: 2 }, content);
|
||||
expect(crates).toMatchObject({
|
||||
scriptedId: "unmarked-crates",
|
||||
massTonnes: config.tutorialMassTonnes.medium,
|
||||
});
|
||||
expect(crates!.payout).toBe(Math.round(normalPayout(crates!) * config.tutorialPayoutShare * 2));
|
||||
|
||||
for (const runsCompleted of [0, 1, 3, 4, 7]) {
|
||||
const board = generateTutorialBoard({ ...atEarth, runsCompleted }, content);
|
||||
expect(board.some((c) => c.scriptedId !== undefined)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("trades XP per minute (light) against credits per minute (full)", () => {
|
||||
const [light, , full] = generateTutorialBoard(atEarth, content);
|
||||
expect(xpPerMinute(light!)).toBeGreaterThan(xpPerMinute(full!));
|
||||
expect(full!.payout / tripMinutes(full!)).toBeGreaterThan(light!.payout / tripMinutes(light!));
|
||||
});
|
||||
|
||||
it("keeps the four slowest possible tutorial runs under 6 real minutes in total", () => {
|
||||
const slowest = Math.max(
|
||||
...[atEarth, atMoon].map((input) => tripMinutes(generateTutorialBoard(input, content)[2]!)),
|
||||
);
|
||||
expect(4 * slowest).toBeLessThan(6);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue