From c36d1edf550465e2faed0e0739d0f7b427a7c39e Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 12:22:02 -0400 Subject: [PATCH] Give every contract board jobs for each ship size Bay contract mass now comes from per-ship-size bands, and the first contract in each band is a no-skill Standard job to a discovered location, so a new player with only a Courier always has work. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ memory-bank/implementation-plan.md | 3 +- memory-bank/progress.md | 3 +- packages/content/src/data/config.ts | 8 +- packages/content/src/schemas.ts | 128 +++++++++++++++----------- packages/content/test/config.test.ts | 10 ++ packages/content/test/schemas.test.ts | 20 +++- packages/sim/src/board.ts | 49 ++++++---- packages/sim/test/board.test.ts | 33 ++++++- 9 files changed, 179 insertions(+), 82 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index f69542e..75a7d80 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -604,3 +604,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **"4 of 4" with no deal:** the user's account played through code being changed mid-phase (the dev server reloads), so its first tutorial run was flown before the run counter existed and the count ran one behind; after the fourth counted run the loaner was at the Moon. On finished code the deal appears after run 4. HQ now explains the remaining step when enough runs are done but the loaner is not at Earth (`tutorialHint`). - **The dispatch sheet looked "stuck" on the first job:** it did change per job, but showed only "Earth → Moon" and the first failing check, so every over-heavy job looked identical. The sheet now shows the job's cargo, tier, mass and required skills, and lists every problem with numbers ("Too heavy: 228 t (holds 20 t)", "Needs Expensive Cargo 2 (pilot has 0)", range, Long Haul, fuel). Eligibility is still decided by sim `validateDispatch`. - **Still open:** most post-deal boards have no contract a Courier can carry (see Step 10A.18). + +## Contract size bands (playtest fix, user's choice) + +- **Bay contract mass comes from bands, one per ship size** (`contractMassBands` in GameConfig, replacing `contractMassTonnes`): 3 contracts of 5–20 t (Courier), 3 of 21–100 t (Freighter), 2 of 101–400 t (Bulk Hauler). The counts must add up to `contractBoardSize` (schema check). Chosen by the user over shrinking the range or fitting boards to the player's fleet. +- **The first contract of each band is a guaranteed job:** Standard cargo (no skill) to an already-discovered location, never first contact. A new pilot has no skill points (tutorial XP does not reach level 1), so without this a Courier-sized job could still need a skill. The other slots are drawn as before. First contact now applies to 5 of 8 slots, about 6% of contracts, inside the 5–15% the Step 3.9 test expects. +- **Boards list contracts band by band** (small to large), so a new player sees Courier-sized work first. +- **Existing stored boards are unchanged** until their period ends (at most an hour); new boards use the bands. diff --git a/memory-bank/implementation-plan.md b/memory-bank/implementation-plan.md index a546506..6206246 100644 --- a/memory-bank/implementation-plan.md +++ b/memory-bank/implementation-plan.md @@ -61,7 +61,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple 5. **Contract boards are stored.** A bay's board is generated on the first request in a period and stored. Later requests in that period return the stored board, so a discovery mid-period does not change it. Every contract expires at the end of its board period; nothing carries over. 6. **Cargo tier.** Standard is tier 0. A non-Standard contract has a tier from 1 to 5, and that tier is the required level in the cargo's skill. 7. **Long Haul.** A config list of distance cut-offs, one per Long Haul level. The required Long Haul level is the number of cut-offs the route distance exceeds. -8. **Config-driven tuning.** First-contact chance, first-contact multiplier, contract mass range, and Long Haul cut-offs all live in GameConfig. +8. **Config-driven tuning.** First-contact chance, first-contact multiplier, contract mass range, and Long Haul cut-offs all live in GameConfig. *(Amended after the CR-001 playtest: the single mass range is replaced by per-ship-size mass bands, `contractMassBands`.)* 9. **Game epoch.** Two fields: a real start timestamp and the in-game ephemeris date it maps to. The clock is global for all players. 10. **World seed** is per player and is also used for pilot and ship names. 11. **Payout** is fixed when a contract is offered, using the distance at that moment. Travel time and fuel use the distance at departure. @@ -203,6 +203,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple - Add a function that generates a bay's contract board from: the bay's location, the set of discovered locations, the board period index (real time ÷ board period), and the player's world seed. - Rules: the board has the configured number of contracts; destinations are mostly discovered locations; cargo type is chosen from types the origin produces and the destination demands, falling back to Standard if none match; the configured first-contact chance decides whether a contract targets a random undiscovered location instead (flagged first-contact); mass comes from the configured range; non-Standard contracts get a tier from 1 to 5, which is their required skill level; required Long Haul level comes from the distance cut-offs; every contract expires at the end of the board period. - **Test:** The same inputs always produce an identical board. A different period index produces a different board. No contract has origin equal to destination. Over 1,000 generated boards, first-contact contracts are between 5% and 15% of all contracts and every one targets an undiscovered location. +- **Amended after the CR-001 playtest:** contract mass comes from per-ship-size bands (`contractMassBands`, e.g. 3 × 5–20 t, 3 × 21–100 t, 2 × 101–400 t), and the first contract in each band is Standard cargo to a discovered location, so every ship size always has a job a new pilot can fly. ### Step 3.10 — Tutorial contract board - Add a separate generator for the tutorial "company board": Earth↔Moon only, Standard cargo only, payout reduced by the tutorial payout share, always flown with the company loaner Courier. Contracts start at the loaner's current location: Earth→Moon when it is at Earth, Moon→Earth when it is at the Moon. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 601e141..b01eb92 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -127,4 +127,5 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a ### Playtest fixes after Phase 10A -- [x] HQ tutorial hint when enough runs are done but the loaner is away from Earth; dispatch sheet shows job details and lists every problem with numbers; onboarding test race fixed. Open: post-deal boards rarely have Courier-sized contracts (awaiting decision). +- [x] HQ tutorial hint when enough runs are done but the loaner is away from Earth; dispatch sheet shows job details and lists every problem with numbers; onboarding test race fixed. +- [x] Contract size bands (user's choice): `contractMassBands` gives every board jobs for each ship size, each band starting with a no-skill Standard job to a discovered place. Smoke test: guest → 4 runs → deal → a Courier job resolves with a loan repayment. diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index a6bf077..525a9bf 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -26,7 +26,13 @@ export const gameConfig: GameConfig = { contractBoardPeriodRealMinutes: 60, firstContactChance: 0.1, firstContactPayoutMultiplier: 2, - contractMassTonnes: { min: 5, max: 400 }, + // One band per ship size, so every board has work for every ship (playtest fix): + // up to a Courier's 20 t, a Freighter's 100 t, and a Bulk Hauler's 400 t. + contractMassBands: [ + { min: 5, max: 20, count: 3 }, + { min: 21, max: 100, count: 3 }, + { min: 101, max: 400, count: 2 }, + ], // Mars at its closest needs no Long Haul; Jupiter at its farthest needs level 5. longHaulCutoffsMillionKm: [100, 250, 400, 600, 800], xpPerDelivery: { base: 10, perMillionKm: 0.2, perCargoTier: 5 }, diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index 9f2e331..e898c4b 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -92,61 +92,81 @@ export const skillSchema = z.object({ }); export type Skill = z.infer; -export const gameConfigSchema = z.object({ - /** Game hours per real hour (60 means 1 real minute = 1 game hour). */ - timeCompression: z.number().positive(), - /** Decision 0.5.9: the real instant the game clock starts and the in-game date it maps to. */ - epoch: z.object({ - realStart: z.iso.datetime(), - ephemerisDate: z.iso.datetime(), - }), - startingCredits: nonNegativeInt, - /** Fraction of a normal payout the player receives for a tutorial run. */ - tutorialPayoutShare: fraction, - contractBoardSize: positiveInt, - /** Contracts per tutorial board: one per mass tier (CR-001). */ - tutorialBoardSize: positiveInt, - /** Ship model the company lends during the tutorial (decision 0.5.29). */ - tutorialLoanerShipModelId: idSchema, - /** Tutorial runs completed before the deal is offered (decision 0.5.30). */ - tutorialRunsBeforeDeal: positiveInt, - /** Cargo mass of the three tutorial contracts (decision 0.5.31). */ - tutorialMassTonnes: z - .object({ - light: z.number().positive(), - medium: z.number().positive(), - full: z.number().positive(), - }) - .refine((t) => t.light < t.medium && t.medium < t.full, { - message: "Tiers must increase: light < medium < full", +export const gameConfigSchema = z + .object({ + /** Game hours per real hour (60 means 1 real minute = 1 game hour). */ + timeCompression: z.number().positive(), + /** Decision 0.5.9: the real instant the game clock starts and the in-game date it maps to. */ + epoch: z.object({ + realStart: z.iso.datetime(), + ephemerisDate: z.iso.datetime(), }), - /** Loading and unloading time per tonne of cargo, in game hours (decision 0.5.31). */ - handlingGameHoursPerTonne: z.number().nonnegative(), - /** Share of each positive payout the lender takes until the loan is repaid (0.5.32). */ - loanRepaymentShare: fraction, - /** Loan taken on with the deal, in credits (decision 0.5.30). */ - dealLoanAmount: positiveInt, - contractBoardPeriodRealMinutes: z.number().positive(), - firstContactChance: fraction, - firstContactPayoutMultiplier: z.number().positive(), - contractMassTonnes: z - .object({ min: z.number().positive(), max: z.number().positive() }) - .refine((range) => range.min <= range.max, { message: "min must not exceed max" }), - /** One cut-off per Long Haul level (decision 0.5.7). */ - longHaulCutoffsMillionKm: increasingList, - xpPerDelivery: z.object({ - base: z.number().nonnegative(), - perMillionKm: z.number().nonnegative(), - perCargoTier: z.number().nonnegative(), - }), - /** Total XP needed to reach level 1, 2, 3, ... */ - pilotLevelXpThresholds: increasingList, - skillPointsPerLevel: positiveInt, - /** Credits. */ - pilotHireCost: nonNegativeInt, - /** Credits per delivery; the avatar is exempt (decision 0.5.12). */ - pilotPayPerDelivery: nonNegativeInt, -}); + startingCredits: nonNegativeInt, + /** Fraction of a normal payout the player receives for a tutorial run. */ + tutorialPayoutShare: fraction, + contractBoardSize: positiveInt, + /** Contracts per tutorial board: one per mass tier (CR-001). */ + tutorialBoardSize: positiveInt, + /** Ship model the company lends during the tutorial (decision 0.5.29). */ + tutorialLoanerShipModelId: idSchema, + /** Tutorial runs completed before the deal is offered (decision 0.5.30). */ + tutorialRunsBeforeDeal: positiveInt, + /** Cargo mass of the three tutorial contracts (decision 0.5.31). */ + tutorialMassTonnes: z + .object({ + light: z.number().positive(), + medium: z.number().positive(), + full: z.number().positive(), + }) + .refine((t) => t.light < t.medium && t.medium < t.full, { + message: "Tiers must increase: light < medium < full", + }), + /** Loading and unloading time per tonne of cargo, in game hours (decision 0.5.31). */ + handlingGameHoursPerTonne: z.number().nonnegative(), + /** Share of each positive payout the lender takes until the loan is repaid (0.5.32). */ + loanRepaymentShare: fraction, + /** Loan taken on with the deal, in credits (decision 0.5.30). */ + dealLoanAmount: positiveInt, + contractBoardPeriodRealMinutes: z.number().positive(), + firstContactChance: fraction, + firstContactPayoutMultiplier: z.number().positive(), + /** + * Contract mass bands, one per ship size class: each board has `count` + * contracts with a whole-tonne mass in [min, max]. Counts add up to the + * board size (see the refinement below). + */ + contractMassBands: z + .array( + z + .object({ + min: z.number().int().positive(), + max: z.number().int().positive(), + count: positiveInt, + }) + .refine((band) => band.min <= band.max, { message: "min must not exceed max" }), + ) + .min(1), + /** One cut-off per Long Haul level (decision 0.5.7). */ + longHaulCutoffsMillionKm: increasingList, + xpPerDelivery: z.object({ + base: z.number().nonnegative(), + perMillionKm: z.number().nonnegative(), + perCargoTier: z.number().nonnegative(), + }), + /** Total XP needed to reach level 1, 2, 3, ... */ + pilotLevelXpThresholds: increasingList, + skillPointsPerLevel: positiveInt, + /** Credits. */ + pilotHireCost: nonNegativeInt, + /** Credits per delivery; the avatar is exempt (decision 0.5.12). */ + pilotPayPerDelivery: nonNegativeInt, + }) + .refine( + (config) => + config.contractMassBands.reduce((sum, band) => sum + band.count, 0) === + config.contractBoardSize, + { message: "Mass band counts must add up to contractBoardSize", path: ["contractMassBands"] }, + ); export type GameConfig = z.infer; /** diff --git a/packages/content/test/config.test.ts b/packages/content/test/config.test.ts index d793d56..da8e85f 100644 --- a/packages/content/test/config.test.ts +++ b/packages/content/test/config.test.ts @@ -57,4 +57,14 @@ describe("game config", () => { expect(gameConfig.dealLoanAmount).toBeGreaterThan(0); expect(gameConfig.dealLoanAmount).toBe(courier.price + earth.bayLeasePrice); }); + + it("has a contract mass band each purchasable ship can carry", () => { + for (const ship of shipModels.filter((s) => s.purchasable)) { + expect( + gameConfig.contractMassBands.some((band) => band.max <= ship.cargoCapacityTonnes), + ).toBe(true); + } + const total = gameConfig.contractMassBands.reduce((sum, b) => sum + b.count, 0); + expect(total).toBe(gameConfig.contractBoardSize); + }); }); diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index fbe427d..a5683f3 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -63,7 +63,10 @@ const validGameConfig = { contractBoardPeriodRealMinutes: 60, firstContactChance: 0.1, firstContactPayoutMultiplier: 2, - contractMassTonnes: { min: 1, max: 100 }, + contractMassBands: [ + { min: 1, max: 20, count: 4 }, + { min: 21, max: 100, count: 4 }, + ], longHaulCutoffsMillionKm: [100, 200, 300, 400, 500], xpPerDelivery: { base: 10, perMillionKm: 0.1, perCargoTier: 5 }, pilotLevelXpThresholds: [100, 250, 500], @@ -127,8 +130,19 @@ describe("GameConfig refinements", () => { expect(onboardingGoalSchema.safeParse(goal).success).toBe(false); }); - it("rejects a mass range with min above max", () => { - const config = { ...validGameConfig, contractMassTonnes: { min: 50, max: 10 } }; + it("rejects a mass band with min above max", () => { + const config = { + ...validGameConfig, + contractMassBands: [ + { min: 50, max: 10, count: 4 }, + { min: 21, max: 100, count: 4 }, + ], + }; + expect(gameConfigSchema.safeParse(config).success).toBe(false); + }); + + it("rejects mass band counts that do not add up to the board size", () => { + const config = { ...validGameConfig, contractMassBands: [{ min: 1, max: 20, count: 3 }] }; expect(gameConfigSchema.safeParse(config).success).toBe(false); }); diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index 5d19345..4b7dfde 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -108,7 +108,11 @@ function pickTier(rng: Rng, cargoType: CargoType, content: SimContent): number { return rng.int(1, skill.maxLevel); } -/** Generates a bay's contract board for one period. Same inputs always give the same board. */ +/** + * Generates a bay's contract board for one period: `contractMassBands` decide how + * many contracts fall in each ship-size mass band. Same inputs always give the + * same board. + */ export function generateContractBoard(input: BoardInput, content: SimContent): Contract[] { const { origin, discoveredIds, periodIndex, worldSeed } = input; const { config } = content; @@ -118,28 +122,33 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C const discovered = others.filter((l) => discoveredIds.has(l.id)); const undiscovered = others.filter((l) => !discoveredIds.has(l.id)); - const board: Contract[] = []; - for (let i = 0; i < config.contractBoardSize; i++) { + // One slot per contract, band by band. The first slot of each band is a + // guaranteed job: Standard cargo (no skill) to a discovered place, so every + // ship size always has something a new pilot can fly. + const slots = config.contractMassBands.flatMap((band) => + Array.from({ length: band.count }, (_, k) => ({ band, guaranteed: k === 0 })), + ); + + return slots.map(({ band, guaranteed }, i) => { + const plain = guaranteed && discovered.length > 0; const firstContact = + !plain && undiscovered.length > 0 && (discovered.length === 0 || rng.next() < config.firstContactChance); const destination = rng.pick(firstContact ? undiscovered : discovered); - const cargoType = pickCargo(rng, origin, destination, content); - board.push( - buildContract( - { - id: `${origin.id}:${periodIndex}:${i}`, - origin, - destination, - cargoType, - tier: pickTier(rng, cargoType, content), - massTonnes: rng.int(config.contractMassTonnes.min, config.contractMassTonnes.max), - firstContact, - periodIndex, - }, - content, - ), + const cargoType = plain ? standardCargo(content) : pickCargo(rng, origin, destination, content); + return buildContract( + { + id: `${origin.id}:${periodIndex}:${i}`, + origin, + destination, + cargoType, + tier: pickTier(rng, cargoType, content), + massTonnes: rng.int(band.min, band.max), + firstContact, + periodIndex, + }, + content, ); - } - return board; + }); } diff --git a/packages/sim/test/board.test.ts b/packages/sim/test/board.test.ts index dd27173..28e641b 100644 --- a/packages/sim/test/board.test.ts +++ b/packages/sim/test/board.test.ts @@ -51,8 +51,37 @@ describe("contract board generation", () => { expect(c.tier).toBeLessThanOrEqual(5); expect(c.requiredSkill?.level).toBe(c.tier); } - expect(c.massTonnes).toBeGreaterThanOrEqual(content.config.contractMassTonnes.min); - expect(c.massTonnes).toBeLessThanOrEqual(content.config.contractMassTonnes.max); + const bands = content.config.contractMassBands; + expect(c.massTonnes).toBeGreaterThanOrEqual(Math.min(...bands.map((b) => b.min))); + expect(c.massTonnes).toBeLessThanOrEqual(Math.max(...bands.map((b) => b.max))); + } + } + }); + + it("fills each mass band with its configured number of contracts", () => { + const board = generateContractBoard(input, content); + let slot = 0; + for (const band of content.config.contractMassBands) { + for (let k = 0; k < band.count; k++, slot++) { + expect(board[slot]!.massTonnes).toBeGreaterThanOrEqual(band.min); + expect(board[slot]!.massTonnes).toBeLessThanOrEqual(band.max); + } + } + expect(slot).toBe(board.length); + }); + + it("always offers each ship size a Standard job to a discovered place (no skill needed)", () => { + for (let p = 0; p < 500; p++) { + const board = generateContractBoard({ ...input, periodIndex: p }, content); + for (const ship of content.shipModels.filter((s) => s.purchasable)) { + const flyable = board.some( + (c) => + c.massTonnes <= ship.cargoCapacityTonnes && + c.requiredSkill === null && + !c.firstContact && + discoveredIds.has(c.destinationId), + ); + expect(flyable).toBe(true); } } });