drive-between-the-lines/src/sim/garage.test.ts
dejvino 5b881312b0 Six vehicles, and suspicion is about belonging rather than being good
Reworked to the requested catalogue: small runabout, tractor, large
estate, lorry, sports coupe, armoured carrier. The runabout is free and
everything else is unlocked.

The important change is not the list, it is the axis. Attention is no
longer tied to capability - it is tied to whether the vehicle *belongs
here*. A lorry is three tonnes, well protected and nobody looks twice,
because lorries exist. A sports coupe is quick and flimsy and everybody
looks, because who drives that, here, now. So the whole civilian half
stays quiet however capable it gets, and you pay for speed and armour
in money and in handling instead. Only two vehicles cost you attention,
and they are the two that do not belong: the flashy one and the one
with a gun mount.

That kills the old "each rung must be more conspicuous than the last"
test, which no longer describes the design. In its place is the
invariant that actually matters and is much stronger: across all
thirty pairs, no vehicle may be at least as good as another on speed,
protection, discretion *and* handling at once. That is what stops a
dominant pick appearing and the loop losing its only decision - a lorry
is safe and unremarkable and pays for it by steering like a barge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:05:03 +02:00

180 lines
7.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
CARS,
buy,
carById,
createGarage,
current,
nextUnlock,
owns,
take,
virtues,
} from './garage';
import { applyWear, deriveHandling, freshCondition } from './car';
describe('the catalogue', () => {
it('gives you something to drive for nothing', () => {
// There is always a car. The loop's dead end was a player with no money and
// nothing that ran, and the garage exists to make that state unreachable.
const garage = createGarage();
expect(garage.owned.length).toBeGreaterThan(0);
expect(CARS[0]!.cost).toBe(0);
expect(current(garage)).toBeTruthy();
});
it('has no car that beats another at everything', () => {
// The invariant the whole design rests on. Suspicion is not tied to
// capability here — a lorry is huge and tough and nobody looks twice —
// so the thing keeping the catalogue honest is not a ladder but the
// absence of a dominant option. If any vehicle were at least as good on
// speed, protection, discretion *and* handling, picking the car would
// stop being a decision and the loop would lose its only one.
const axes = ['speed', 'protection', 'discretion', 'handling'] as const;
for (const a of CARS) {
for (const b of CARS) {
if (a === b) continue;
const va = virtues(a);
const vb = virtues(b);
const dominates = axes.every((k) => va[k] >= vb[k]);
expect(dominates, `${a.name} dominates ${b.name}`).toBe(false);
}
}
});
it('keeps the civilian half quiet however capable it gets', () => {
// An ordinary thing doing an ordinary thing is invisible whatever its
// specification. This is what stops "buy protection" and "stay unnoticed"
// being the same axis.
const lorry = carById('lorry');
const sports = carById('sports');
expect(lorry.presence).toBeLessThan(sports.presence / 2);
// ...even though it is far better protected than the flashy one.
expect(lorry.fragility).toBeLessThan(sports.fragility);
});
it('charges attention only for the things that do not belong here', () => {
const conspicuous = CARS.filter((c) => c.presence > 1.5).map((c) => c.id);
expect(conspicuous.sort()).toEqual(['apc', 'sports']);
});
it('does not put discretion up for sale', () => {
// Being unremarkable must stay available for nothing, or the early game is
// simply the worst version of the late one. The quietest vehicle in the
// catalogue is one of the two cheapest.
const byQuiet = [...CARS].sort((a, b) => a.presence - b.presence);
const byPrice = [...CARS].sort((a, b) => a.cost - b.cost);
expect(byPrice.slice(0, 2).map((c) => c.id)).toContain(byQuiet[0]!.id);
});
it('starts you in the small car and nothing else', () => {
const garage = createGarage();
expect(garage.owned).toEqual(['runabout']);
expect(current(garage).cost).toBe(0);
});
});
describe('buying and taking cars', () => {
it('will not sell you what you cannot afford', () => {
const garage = createGarage();
const dear = CARS[CARS.length - 1]!;
const { bought, funds } = buy(garage, dear.id, dear.cost - 1);
expect(bought).toBe(false);
expect(funds).toBe(dear.cost - 1);
expect(owns(garage, dear.id)).toBe(false);
});
it('takes the money once and only once', () => {
const garage = createGarage();
const car = CARS[1]!;
const first = buy(garage, car.id, car.cost + 50);
expect(first.bought).toBe(true);
expect(first.funds).toBe(50);
// Buying it again is not a way to lose fifty more.
const second = buy(garage, car.id, first.funds);
expect(second.bought).toBe(false);
expect(second.funds).toBe(50);
});
it('only lets you drive what is in the garage', () => {
const garage = createGarage();
expect(take(garage, CARS[2]!.id)).toBe(false);
expect(current(garage).id).toBe(CARS[0]!.id);
buy(garage, CARS[2]!.id, 99999);
expect(take(garage, CARS[2]!.id)).toBe(true);
expect(current(garage).id).toBe(CARS[2]!.id);
});
it('points at the next thing worth saving for, until there is none', () => {
const garage = createGarage();
expect(nextUnlock(garage)!.id).toBe(CARS[1]!.id);
for (const car of CARS) buy(garage, car.id, 99999);
expect(nextUnlock(garage)).toBeNull();
});
it('hands back a real car for an id it does not know', () => {
// Saves outlive catalogues; a renamed car must not leave the player on foot.
expect(carById('no-such-car').id).toBe(CARS[0]!.id);
});
});
describe('what the car you picked actually changes', () => {
const fresh = freshCondition();
const runabout = carById('runabout');
const tractor = carById('tractor');
const armoured = carById('apc');
it('drives like the car in the catalogue, not like one car with a skin', () => {
const slow = deriveHandling(fresh, tractor);
const quick = deriveHandling(fresh, armoured);
expect(slow.engineForce).toBeCloseTo(tractor.engineForce, 6);
expect(quick.engineForce).toBeCloseTo(armoured.engineForce, 6);
// A tractor turns tighter than an armoured car and grips less.
expect(slow.maxSteer).toBeGreaterThan(quick.maxSteer);
expect(slow.frictionSlip).toBeLessThan(quick.frictionSlip);
});
it('still leaves a ruined car driveable, whichever car it is', () => {
// The garage exists so that being in a bad way is a situation rather than
// an ending. A car that stops entirely at 0% is a fail state wearing a dial.
const ruined = {
level: { engine: 0, tires: 0, chassis: 0 },
ceiling: { engine: 1, tires: 1, chassis: 1 },
};
for (const spec of CARS) {
const h = deriveHandling(ruined, spec);
expect(h.engineForce).toBeGreaterThan(0);
expect(h.maxSteer).toBeGreaterThan(0);
expect(h.brakeForce).toBeGreaterThan(0);
}
});
it('makes armour worth the attention it costs', () => {
// The same crash, in the thing with no protection and the thing built for it.
const crash = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 2.47e6 };
const inTin = applyWear(fresh, crash, runabout.fragility);
const inArmour = applyWear(fresh, crash, armoured.fragility);
// Tin: one head-on and it is all but finished. The per-step cap is what
// stops it reading as exactly zero.
expect(inTin.level.chassis).toBeLessThan(0.25);
// Steel: the same crash is a bad afternoon.
expect(inArmour.level.chassis).toBeGreaterThan(0.5);
// And the permanent scar scales with it too.
expect(inArmour.ceiling.chassis).toBeGreaterThan(inTin.ceiling.chassis);
});
it('leaves the free car genuinely fragile, so the trade has two sides', () => {
// If the starting car merely went slower it would be a punishment rather
// than a choice. It has to actually be made of tin.
const knock = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 6e5 };
const tin = applyWear(fresh, knock, runabout.fragility);
const steel = applyWear(fresh, knock, armoured.fragility);
expect(1 - tin.level.chassis).toBeGreaterThan((1 - steel.level.chassis) * 3);
});
it('gives the tractor real protection, since it is a lump of iron', () => {
// "Very slow, some protection": it is not the fragile one, the runabout is.
expect(tractor.fragility).toBeLessThan(runabout.fragility);
expect(virtues(tractor).speed).toBeLessThan(virtues(runabout).speed);
});
});