Fit jobs pick from all goods that fit; no undersized heavy lots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 18:20:56 -04:00
parent e924defb79
commit 4acf346005
6 changed files with 114 additions and 18 deletions

View File

@ -914,3 +914,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## CR-008 — Boards That Feel Fresh (Phase 10H)
- **User report: "the jobs aren't refreshing".** Investigated before changing anything: the server stores a new board every period (the user's dev database had one per 10-minute period, all different), and a real browser left on the board refetched one second after the timer hit zero and showed the new cards. The new boards just look the same for an early player: Earth → Moon runs of three goods, with identical pay for the same good and mass. The user picked three fixes from four offered: visible refresh, per-job pay rate, more early goods (not more open slots).
## Step 10H.4
- **Fit jobs choose a good, not a class.** Picking a class first (from `cargoClassMix`) and then a good meant a skilled pilot could be locked into one class whose only route good didn't fit the ship: a Heavy Cargo 1 Courier at the Moon got nothing but 5–20 t "Lunar Titanium" (a 30–120 t good). Fit jobs now pick among all qualified goods on the route that fit the slot; the board's class mix for non-fit jobs is untouched.
- **No undersized lots.** When every good of the chosen class is too heavy for a slot, the slot carries a Standard good on the route that fits instead (the CR-007 fallback, extended to mass). Goods too light for a big band still make bulk lots, as the user chose for CR-007; the Earth class-mix test (±3 points) still passes.

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

View File

@ -232,6 +232,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Add a payout function: base pay per tonne per million km × tier multiplier × mass × distance, times the first-contact multiplier when flagged. Payout is fixed at offer time using the distance at that moment (decision 0.5.11).
- **Test:** Payout scales linearly with mass and distance. Dangerous cargo pays more than Standard for identical mass and route. A first-contact contract pays more than an otherwise identical normal contract.
- **Amended by CR-007:** Contracts carry `goodId`, `shipper`, `priority` and `deliveryWindowGameHours`; payout includes the good's pay multiplier and the Priority bonus (Step 10G.2).
- **Amended by CR-008:** Board contracts multiply their payout by a market rate within ±`contractPayVariance` of 1 (tutorial contracts don't) (Step 10H.2).
### Step 3.9 — Contract board generation
- 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.
@ -469,6 +470,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- **Amended by CR-002:** The selector lists every discovered location with an idle-ship badge, defaulting to the most recently arrived idle ship's location, else Earth; Fleet gets "Find work here" (Step 10B.7).
- **Amended by CR-006:** A contract's mass is highlighted, with a "Too heavy" tag, when none of the player's ships at the location (or in the fleet, when none are there) can carry it (decision 0.5.53, Step 10F.1).
- **Amended by CR-007:** Cards headline the good with class/tier, Priority and Too slow chips, and show shipper · mass · distance (Step 10G.8).
- **Amended by CR-008:** When a location's board changes, the screen says "New contracts posted" and tags contracts not on the last board seen there "New" (Step 10H.3).
### Step 9.5 — Dispatch flow
- Selecting a contract opens a dispatch sheet: choose an eligible ship+pilot pair at the origin. Show trip time, fuel cost, net profit, and ETA in both game time and real time. Ineligible pairs are listed but disabled with the reason shown (out of range, can't land, skill too low, too heavy).

View File

@ -220,3 +220,4 @@ Spec: `memory-bank/change-requests/CR-008-fresh-boards.md`. New decisions record
- [x] **Step 10H.1 — Content: pay variance and new goods.** Config `contractPayVariance` 0.15. Five Standard goods (decision 0.5.61): Water Recyclers, Spare Parts (Priority-eligible) and Lunar Base Supplies Earth → Moon; Crew Personal Effects and Recycled Alloys Moon → Earth (locations' goods lists update from the catalog). Earth → Moon now has 6 Standard goods, Moon → Earth 4. All existing tests still pass (tutorial masses unchanged: goods on both routes still hold 5/12/20 t). Test: `goods.test.ts` (route variety, variance is a fraction).
- [x] **Step 10H.2 — Sim: per-contract pay rate.** `PayoutInput.marketRate` (default 1) multiplies the payout; `payRate(rng, content)` draws 1 ± `contractPayVariance` uniformly as the last draw of every board contract (fit and normal); `buildContract` takes `payRate` (tutorial boards pass none, so tutorial payouts are unchanged). Tests: `pay-rate.test.ts` (payout scales with the rate; 1,000 Earth boards: rates within ±15 %, mean within 1 ± 0.01; the same good, mass and route now pays different amounts); the tutorial and CR-007 class-mix tests still pass.
- [x] **Step 10H.3 — Client: visible refresh.** `useNewContracts(locationId, contracts)` (`LocationBoards.tsx`) compares the board with the last one seen at that location (`localStorage` key `deadhead:seen-board:<id>`, read and written in try/catch), then remembers it. Contracts not seen before get a cyan "New" pill and rise in; the refresh line shows a `role="status"` "New contracts posted". Tags stay until the board changes again (contracts taken in between drop out); nothing is tagged on a first visit. The test setup clears `localStorage` after each test. Test: `fresh-board.test.tsx` (remembered older board → tags and notice; same board → none; first visit → none but remembered; rollover while open → notice and tags on the new board).
- [x] **Step 10H.4 — Regression, and a fix found by the user.** Plan amendments for steps 3.8 and 9.4. Manual check across a real rollover (`docs/screenshots/10h4-board-rollover-desktop.png`): one second after the timer hit zero the open page showed "New contracts posted" and tagged every new card New. **User report after restarting:** "the jobs are the same". Their Moon boards had six of eight jobs as Lunar Titanium every period: a fit job picked a class first, and for a Heavy Cargo 1 pilot at the Moon that was only Heavy, whose one route good (Lunar Titanium, 30–120 t) became an undersized bulk lot for the Courier; open jobs did the same. Fix (`board.ts`): fit jobs choose among every good on the route the crew is qualified for and whose range fits the slot and ship (`fitsSlot`), falling back to the class choice only when none does; `slotGoods` swaps a class whose goods are all too heavy for the slot for Standard goods on the route (goods too light for a big band still become bulk lots). With the user's save the next Moon boards carry Optical Glass, Souvenirs, Crew Personal Effects and Recycled Alloys. Test: `board-fleet.test.ts` (the user's Courier at the Moon: ≥ 5 flyable jobs per board, every flyable job's mass inside its good's range, no Lunar Titanium, ≥ 3 distinct goods per board on average); the class-mix test still passes. Full suite (110 files, 763 tests) green. **Phase 10H complete — stopped for review.**

View File

@ -134,6 +134,24 @@ function fitJob(
);
if (reachable.length === 0) continue;
const destination = rng.pick(reachable);
// Only goods this pilot may carry: a good's own tier must not exceed their skill.
const qualified = (g: Good) => {
const skillId = cargoClassOf(g, content).requiredSkillId;
return skillId === null || skillLevel(skills, skillId) >= (g.tier ?? 1);
};
// Any good on this route the crew is qualified for and the ship can carry
// (CR-008 fix): picking a class first left e.g. a Heavy-skilled Courier at the
// Moon with nothing but undersized Lunar Titanium lots.
const fitting = content.goods.filter(
(g) =>
g.producedAt.includes(origin.id) &&
g.demandedAt.includes(destination.id) &&
qualified(g) &&
fitsSlot(g, band, content, model.cargoCapacityTonnes),
);
let candidates = fitting;
if (candidates.length === 0) {
// Nothing on the route fits: a class from the class mix, as a bulk lot if need be.
const skilled = content.cargoTypes.filter(
(c) =>
origin.cargoClassMix.produces.includes(c.id) &&
@ -141,11 +159,16 @@ function fitJob(
(c.requiredSkillId === null || skillLevel(skills, c.requiredSkillId) >= 1),
);
const cargoType = skilled.length > 0 ? rng.pick(skilled) : standardCargo(content);
// Only goods this pilot may carry: a good's own tier must not exceed their skill.
const candidates = routeGoods(cargoType, origin, destination, content, (g) => {
const skillId = cargoClassOf(g, content).requiredSkillId;
return skillId === null || skillLevel(skills, skillId) >= (g.tier ?? 1);
});
candidates = slotGoods(
cargoType,
origin,
destination,
band,
content,
qualified,
model.cargoCapacityTonnes,
);
}
const { good, massTonnes } = chooseGood(
rng,
candidates,
@ -173,6 +196,45 @@ function drawMass(rng: Rng, lo: number, hi: number): number {
* both and within `capacity`; with no overlap, the candidate with the largest
* range and a mass from the band, a bulk lot.
*/
/** The mass range a good can take in a slot (the lightest band has no floor), within `capacity`. */
function slotRange(good: Good, band: MassBand, content: SimContent, capacity: number) {
const lightest = Math.min(...content.config.contractMassBands.map((b) => b.min));
const floor = band.min === lightest ? 0 : band.min;
return {
lo: Math.max(floor, good.massTonnes.min),
hi: Math.min(band.max, capacity, good.massTonnes.max),
};
}
/**
* Goods for a slot of this class (decision 0.5.54): the class's goods on the
* route. If every one of them is too heavy for the slot (band and ship),
* Standard goods on the route that fit instead (CR-008 fix: no 8 t lots of a
* 30 t+ good). Goods too light for a big band stay, and `chooseGood` makes a
* bulk lot of them ("the band wins").
*/
export function slotGoods(
cargoType: CargoType,
origin: Location,
destination: Location,
band: MassBand,
content: SimContent,
allow: (good: Good) => boolean = () => true,
capacity = Infinity,
): Good[] {
const goods = routeGoods(cargoType, origin, destination, content, allow);
const ceiling = Math.min(band.max, capacity);
if (!goods.every((g) => g.massTonnes.min > ceiling)) return goods;
const standard = routeGoods(standardCargo(content), origin, destination, content, allow);
return standard.some((g) => fitsSlot(g, band, content, capacity)) ? standard : goods;
}
/** Whether a good's own mass range fits the slot's band and the ship. */
export function fitsSlot(good: Good, band: MassBand, content: SimContent, capacity = Infinity) {
const { lo, hi } = slotRange(good, band, content, capacity);
return lo <= hi;
}
export function chooseGood(
rng: Rng,
candidates: readonly Good[],
@ -180,17 +242,11 @@ export function chooseGood(
content: SimContent,
capacity = Infinity,
): { good: Good; massTonnes: number } {
const lightest = Math.min(...content.config.contractMassBands.map((b) => b.min));
const floor = band.min === lightest ? 0 : band.min;
const ceiling = Math.min(band.max, capacity);
const range = (g: Good) => ({
lo: Math.max(floor, g.massTonnes.min),
hi: Math.min(ceiling, g.massTonnes.max),
});
const overlapping = candidates.filter((g) => range(g).lo <= range(g).hi);
const overlapping = candidates.filter((g) => fitsSlot(g, band, content, capacity));
if (overlapping.length > 0) {
const good = rng.pick(overlapping);
const { lo, hi } = range(good);
const { lo, hi } = slotRange(good, band, content, capacity);
return { good, massTonnes: drawMass(rng, lo, hi) };
}
const good = candidates.reduce((a, b) => (b.massTonnes.max > a.massTonnes.max ? b : a));
@ -431,7 +487,7 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
const cargoType = plain ? standardCargo(content) : pickCargo(rng, origin, destination, content);
const { good, massTonnes } = chooseGood(
rng,
routeGoods(cargoType, origin, destination, content),
slotGoods(cargoType, origin, destination, band, content),
band,
content,
);

View File

@ -108,3 +108,35 @@ describe("boards that fit the fleet (CR-006)", () => {
}
});
});
describe("fit jobs carry varied goods (CR-008 fix)", () => {
// The user's save: one Courier at the Moon, pilot Heavy Cargo 1 and Long Haul 1.
const pilotCourier: FleetMember = {
modelId: "courier",
skills: { "heavy-cargo": 1, "long-haul": 1 },
};
const moonInput: BoardInput = {
location: content.location("moon")!,
discoveredIds: new Set(["earth", "moon"]),
periodIndex: 0,
worldSeed: 4242,
fleet: [pilotCourier],
};
it("offers the Courier several goods it can really carry, not undersized Lunar Titanium", () => {
let distinct = 0;
for (let p = 0; p < 100; p++) {
const board = generateContractBoard({ ...moonInput, periodIndex: p }, content);
const flyable = board.filter((c) => fits(c, pilotCourier));
expect(flyable.length).toBeGreaterThanOrEqual(5);
for (const c of flyable) {
const { min, max } = content.good(c.goodId)!.massTonnes;
expect(c.massTonnes).toBeGreaterThanOrEqual(min);
expect(c.massTonnes).toBeLessThanOrEqual(max);
expect(c.goodId).not.toBe("lunar-titanium");
}
distinct += new Set(flyable.map((c) => c.goodId)).size;
}
expect(distinct / 100).toBeGreaterThanOrEqual(3);
});
});