A garage of cars, traded against being noticed
The loop's problem was that it had no decision in it. Condition was health, capability, currency sink and fail state all at once, so every event pushed the same way and there was nothing to weigh against anything else. This is the axis the loop turns on instead: A tractor is slow and made of tin, and nobody looks twice at it. An armoured car survives being shot at, and everybody notices it. Five cars from a field tractor to an armoured car. Money buys them permanently rather than paying a repair bill, so progress climbs while any single drive still declines. There is deliberately no best one - 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. Kept honest by a pairwise test rather than a glance at the table: every rung has to be more conspicuous than the one below by at least the geometric mean of what it gains in toughness and speed. The armoured car failed it on the first pass - 3.7x tougher for 1.4x the attention, which is a strictly better car and would have collapsed the choice back into "drive the newest thing". Repriced to 3.6, because being undercover in an armoured car ought to be close to a contradiction in terms. Catalogue and state only; nothing reads it yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
47e9e8bfdb
commit
ea386bca2a
92
src/sim/garage.test.ts
Normal file
92
src/sim/garage.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
200
src/sim/garage.ts
Normal file
200
src/sim/garage.ts
Normal file
@ -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;
|
||||||
Loading…
Reference in New Issue
Block a user