diff --git a/src/sim/car.ts b/src/sim/car.ts index 1219463..224bbef 100644 --- a/src/sim/car.ts +++ b/src/sim/car.ts @@ -17,11 +17,11 @@ import { carById, type CarSpec } from './garage'; /** * The car every balance figure in this file is quoted against. * - * The saloon is the ordinary one — fragility 1, presence near 1 — so "a crash - * costs 1.4 points of condition" means something concrete rather than - * depending on what happens to be in the garage. + * The estate is the ordinary one — an unremarkable family car in the middle of + * the catalogue — so "a crash costs 1.4 points of condition" means something + * concrete rather than depending on what happens to be in the garage. */ -const REFERENCE_CAR: CarSpec = carById('saloon'); +const REFERENCE_CAR: CarSpec = carById('estate'); export interface Subsystems { /** 1 = factory fresh, 0 = ruined. */ diff --git a/src/sim/garage.test.ts b/src/sim/garage.test.ts index 4bf8e39..c033a80 100644 --- a/src/sim/garage.test.ts +++ b/src/sim/garage.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { CARS, buy, carById, createGarage, current, nextUnlock, owns, take } from './garage'; +import { + CARS, + buy, + carById, + createGarage, + current, + nextUnlock, + owns, + take, + virtues, +} from './garage'; import { applyWear, deriveHandling, freshCondition } from './car'; describe('the catalogue', () => { @@ -12,39 +22,54 @@ describe('the catalogue', () => { expect(current(garage)).toBeTruthy(); }); - it('is not an upgrade ladder', () => { - // The whole design rests on this. If money simply bought a better car then - // the choice collapses into "drive the newest thing", and picking the car - // stops being the decision the game is about. - const dearest = CARS[CARS.length - 1]!; - const cheapest = CARS[0]!; - expect(dearest.engineForce).toBeGreaterThan(cheapest.engineForce); - expect(dearest.fragility).toBeLessThan(cheapest.fragility); - // ...and pays for both, in the only currency that matters out there. - expect(dearest.presence).toBeGreaterThan(cheapest.presence * 3); - }); - - it('charges attention for every advantage, rung by rung', () => { - // Checked pairwise rather than end to end, so no single rung can quietly - // become strictly better than the one below it. The bar is the geometric - // mean of what the rung gains: a car that is both tougher and quicker has - // to be conspicuous in proportion to both. - for (let i = 1; i < CARS.length; i++) { - const worse = CARS[i - 1]!; - const better = CARS[i]!; - expect(better.cost).toBeGreaterThan(worse.cost); - const tougher = worse.fragility / better.fragility; - const faster = better.engineForce / worse.engineForce; - const earned = Math.sqrt(tougher * faster); - expect(better.presence / worse.presence).toBeGreaterThan(earned); + 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 something quiet worth driving at every price', () => { - // The cheapest car has to stay the least conspicuous, or the early game - // simply hands you the answer once you can afford anything at all. - const quietest = [...CARS].sort((a, b) => a.presence - b.presence)[0]!; - expect(quietest.id).toBe(CARS[0]!.id); + 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); }); }); @@ -94,8 +119,9 @@ describe('buying and taking cars', () => { describe('what the car you picked actually changes', () => { const fresh = freshCondition(); + const runabout = carById('runabout'); const tractor = carById('tractor'); - const armoured = carById('armoured'); + const armoured = carById('apc'); it('drives like the car in the catalogue, not like one car with a skin', () => { const slow = deriveHandling(fresh, tractor); @@ -123,25 +149,31 @@ describe('what the car you picked actually changes', () => { }); it('makes armour worth the attention it costs', () => { - // The same crash, in the cheapest car and the dearest. + // 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 inTractor = applyWear(fresh, crash, tractor.fragility); + 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(inTractor.level.chassis).toBeLessThan(0.25); + 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(inTractor.ceiling.chassis); + expect(inArmour.ceiling.chassis).toBeGreaterThan(inTin.ceiling.chassis); }); - it('leaves the tractor genuinely fragile, so the trade has two sides', () => { - // If the cheap car merely went slower it would be a punishment rather than - // a choice. It has to actually be made of tin. + 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, tractor.fragility); + 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); + }); }); diff --git a/src/sim/garage.ts b/src/sim/garage.ts index 477ecc5..50f5982 100644 --- a/src/sim/garage.ts +++ b/src/sim/garage.ts @@ -13,15 +13,23 @@ * patched up when you get home — and money buys **cars**, permanently. So * progress climbs while any given drive still declines. * - * The choice itself is the point: + * The choice itself is the point, and the axis is not capability. It is + * whether the vehicle *belongs here*: * - * A tractor is slow and made of tin, and **nobody looks twice at it**. - * An armoured car will survive being shot at, and **everybody notices it**. + * A lorry is huge and tough and nobody looks twice, because lorries exist. + * A sports car is quick and flimsy and everybody looks, because who drives + * that, here, now. * - * There is deliberately no best car. A quiet errand down cold roads wants the - * thing nobody reports; a run into a district that already knows your face - * wants the thing that survives what happens next. Picking wrong is supposed to - * be how you lose, and picking right is most of the skill the game asks for. + * So the civilian half of the catalogue — runabout, tractor, estate, lorry — + * stays quiet however capable it gets, and you pay for protection and speed in + * money and in handling rather than in attention. The military half buys you + * out of that at a price nothing else charges. + * + * There is deliberately no best car, and it is checked rather than asserted: + * no vehicle may beat another on speed, protection, discretion *and* handling + * at once. A quiet errand down cold roads wants the thing nobody reports; a run + * into a district that already knows your face wants the thing that survives + * what happens next. Picking wrong is supposed to be how you lose. */ export interface CarSpec { @@ -33,13 +41,13 @@ export interface CarSpec { cost: number; // --- How it drives --- - /** Kilograms. Heavier shrugs off shunts and takes longer to stop. */ + /** Kilograms. Heavier shrugs off shunts and takes far longer to stop. */ mass: number; /** Newtons per driven wheel at full throttle, in good condition. */ engineForce: number; /** Braking impulse per wheel, in good condition. */ brakeForce: number; - /** Steering lock in radians, in good condition. */ + /** Steering lock in radians, in good condition. Big things turn like barges. */ maxSteer: number; /** Tyre grip, in good condition. Lower slides. */ grip: number; @@ -47,111 +55,130 @@ export interface CarSpec { // --- What it costs you --- /** * Share of incoming damage the car actually takes. Armour is this number. - * 1 is bare metal; 0.35 is something built to be shot at. + * Above 1 is worse than bare metal; 0.22 is something built to be shot at. */ fragility: number; /** * How much attention it draws, as a multiplier on how fast anyone watching * makes their mind up about you. * - * The counterweight to everything above. A farm truck at 0.4 is part of the - * scenery; an armoured car at 3.6 is a thing people phone in before you have - * finished the street. This is what stops the catalogue being a straight - * upgrade ladder — and being undercover in an armoured car *should* be close - * to a contradiction in terms. + * Deliberately *not* correlated with how good the vehicle is. An ordinary + * thing doing an ordinary thing is invisible whatever its specification; the + * two that cost you here are the one nobody in this country drives and the + * one with a gun mount. */ presence: number; // --- What it looks like --- colour: number; - /** Scale on the body mesh, so a truck reads as a truck from the mirror. */ + /** Scale on the body mesh, so a lorry reads as a lorry from the mirror. */ size: { width: number; height: number; length: number }; } -/** - * The catalogue, cheapest first. - * - * Read down the `presence` column rather than the price column: that is the - * axis the whole thing turns on, and it climbs faster than anything else. - */ +/** The catalogue, cheapest first. */ export const CARS: CarSpec[] = [ + { + id: 'runabout', + name: 'Small runabout', + blurb: 'Somebody’s second car. Slow, tinny, and completely unremarkable.', + cost: 0, + mass: 950, + engineForce: 1900, + brakeForce: 30, + maxSteer: 0.64, + grip: 4.6, + fragility: 1.3, + presence: 0.5, + colour: 0x9aa2a8, + size: { width: 0.9, height: 0.88, length: 0.86 }, + }, { id: 'tractor', name: 'Field tractor', - blurb: 'Slow, loud, and utterly beneath anybody’s notice.', - cost: 0, - mass: 1500, - engineForce: 1500, - brakeForce: 22, - maxSteer: 0.66, - grip: 3.6, - fragility: 1.25, - presence: 0.4, + blurb: 'Barely faster than walking, built out of girders, part of the scenery.', + cost: 220, + mass: 1600, + engineForce: 1300, + brakeForce: 24, + maxSteer: 0.7, + grip: 3.4, + fragility: 0.8, + presence: 0.45, colour: 0x6a7042, - size: { width: 1.0, height: 1.25, length: 0.92 }, + size: { width: 1.02, height: 1.24, length: 0.9 }, }, { - id: 'saloon', - name: 'Grey saloon', - blurb: 'The car half the country drives. Quick enough, quiet enough.', - cost: 260, - mass: 1100, - engineForce: 2600, - brakeForce: 32, - maxSteer: 0.6, - grip: 4.6, - fragility: 1, - presence: 0.85, - colour: 0x8c9298, - size: { width: 1, height: 1, length: 1 }, - }, - { - id: 'pickup', - name: 'Work pickup', - blurb: 'Takes a knock and keeps its shape. People do look at the plates.', - cost: 620, - mass: 1650, + id: 'estate', + name: 'Large estate', + blurb: 'A family car with some weight to it. Quick enough, dull enough.', + cost: 520, + mass: 1350, engineForce: 3000, - brakeForce: 30, - maxSteer: 0.54, - grip: 4.2, - fragility: 0.7, - presence: 1.15, - colour: 0x7d5a3a, - size: { width: 1.08, height: 1.1, length: 1.12 }, + brakeForce: 33, + maxSteer: 0.56, + grip: 4.5, + fragility: 0.85, + presence: 0.7, + colour: 0x7c8378, + size: { width: 1.02, height: 1, length: 1.12 }, }, { - id: 'runner', - name: 'Border runner', - blurb: 'Built for one thing. You will not be outrun, and you will be seen.', - cost: 1150, + id: 'lorry', + name: 'Lorry', + blurb: 'Three tonnes of cab and flatbed. Steers like a barge, stops like one.', + cost: 950, + mass: 3200, + engineForce: 4200, + brakeForce: 26, + maxSteer: 0.38, + grip: 3.8, + fragility: 0.5, + presence: 0.8, + colour: 0x4f6068, + size: { width: 1.16, height: 1.34, length: 1.5 }, + }, + { + id: 'sports', + name: 'Sports coupe', + blurb: 'Nothing here goes this fast. That is the problem with it.', + cost: 1500, mass: 1000, - engineForce: 4100, - brakeForce: 38, - maxSteer: 0.62, - grip: 5.4, - fragility: 1.1, - presence: 1.7, - colour: 0x9c3b30, - size: { width: 0.98, height: 0.9, length: 1.06 }, + engineForce: 5200, + brakeForce: 42, + maxSteer: 0.6, + grip: 5.8, + fragility: 1.35, + presence: 2.4, + colour: 0xb03a2e, + size: { width: 1, height: 0.8, length: 1.06 }, }, { - id: 'armoured', - name: 'Armoured car', + id: 'apc', + name: 'Armoured carrier', blurb: 'Nothing short of a tower will stop it. Everybody phones it in.', - cost: 2200, - mass: 2600, - engineForce: 4600, - brakeForce: 34, - maxSteer: 0.44, - grip: 4.0, - fragility: 0.3, - presence: 3.6, + cost: 2600, + mass: 4000, + engineForce: 3000, + brakeForce: 28, + maxSteer: 0.34, + grip: 3.6, + fragility: 0.22, + presence: 3.8, colour: 0x4d5348, - size: { width: 1.16, height: 1.3, length: 1.14 }, + size: { width: 1.2, height: 1.32, length: 1.22 }, }, ]; +/** The four ways a vehicle can be good, for the no-dominance check. */ +export const virtues = (c: CarSpec) => ({ + /** Acceleration, which is what actually gets you away. */ + speed: c.engineForce / c.mass, + protection: 1 / c.fragility, + discretion: 1 / c.presence, + /** Lock times grip: how well it will actually take a junction. */ + handling: c.maxSteer * c.grip, +}); + export const carById = (id: string): CarSpec => CARS.find((c) => c.id === id) ?? CARS[0]!; export interface GarageState {