/** * 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, and the axis is not capability. It is * whether the vehicle *belongs here*: * * 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. * * 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 { 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 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. Big things turn like barges. */ 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. * 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. * * 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 lorry reads as a lorry from the mirror. */ size: { width: number; height: number; length: number }; } /** 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: '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.02, height: 1.24, length: 0.9 }, }, { 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: 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: '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: 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: 'apc', name: 'Armoured carrier', blurb: 'Nothing short of a tower will stop it. Everybody phones it in.', 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.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 { /** 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;