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>
228 lines
7.1 KiB
TypeScript
228 lines
7.1 KiB
TypeScript
/**
|
||
* 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;
|