Give every contract board jobs for each ship size
CI / check (push) Successful in 2m46s Details

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 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 12:22:02 -04:00
parent c9ce89ff38
commit c36d1edf55
9 changed files with 179 additions and 82 deletions

View File

@ -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`). - **"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`. - **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). - **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.

View File

@ -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. 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. 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. 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. 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. 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. 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. - 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. - 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. - **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 ### 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. - 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.

View File

@ -127,4 +127,5 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
### Playtest fixes after Phase 10A ### 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.

View File

@ -26,7 +26,13 @@ export const gameConfig: GameConfig = {
contractBoardPeriodRealMinutes: 60, contractBoardPeriodRealMinutes: 60,
firstContactChance: 0.1, firstContactChance: 0.1,
firstContactPayoutMultiplier: 2, 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. // Mars at its closest needs no Long Haul; Jupiter at its farthest needs level 5.
longHaulCutoffsMillionKm: [100, 250, 400, 600, 800], longHaulCutoffsMillionKm: [100, 250, 400, 600, 800],
xpPerDelivery: { base: 10, perMillionKm: 0.2, perCargoTier: 5 }, xpPerDelivery: { base: 10, perMillionKm: 0.2, perCargoTier: 5 },

View File

@ -92,7 +92,8 @@ export const skillSchema = z.object({
}); });
export type Skill = z.infer<typeof skillSchema>; export type Skill = z.infer<typeof skillSchema>;
export const gameConfigSchema = z.object({ export const gameConfigSchema = z
.object({
/** Game hours per real hour (60 means 1 real minute = 1 game hour). */ /** Game hours per real hour (60 means 1 real minute = 1 game hour). */
timeCompression: z.number().positive(), timeCompression: z.number().positive(),
/** Decision 0.5.9: the real instant the game clock starts and the in-game date it maps to. */ /** Decision 0.5.9: the real instant the game clock starts and the in-game date it maps to. */
@ -129,9 +130,22 @@ export const gameConfigSchema = z.object({
contractBoardPeriodRealMinutes: z.number().positive(), contractBoardPeriodRealMinutes: z.number().positive(),
firstContactChance: fraction, firstContactChance: fraction,
firstContactPayoutMultiplier: z.number().positive(), firstContactPayoutMultiplier: z.number().positive(),
contractMassTonnes: z /**
.object({ min: z.number().positive(), max: z.number().positive() }) * Contract mass bands, one per ship size class: each board has `count`
.refine((range) => range.min <= range.max, { message: "min must not exceed max" }), * 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). */ /** One cut-off per Long Haul level (decision 0.5.7). */
longHaulCutoffsMillionKm: increasingList, longHaulCutoffsMillionKm: increasingList,
xpPerDelivery: z.object({ xpPerDelivery: z.object({
@ -146,7 +160,13 @@ export const gameConfigSchema = z.object({
pilotHireCost: nonNegativeInt, pilotHireCost: nonNegativeInt,
/** Credits per delivery; the avatar is exempt (decision 0.5.12). */ /** Credits per delivery; the avatar is exempt (decision 0.5.12). */
pilotPayPerDelivery: nonNegativeInt, 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<typeof gameConfigSchema>; export type GameConfig = z.infer<typeof gameConfigSchema>;
/** /**

View File

@ -57,4 +57,14 @@ describe("game config", () => {
expect(gameConfig.dealLoanAmount).toBeGreaterThan(0); expect(gameConfig.dealLoanAmount).toBeGreaterThan(0);
expect(gameConfig.dealLoanAmount).toBe(courier.price + earth.bayLeasePrice); 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);
});
}); });

View File

@ -63,7 +63,10 @@ const validGameConfig = {
contractBoardPeriodRealMinutes: 60, contractBoardPeriodRealMinutes: 60,
firstContactChance: 0.1, firstContactChance: 0.1,
firstContactPayoutMultiplier: 2, 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], longHaulCutoffsMillionKm: [100, 200, 300, 400, 500],
xpPerDelivery: { base: 10, perMillionKm: 0.1, perCargoTier: 5 }, xpPerDelivery: { base: 10, perMillionKm: 0.1, perCargoTier: 5 },
pilotLevelXpThresholds: [100, 250, 500], pilotLevelXpThresholds: [100, 250, 500],
@ -127,8 +130,19 @@ describe("GameConfig refinements", () => {
expect(onboardingGoalSchema.safeParse(goal).success).toBe(false); expect(onboardingGoalSchema.safeParse(goal).success).toBe(false);
}); });
it("rejects a mass range with min above max", () => { it("rejects a mass band with min above max", () => {
const config = { ...validGameConfig, contractMassTonnes: { min: 50, max: 10 } }; 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); expect(gameConfigSchema.safeParse(config).success).toBe(false);
}); });

View File

@ -108,7 +108,11 @@ function pickTier(rng: Rng, cargoType: CargoType, content: SimContent): number {
return rng.int(1, skill.maxLevel); 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[] { export function generateContractBoard(input: BoardInput, content: SimContent): Contract[] {
const { origin, discoveredIds, periodIndex, worldSeed } = input; const { origin, discoveredIds, periodIndex, worldSeed } = input;
const { config } = content; 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 discovered = others.filter((l) => discoveredIds.has(l.id));
const undiscovered = others.filter((l) => !discoveredIds.has(l.id)); const undiscovered = others.filter((l) => !discoveredIds.has(l.id));
const board: Contract[] = []; // One slot per contract, band by band. The first slot of each band is a
for (let i = 0; i < config.contractBoardSize; i++) { // 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 = const firstContact =
!plain &&
undiscovered.length > 0 && undiscovered.length > 0 &&
(discovered.length === 0 || rng.next() < config.firstContactChance); (discovered.length === 0 || rng.next() < config.firstContactChance);
const destination = rng.pick(firstContact ? undiscovered : discovered); const destination = rng.pick(firstContact ? undiscovered : discovered);
const cargoType = pickCargo(rng, origin, destination, content); const cargoType = plain ? standardCargo(content) : pickCargo(rng, origin, destination, content);
board.push( return buildContract(
buildContract(
{ {
id: `${origin.id}:${periodIndex}:${i}`, id: `${origin.id}:${periodIndex}:${i}`,
origin, origin,
destination, destination,
cargoType, cargoType,
tier: pickTier(rng, cargoType, content), tier: pickTier(rng, cargoType, content),
massTonnes: rng.int(config.contractMassTonnes.min, config.contractMassTonnes.max), massTonnes: rng.int(band.min, band.max),
firstContact, firstContact,
periodIndex, periodIndex,
}, },
content, content,
),
); );
} });
return board;
} }

View File

@ -51,8 +51,37 @@ describe("contract board generation", () => {
expect(c.tier).toBeLessThanOrEqual(5); expect(c.tier).toBeLessThanOrEqual(5);
expect(c.requiredSkill?.level).toBe(c.tier); expect(c.requiredSkill?.level).toBe(c.tier);
} }
expect(c.massTonnes).toBeGreaterThanOrEqual(content.config.contractMassTonnes.min); const bands = content.config.contractMassBands;
expect(c.massTonnes).toBeLessThanOrEqual(content.config.contractMassTonnes.max); 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);
} }
} }
}); });