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).
|
- **`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.
|
- **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,
|
tx,
|
||||||
{ playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex },
|
{ playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex },
|
||||||
() =>
|
() =>
|
||||||
generateTutorialBoard(
|
// Run counting arrives with the database change (Step 10A.7); until then
|
||||||
{ loanerLocationId, periodIndex, worldSeed: player.worldSeed },
|
// every board is the first run's.
|
||||||
content,
|
generateTutorialBoard({ loanerLocationId, runsCompleted: 0, periodIndex }, content),
|
||||||
),
|
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
return boardView(tx, playerId, contracts, periodIndex);
|
return boardView(tx, playerId, contracts, periodIndex);
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ describe("tutorial dispatch", () => {
|
||||||
});
|
});
|
||||||
const travelMs =
|
const travelMs =
|
||||||
(travelTimeGameHours(
|
(travelTimeGameHours(
|
||||||
content.shipModel("courier")!,
|
content.shipModel("company-courier")!,
|
||||||
dispatch.distanceMillionKm,
|
dispatch.distanceMillionKm,
|
||||||
contract!.massTonnes,
|
contract!.massTonnes,
|
||||||
content.config,
|
content.config,
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ const board = {
|
||||||
periodEndsAt: NOW + 3_600_000,
|
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) {
|
function expectedTripTime(now: number, massTonnes: number) {
|
||||||
const distance = routeDistance(
|
const distance = routeDistance(
|
||||||
content.location("earth")!,
|
content.location("earth")!,
|
||||||
|
|
@ -39,7 +39,7 @@ function expectedTripTime(now: number, massTonnes: number) {
|
||||||
realToGame(now, content.config),
|
realToGame(now, content.config),
|
||||||
);
|
);
|
||||||
const hours = travelTimeGameHours(
|
const hours = travelTimeGameHours(
|
||||||
content.shipModel("courier")!,
|
content.shipModel("company-courier")!,
|
||||||
distance,
|
distance,
|
||||||
massTonnes,
|
massTonnes,
|
||||||
content.config,
|
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.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.
|
- [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.4 — Sim: loan repayment
|
||||||
- [ ] Step 10A.5 — Sim: onboarding goal evaluation
|
- [ ] Step 10A.5 — Sim: onboarding goal evaluation
|
||||||
- [ ] Step 10A.6 — Schema and database
|
- [ ] Step 10A.6 — Schema and database
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ export const contractSchema = z.object({
|
||||||
offeredAt: timeSchema,
|
offeredAt: timeSchema,
|
||||||
expiresAt: timeSchema,
|
expiresAt: timeSchema,
|
||||||
firstContact: z.boolean(),
|
firstContact: z.boolean(),
|
||||||
|
/** Set on a scripted tutorial contract (decision 0.5.33). */
|
||||||
|
scriptedId: idSchema.optional(),
|
||||||
});
|
});
|
||||||
export type Contract = z.infer<typeof contractSchema>;
|
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 { MS_PER_MINUTE, type RealTime, realToGame } from "./clock";
|
||||||
import { contractPayout, type Contract } from "./contract";
|
import { contractPayout, type Contract } from "./contract";
|
||||||
import { routeDistance } from "./distance";
|
import { routeDistance } from "./distance";
|
||||||
|
|
@ -10,6 +17,7 @@ export interface SimContent {
|
||||||
cargoTypes: readonly CargoType[];
|
cargoTypes: readonly CargoType[];
|
||||||
skills: readonly Skill[];
|
skills: readonly Skill[];
|
||||||
shipModels: readonly ShipModel[];
|
shipModels: readonly ShipModel[];
|
||||||
|
scriptedTutorialContracts: readonly ScriptedTutorialContract[];
|
||||||
config: GameConfig;
|
config: GameConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export interface Contract {
|
||||||
/** End of the board period the contract was offered in. */
|
/** End of the board period the contract was offered in. */
|
||||||
expiresAt: RealTime;
|
expiresAt: RealTime;
|
||||||
firstContact: boolean;
|
firstContact: boolean;
|
||||||
|
/** Set on a scripted tutorial contract (decision 0.5.33): its content id. */
|
||||||
|
scriptedId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PayoutInput {
|
export interface PayoutInput {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import type { Location, ShipModel } from "@deadhead/content";
|
import type { Location, ShipModel } from "@deadhead/content";
|
||||||
import { buildContract, standardCargo, type SimContent } from "./board";
|
import { buildContract, standardCargo, type SimContent } from "./board";
|
||||||
import type { Contract } from "./contract";
|
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 {
|
export function loanerModel(content: SimContent): ShipModel {
|
||||||
const courier = content.shipModels.find((s) => s.archetype === "courier");
|
const id = content.config.tutorialLoanerShipModelId;
|
||||||
if (!courier) throw new Error("Content has no Courier ship model");
|
const loaner = content.shipModels.find((s) => s.id === id);
|
||||||
return courier;
|
if (!loaner) throw new Error(`Content has no loaner ship model "${id}"`);
|
||||||
|
return loaner;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TutorialLocationId = "earth" | "moon";
|
export type TutorialLocationId = "earth" | "moon";
|
||||||
|
|
@ -15,8 +15,9 @@ export type TutorialLocationId = "earth" | "moon";
|
||||||
export interface TutorialBoardInput {
|
export interface TutorialBoardInput {
|
||||||
/** Where the loaner currently is; contracts start here (decision 0.5.3). */
|
/** Where the loaner currently is; contracts start here (decision 0.5.3). */
|
||||||
loanerLocationId: TutorialLocationId;
|
loanerLocationId: TutorialLocationId;
|
||||||
|
/** Tutorial runs already flown; the board is for run `runsCompleted + 1`. */
|
||||||
|
runsCompleted: number;
|
||||||
periodIndex: number;
|
periodIndex: number;
|
||||||
worldSeed: Seed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function location(id: string, content: SimContent): Location {
|
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 tutorial "company board" (CR-001, Step 10A.3): three Standard contracts
|
||||||
* the loaner, starting at the loaner's location. Each payout is the tutorial
|
* from the loaner's location to the other end of the Earth–Moon route, at the
|
||||||
* share of the equivalent normal payout, rounded to whole credits.
|
* 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[] {
|
export function generateTutorialBoard(input: TutorialBoardInput, content: SimContent): Contract[] {
|
||||||
const { loanerLocationId, periodIndex, worldSeed } = input;
|
const { loanerLocationId, runsCompleted, periodIndex } = input;
|
||||||
const { config } = content;
|
const { config } = content;
|
||||||
const origin = location(loanerLocationId, content);
|
const origin = location(loanerLocationId, content);
|
||||||
const destination = location(loanerLocationId === "earth" ? "moon" : "earth", content);
|
const destination = location(loanerLocationId === "earth" ? "moon" : "earth", content);
|
||||||
const cargoType = standardCargo(content);
|
const tiers = config.tutorialMassTonnes;
|
||||||
const maxMass = Math.min(config.contractMassTonnes.max, loanerModel(content).cargoCapacityTonnes);
|
const run = runsCompleted + 1;
|
||||||
const rng = createRng(deriveSeed(worldSeed, `tutorial:${loanerLocationId}:${periodIndex}`));
|
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(
|
const contract = buildContract(
|
||||||
{
|
{
|
||||||
id: `tutorial:${loanerLocationId}:${periodIndex}:${i}`,
|
id: `tutorial:${loanerLocationId}:${run}:${periodIndex}:${i}`,
|
||||||
origin,
|
origin,
|
||||||
destination,
|
destination,
|
||||||
cargoType,
|
cargoType,
|
||||||
tier: 0,
|
tier: 0,
|
||||||
massTonnes: rng.int(config.contractMassTonnes.min, maxMass),
|
massTonnes,
|
||||||
firstContact: false,
|
firstContact: false,
|
||||||
periodIndex,
|
periodIndex,
|
||||||
},
|
},
|
||||||
content,
|
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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
boardPeriodIndex,
|
boardPeriodIndex,
|
||||||
|
contractPayout,
|
||||||
|
deliveryXp,
|
||||||
generateTutorialBoard,
|
generateTutorialBoard,
|
||||||
loanerModel,
|
loanerModel,
|
||||||
|
MS_PER_HOUR,
|
||||||
|
realToGame,
|
||||||
|
routeDistance,
|
||||||
|
travelTimeGameHours,
|
||||||
|
type Contract,
|
||||||
type TutorialBoardInput,
|
type TutorialBoardInput,
|
||||||
} from "../src/index";
|
} from "../src/index";
|
||||||
import { buildContract } from "../src/board";
|
|
||||||
|
|
||||||
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config);
|
const config = content.config;
|
||||||
const atEarth: TutorialBoardInput = { loanerLocationId: "earth", periodIndex, worldSeed: 99 };
|
const START = Date.parse("2100-01-01T00:00:00Z");
|
||||||
const atMoon: TutorialBoardInput = { ...atEarth, loanerLocationId: "moon" };
|
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", () => {
|
function distanceOf(contract: Contract): number {
|
||||||
it("offers only Earth→Moon when the loaner is at Earth", () => {
|
return routeDistance(
|
||||||
for (const c of generateTutorialBoard(atEarth, content)) {
|
content.location(contract.originId)!,
|
||||||
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]);
|
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)) {
|
for (const c of generateTutorialBoard(atMoon, content)) {
|
||||||
expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
|
expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has the configured size, Standard cargo only, sized to fit the loaner", () => {
|
it("pays the tutorial share of the normal payout", () => {
|
||||||
const board = generateTutorialBoard(atEarth, content);
|
for (const c of generateTutorialBoard(atEarth, content)) {
|
||||||
expect(board).toHaveLength(content.config.tutorialBoardSize);
|
expect(c.payout).toBe(Math.round(normalPayout(c) * config.tutorialPayoutShare));
|
||||||
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 exactly the tutorial share of the equivalent normal payout", () => {
|
it("gives an identical board for the same inputs, and a new one per run", () => {
|
||||||
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", () => {
|
|
||||||
expect(generateTutorialBoard(atEarth, content)).toEqual(
|
expect(generateTutorialBoard(atEarth, content)).toEqual(
|
||||||
generateTutorialBoard(atEarth, content),
|
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