Six vehicles, and suspicion is about belonging rather than being good
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>
This commit is contained in:
parent
6a0c16fcf4
commit
5b881312b0
@ -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. */
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user