diff --git a/src/sim/garage.test.ts b/src/sim/garage.test.ts new file mode 100644 index 0000000..cdcd741 --- /dev/null +++ b/src/sim/garage.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { CARS, buy, carById, createGarage, current, nextUnlock, owns, take } from './garage'; + +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('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('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); + }); +}); + +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); + }); +}); diff --git a/src/sim/garage.ts b/src/sim/garage.ts new file mode 100644 index 0000000..477ecc5 --- /dev/null +++ b/src/sim/garage.ts @@ -0,0 +1,200 @@ +/** + * The cars you can be driving, and which of them you own. Pure — no engine + * imports. + * + * This is the loop's central decision, and it exists because the old one had + * none. Condition used to be health, capability, currency sink and fail state + * all at once, so every event pushed the same way: damage made the car slower + * and harder to hold straight, which made it worse at earning, which meant + * fewer repairs, which meant more damage. A spiral with no negative term in it + * anywhere, and no decision inside it either. + * + * A garage breaks that apart. Damage is now a *sortie* resource — you are + * 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: + * + * 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**. + * + * 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. + */ + +export interface CarSpec { + id: string; + name: string; + /** One line, in the words somebody handing you the keys would use. */ + blurb: string; + /** What it costs to put one in the garage. The first is free. */ + cost: number; + + // --- How it drives --- + /** Kilograms. Heavier shrugs off shunts and takes 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. */ + maxSteer: number; + /** Tyre grip, in good condition. Lower slides. */ + grip: number; + + // --- 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. + */ + 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. + */ + presence: number; + + // --- What it looks like --- + colour: number; + /** Scale on the body mesh, so a truck reads as a truck 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. + */ +export const CARS: CarSpec[] = [ + { + 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, + colour: 0x6a7042, + size: { width: 1.0, height: 1.25, length: 0.92 }, + }, + { + 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, + 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 }, + }, + { + id: 'runner', + name: 'Border runner', + blurb: 'Built for one thing. You will not be outrun, and you will be seen.', + cost: 1150, + 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 }, + }, + { + id: 'armoured', + name: 'Armoured car', + 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, + colour: 0x4d5348, + size: { width: 1.16, height: 1.3, length: 1.14 }, + }, +]; + +export const carById = (id: string): CarSpec => CARS.find((c) => c.id === id) ?? CARS[0]!; + +export interface GarageState { + /** Ids of every car in the garage. The first one is always there. */ + owned: string[]; + /** What is being driven right now. */ + driving: string; +} + +export const createGarage = (): GarageState => ({ + owned: [CARS[0]!.id], + driving: CARS[0]!.id, +}); + +export const owns = (garage: GarageState, id: string): boolean => garage.owned.includes(id); + +export const current = (garage: GarageState): CarSpec => carById(garage.driving); + +/** + * Buys a car, if it is not already owned and the money is there. + * Returns what is left. Money is only ever spent here. + */ +export function buy( + garage: GarageState, + id: string, + funds: number, +): { bought: boolean; funds: number } { + const spec = CARS.find((c) => c.id === id); + if (!spec || owns(garage, id) || funds < spec.cost) return { bought: false, funds }; + garage.owned.push(id); + return { bought: true, funds: funds - spec.cost }; +} + +/** Takes a different car out. Only ones you own, and only at a base. */ +export function take(garage: GarageState, id: string): boolean { + if (!owns(garage, id)) return false; + garage.driving = id; + return true; +} + +/** + * The next thing worth saving for: cheapest car not yet owned. + * Null once the garage is complete. + */ +export const nextUnlock = (garage: GarageState): CarSpec | null => + CARS.find((c) => !owns(garage, c.id)) ?? null;