Step 10F.3: boards fit the player's fleet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dc24bdeb7a
commit
215f380e80
|
|
@ -882,3 +882,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
## Step 10F.1
|
||||
|
||||
- **"Your ship" means the ships at the board's location**, because those are the ones that can take the job now; with none there, the whole fleet (one of them could deadhead in). Ships in flight have no location, so they only count in the fallback.
|
||||
|
||||
## Step 10F.3
|
||||
|
||||
- **Freed slots go to the largest flyable band first.** With a Courier and a Freighter, the Bulk Hauler band's spare slot becomes a Freighter-band job, since the biggest ship you own earns the most per trip. With one Courier everything lands in the Courier band.
|
||||
- **A band counts as flyable if some ship can carry its minimum**, and a fit job's mass is capped at the chosen ship's capacity; so a Freighter also gets work from a band whose top it can't carry.
|
||||
- **Fit destinations are checked at the board's start time**, like the contract's own distance. A route at the edge of a ship's range could drift out during the 10-minute period; dispatch validation still decides.
|
||||
- **An uncrewed ship counts with no skills**, as if crewed by a fresh hire, so buying a ship immediately shapes the next board.
|
||||
|
|
|
|||
|
|
@ -195,3 +195,4 @@ Spec: `memory-bank/change-requests/CR-006-boards-that-fit.md`. New decisions rec
|
|||
|
||||
- [x] **Step 10F.1 — Client: weight warning.** `carryLimit(state, locationId)` (`LocationBoards.tsx`): the largest capacity among the player's ships at the location, else the whole fleet, else null. Contract cards show the mass in rose and a rose "Too heavy" pill when it is over that limit. Test: `weight-warning.test.tsx`.
|
||||
- [x] **Step 10F.2 — Content: board config.** GameConfig gains `boardPreviewSlots` (1) and `boardOpenSlotsPerBand` (1), non-negative integers. Test: `schemas.test.ts` (missing-field coverage plus a negative/fractional rejection test).
|
||||
- [x] **Step 10F.3 — Sim: fleet-aware boards.** `BoardInput.fleet?: FleetMember[]` (`{ modelId, skills }`, exported). `planSlots` (board.ts): with no usable fleet, the old plan (each band's count, first slot guaranteed); otherwise bands no ship can carry keep `boardPreviewSlots` (their first is guaranteed), freed slots go to flyable bands largest-first round-robin, and flyable bands are fit slots but for `boardOpenSlotsPerBand` open ones. `fitJob` picks a crew that can carry the band (random start, then the next), a discovered destination it can reach and land at with its Long Haul, cargo the origin produces and the destination demands that the pilot is skilled for (else Standard), tier 1..pilot level, mass up to the ship's capacity; never first contact. A fit slot no crew can fill falls back to a guaranteed slot. No fleet: identical boards and RNG order (existing tests unchanged). Test: `board-fleet.test.ts` (empty fleet = old board; determinism; a lone Courier gets ≥ 5 flyable jobs on every one of 200 boards plus a preview per bigger band; skill cargo never above the pilot's level; a full fleet keeps band counts with fit jobs per band; expiry).
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import type {
|
|||
import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock";
|
||||
import { contractPayout, type Contract } from "./contract";
|
||||
import { routeDistance } from "./distance";
|
||||
import { checkRoute } from "./eligibility";
|
||||
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
|
||||
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
|
||||
|
||||
/** The static data the sim needs. The `content` bundle satisfies this. */
|
||||
|
|
@ -41,12 +43,109 @@ export function standardCargo(content: SimContent): CargoType {
|
|||
return standard;
|
||||
}
|
||||
|
||||
/** One of the player's ships, with its pilot's skills (none without a pilot). */
|
||||
export interface FleetMember {
|
||||
modelId: string;
|
||||
skills: SkillLevels;
|
||||
}
|
||||
|
||||
export interface BoardInput {
|
||||
/** The location whose freight market this is; every contract starts here (CR-002). */
|
||||
location: Location;
|
||||
discoveredIds: ReadonlySet<string>;
|
||||
periodIndex: number;
|
||||
worldSeed: Seed;
|
||||
/** The player's ships (CR-006). Absent or empty: a board that ignores the fleet. */
|
||||
fleet?: readonly FleetMember[];
|
||||
}
|
||||
|
||||
type MassBand = GameConfig["contractMassBands"][number];
|
||||
type SlotKind = "guaranteed" | "open" | "fit";
|
||||
interface Crew {
|
||||
model: ShipModel;
|
||||
skills: SkillLevels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which contracts a board has, band by band (decision 0.5.52). Without a crew
|
||||
* every band keeps its count and opens with a guaranteed job. With one, a band
|
||||
* no ship can carry shrinks to a preview, its other slots go to the flyable
|
||||
* bands (largest first, round-robin), and flyable bands are fit jobs but for
|
||||
* `boardOpenSlotsPerBand` open ones.
|
||||
*/
|
||||
function planSlots(crews: readonly Crew[], config: GameConfig) {
|
||||
const bands = config.contractMassBands;
|
||||
const flyable = bands.map((band) => crews.some((c) => c.model.cargoCapacityTonnes >= band.min));
|
||||
if (!flyable.some(Boolean)) {
|
||||
return bands.flatMap((band) =>
|
||||
Array.from({ length: band.count }, (_, k) => ({
|
||||
band,
|
||||
kind: (k === 0 ? "guaranteed" : "open") as SlotKind,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const counts = bands.map((band, i) =>
|
||||
flyable[i] ? band.count : Math.min(band.count, config.boardPreviewSlots),
|
||||
);
|
||||
const targets = bands
|
||||
.map((_, i) => i)
|
||||
.filter((i) => flyable[i])
|
||||
.reverse();
|
||||
let freed = bands.reduce((sum, band, i) => sum + band.count - counts[i]!, 0);
|
||||
for (let k = 0; freed > 0; k++, freed--) counts[targets[k % targets.length]!]!++;
|
||||
|
||||
return bands.flatMap((band, i) => {
|
||||
const fit = flyable[i] ? Math.max(0, counts[i]! - config.boardOpenSlotsPerBand) : 0;
|
||||
return Array.from({ length: counts[i]! }, (_, k) => ({
|
||||
band,
|
||||
kind: (k < fit ? "fit" : !flyable[i] && k === 0 ? "guaranteed" : "open") as SlotKind,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A job one crew can take (decision 0.5.52): within its capacity, to a
|
||||
* discovered place it can reach and land at with its Long Haul, with cargo its
|
||||
* pilot is skilled for. Crews are tried from a random start; null if none fits.
|
||||
*/
|
||||
function fitJob(
|
||||
rng: Rng,
|
||||
band: MassBand,
|
||||
crews: readonly Crew[],
|
||||
origin: Location,
|
||||
discovered: readonly Location[],
|
||||
offeredAt: RealTime,
|
||||
content: SimContent,
|
||||
) {
|
||||
const gameTime = realToGame(offeredAt, content.config);
|
||||
const able = crews.filter((c) => c.model.cargoCapacityTonnes >= band.min);
|
||||
if (able.length === 0) return null;
|
||||
const start = rng.int(0, able.length - 1);
|
||||
for (let k = 0; k < able.length; k++) {
|
||||
const { model, skills } = able[(start + k) % able.length]!;
|
||||
const reachable = discovered.filter(
|
||||
(d) =>
|
||||
checkRoute(model, origin, d, gameTime).ok &&
|
||||
requiredLongHaulLevel(routeDistance(origin, d, gameTime), content.config) <=
|
||||
skillLevel(skills, LONG_HAUL_SKILL_ID),
|
||||
);
|
||||
if (reachable.length === 0) continue;
|
||||
const destination = rng.pick(reachable);
|
||||
const skilled = content.cargoTypes.filter(
|
||||
(c) =>
|
||||
origin.produces.includes(c.id) &&
|
||||
destination.demands.includes(c.id) &&
|
||||
(c.requiredSkillId === null || skillLevel(skills, c.requiredSkillId) >= 1),
|
||||
);
|
||||
const cargoType = skilled.length > 0 ? rng.pick(skilled) : standardCargo(content);
|
||||
const tier =
|
||||
cargoType.requiredSkillId === null
|
||||
? 0
|
||||
: rng.int(1, skillLevel(skills, cargoType.requiredSkillId));
|
||||
const massTonnes = rng.int(band.min, Math.min(band.max, model.cargoCapacityTonnes));
|
||||
return { destination, cargoType, tier, massTonnes };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Builds one contract from a chosen route, cargo, tier and mass. */
|
||||
|
|
@ -112,8 +211,8 @@ function pickTier(rng: Rng, cargoType: CargoType, content: SimContent): number {
|
|||
/**
|
||||
* Generates a location's contract board for one period (any discovered
|
||||
* location, bay or not, CR-002): `contractMassBands` decide how
|
||||
* many contracts fall in each ship-size mass band. Same inputs always give the
|
||||
* same board.
|
||||
* many contracts fall in each ship-size mass band, shaped by the player's fleet
|
||||
* when given (CR-006). Same inputs always give the same board.
|
||||
*/
|
||||
export function generateContractBoard(input: BoardInput, content: SimContent): Contract[] {
|
||||
const { location: origin, discoveredIds, periodIndex, worldSeed } = input;
|
||||
|
|
@ -124,14 +223,23 @@ 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));
|
||||
|
||||
// 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 })),
|
||||
);
|
||||
const crews: Crew[] = (input.fleet ?? []).flatMap((f) => {
|
||||
const model = content.shipModels.find((m) => m.id === f.modelId);
|
||||
return model ? [{ model, skills: f.skills }] : [];
|
||||
});
|
||||
const offeredAt = periodIndex * boardPeriodMs(config);
|
||||
|
||||
return slots.map(({ band, guaranteed }, i) => {
|
||||
// One slot per contract, band by band. A guaranteed slot is Standard cargo
|
||||
// (no skill) to a discovered place, so every ship size always has something
|
||||
// a new pilot can fly; fit slots are built for the player's own ships.
|
||||
return planSlots(crews, config).map(({ band, kind }, i) => {
|
||||
const id = `${origin.id}:${periodIndex}:${i}`;
|
||||
const fit =
|
||||
kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null;
|
||||
if (fit) {
|
||||
return buildContract({ id, origin, ...fit, firstContact: false, periodIndex }, content);
|
||||
}
|
||||
const guaranteed = kind !== "open";
|
||||
const plain = guaranteed && discovered.length > 0;
|
||||
const firstContact =
|
||||
!plain &&
|
||||
|
|
@ -141,7 +249,7 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
|
|||
const cargoType = plain ? standardCargo(content) : pickCargo(rng, origin, destination, content);
|
||||
return buildContract(
|
||||
{
|
||||
id: `${origin.id}:${periodIndex}:${i}`,
|
||||
id,
|
||||
origin,
|
||||
destination,
|
||||
cargoType,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export {
|
|||
generateContractBoard,
|
||||
requiredLongHaulLevel,
|
||||
} from "./board";
|
||||
export type { BoardInput, SimContent } from "./board";
|
||||
export type { BoardInput, FleetMember, SimContent } from "./board";
|
||||
|
||||
export { generateTutorialBoard, loanerModel } from "./tutorial";
|
||||
export type { TutorialBoardInput, TutorialLocationId } from "./tutorial";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
boardPeriodMs,
|
||||
checkPilotEligibility,
|
||||
checkRoute,
|
||||
generateContractBoard,
|
||||
realToGame,
|
||||
type BoardInput,
|
||||
type Contract,
|
||||
type FleetMember,
|
||||
} from "../src/index";
|
||||
|
||||
const earth = content.location("earth")!;
|
||||
const discoveredIds = new Set(["earth", "moon", "mars"]);
|
||||
const base: BoardInput = { location: earth, discoveredIds, periodIndex: 500, worldSeed: 777 };
|
||||
const { config } = content;
|
||||
const bands = config.contractMassBands;
|
||||
|
||||
/** Whether this crew can fly the contract (the sim's own route and pilot rules). */
|
||||
function fits(contract: Contract, member: FleetMember): boolean {
|
||||
const model = content.shipModel(member.modelId)!;
|
||||
const gameTime = realToGame(contract.offeredAt, config);
|
||||
return (
|
||||
!contract.firstContact &&
|
||||
contract.massTonnes <= model.cargoCapacityTonnes &&
|
||||
checkRoute(model, earth, content.location(contract.destinationId)!, gameTime).ok &&
|
||||
checkPilotEligibility(member.skills, contract).ok
|
||||
);
|
||||
}
|
||||
|
||||
const courier: FleetMember = { modelId: "courier", skills: {} };
|
||||
|
||||
describe("boards that fit the fleet (CR-006)", () => {
|
||||
it("ignores an empty fleet: the same board as with no fleet", () => {
|
||||
expect(generateContractBoard({ ...base, fleet: [] }, content)).toEqual(
|
||||
generateContractBoard(base, content),
|
||||
);
|
||||
});
|
||||
|
||||
it("is deterministic with a fleet", () => {
|
||||
const input = { ...base, fleet: [courier] };
|
||||
expect(generateContractBoard(input, content)).toEqual(generateContractBoard(input, content));
|
||||
});
|
||||
|
||||
it("gives a lone Courier mostly jobs it can fly, keeping one preview per bigger band", () => {
|
||||
const previews = (bands.length - 1) * config.boardPreviewSlots;
|
||||
const expectedFit = config.contractBoardSize - previews - config.boardOpenSlotsPerBand;
|
||||
for (let p = 0; p < 200; p++) {
|
||||
const board = generateContractBoard({ ...base, periodIndex: p, fleet: [courier] }, content);
|
||||
expect(board).toHaveLength(config.contractBoardSize);
|
||||
expect(board.filter((c) => fits(c, courier)).length).toBeGreaterThanOrEqual(expectedFit);
|
||||
// Each band the Courier can't carry still shows a preview.
|
||||
for (const band of bands.slice(1)) {
|
||||
expect(board.some((c) => c.massTonnes >= band.min && c.massTonnes <= band.max)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("offers skill cargo up to the pilot's level, never above", () => {
|
||||
const skilled: FleetMember = { modelId: "courier", skills: { "expensive-cargo": 3 } };
|
||||
let skillJobs = 0;
|
||||
for (let p = 0; p < 200; p++) {
|
||||
const board = generateContractBoard({ ...base, periodIndex: p, fleet: [skilled] }, content);
|
||||
for (const c of board.filter((c) => fits(c, skilled))) {
|
||||
if (c.requiredSkill) {
|
||||
skillJobs++;
|
||||
expect(c.requiredSkill.level).toBeLessThanOrEqual(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(skillJobs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the configured band counts when the fleet covers every band", () => {
|
||||
const fleet: FleetMember[] = ["courier", "freighter", "bulk-hauler"].map((modelId) => ({
|
||||
modelId,
|
||||
skills: {},
|
||||
}));
|
||||
const board = generateContractBoard({ ...base, fleet }, content);
|
||||
let slot = 0;
|
||||
for (const band of bands) {
|
||||
for (let k = 0; k < band.count; k++, slot++) {
|
||||
expect(board[slot]!.massTonnes).toBeGreaterThanOrEqual(band.min);
|
||||
expect(board[slot]!.massTonnes).toBeLessThanOrEqual(band.max);
|
||||
}
|
||||
}
|
||||
for (const [i, band] of bands.entries()) {
|
||||
const member = fleet[i]!;
|
||||
const inBand = board.filter((c) => c.massTonnes >= band.min && c.massTonnes <= band.max);
|
||||
const fitting = inBand.filter((c) => fits(c, member));
|
||||
expect(fitting.length).toBeGreaterThanOrEqual(band.count - config.boardOpenSlotsPerBand);
|
||||
}
|
||||
});
|
||||
|
||||
it("expires fit jobs with the board period", () => {
|
||||
const end = (base.periodIndex + 1) * boardPeriodMs(config);
|
||||
for (const c of generateContractBoard({ ...base, fleet: [courier] }, content)) {
|
||||
expect(c.expiresAt).toBe(end);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue