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 car every balance figure in this file is quoted against.
|
||||||
*
|
*
|
||||||
* The saloon is the ordinary one — fragility 1, presence near 1 — so "a crash
|
* The estate is the ordinary one — an unremarkable family car in the middle of
|
||||||
* costs 1.4 points of condition" means something concrete rather than
|
* the catalogue — so "a crash costs 1.4 points of condition" means something
|
||||||
* depending on what happens to be in the garage.
|
* 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 {
|
export interface Subsystems {
|
||||||
/** 1 = factory fresh, 0 = ruined. */
|
/** 1 = factory fresh, 0 = ruined. */
|
||||||
|
|||||||
@ -1,5 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import { applyWear, deriveHandling, freshCondition } from './car';
|
||||||
|
|
||||||
describe('the catalogue', () => {
|
describe('the catalogue', () => {
|
||||||
@ -12,39 +22,54 @@ describe('the catalogue', () => {
|
|||||||
expect(current(garage)).toBeTruthy();
|
expect(current(garage)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is not an upgrade ladder', () => {
|
it('has no car that beats another at everything', () => {
|
||||||
// The whole design rests on this. If money simply bought a better car then
|
// The invariant the whole design rests on. Suspicion is not tied to
|
||||||
// the choice collapses into "drive the newest thing", and picking the car
|
// capability here — a lorry is huge and tough and nobody looks twice —
|
||||||
// stops being the decision the game is about.
|
// so the thing keeping the catalogue honest is not a ladder but the
|
||||||
const dearest = CARS[CARS.length - 1]!;
|
// absence of a dominant option. If any vehicle were at least as good on
|
||||||
const cheapest = CARS[0]!;
|
// speed, protection, discretion *and* handling, picking the car would
|
||||||
expect(dearest.engineForce).toBeGreaterThan(cheapest.engineForce);
|
// stop being a decision and the loop would lose its only one.
|
||||||
expect(dearest.fragility).toBeLessThan(cheapest.fragility);
|
const axes = ['speed', 'protection', 'discretion', 'handling'] as const;
|
||||||
// ...and pays for both, in the only currency that matters out there.
|
for (const a of CARS) {
|
||||||
expect(dearest.presence).toBeGreaterThan(cheapest.presence * 3);
|
for (const b of CARS) {
|
||||||
});
|
if (a === b) continue;
|
||||||
|
const va = virtues(a);
|
||||||
it('charges attention for every advantage, rung by rung', () => {
|
const vb = virtues(b);
|
||||||
// Checked pairwise rather than end to end, so no single rung can quietly
|
const dominates = axes.every((k) => va[k] >= vb[k]);
|
||||||
// become strictly better than the one below it. The bar is the geometric
|
expect(dominates, `${a.name} dominates ${b.name}`).toBe(false);
|
||||||
// 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', () => {
|
it('keeps the civilian half quiet however capable it gets', () => {
|
||||||
// The cheapest car has to stay the least conspicuous, or the early game
|
// An ordinary thing doing an ordinary thing is invisible whatever its
|
||||||
// simply hands you the answer once you can afford anything at all.
|
// specification. This is what stops "buy protection" and "stay unnoticed"
|
||||||
const quietest = [...CARS].sort((a, b) => a.presence - b.presence)[0]!;
|
// being the same axis.
|
||||||
expect(quietest.id).toBe(CARS[0]!.id);
|
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', () => {
|
describe('what the car you picked actually changes', () => {
|
||||||
const fresh = freshCondition();
|
const fresh = freshCondition();
|
||||||
|
const runabout = carById('runabout');
|
||||||
const tractor = carById('tractor');
|
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', () => {
|
it('drives like the car in the catalogue, not like one car with a skin', () => {
|
||||||
const slow = deriveHandling(fresh, tractor);
|
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', () => {
|
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 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);
|
const inArmour = applyWear(fresh, crash, armoured.fragility);
|
||||||
// Tin: one head-on and it is all but finished. The per-step cap is what
|
// Tin: one head-on and it is all but finished. The per-step cap is what
|
||||||
// stops it reading as exactly zero.
|
// 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.
|
// Steel: the same crash is a bad afternoon.
|
||||||
expect(inArmour.level.chassis).toBeGreaterThan(0.5);
|
expect(inArmour.level.chassis).toBeGreaterThan(0.5);
|
||||||
// And the permanent scar scales with it too.
|
// 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', () => {
|
it('leaves the free car genuinely fragile, so the trade has two sides', () => {
|
||||||
// If the cheap car merely went slower it would be a punishment rather than
|
// If the starting car merely went slower it would be a punishment rather
|
||||||
// a choice. It has to actually be made of tin.
|
// than a choice. It has to actually be made of tin.
|
||||||
const knock = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 6e5 };
|
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);
|
const steel = applyWear(fresh, knock, armoured.fragility);
|
||||||
expect(1 - tin.level.chassis).toBeGreaterThan((1 - steel.level.chassis) * 3);
|
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
|
* patched up when you get home — and money buys **cars**, permanently. So
|
||||||
* progress climbs while any given drive still declines.
|
* 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**.
|
* A lorry is huge and tough and nobody looks twice, because lorries exist.
|
||||||
* An armoured car will survive being shot at, and **everybody notices it**.
|
* 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
|
* So the civilian half of the catalogue — runabout, tractor, estate, lorry —
|
||||||
* thing nobody reports; a run into a district that already knows your face
|
* stays quiet however capable it gets, and you pay for protection and speed in
|
||||||
* wants the thing that survives what happens next. Picking wrong is supposed to
|
* money and in handling rather than in attention. The military half buys you
|
||||||
* be how you lose, and picking right is most of the skill the game asks for.
|
* 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 {
|
export interface CarSpec {
|
||||||
@ -33,13 +41,13 @@ export interface CarSpec {
|
|||||||
cost: number;
|
cost: number;
|
||||||
|
|
||||||
// --- How it drives ---
|
// --- 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;
|
mass: number;
|
||||||
/** Newtons per driven wheel at full throttle, in good condition. */
|
/** Newtons per driven wheel at full throttle, in good condition. */
|
||||||
engineForce: number;
|
engineForce: number;
|
||||||
/** Braking impulse per wheel, in good condition. */
|
/** Braking impulse per wheel, in good condition. */
|
||||||
brakeForce: number;
|
brakeForce: number;
|
||||||
/** Steering lock in radians, in good condition. */
|
/** Steering lock in radians, in good condition. Big things turn like barges. */
|
||||||
maxSteer: number;
|
maxSteer: number;
|
||||||
/** Tyre grip, in good condition. Lower slides. */
|
/** Tyre grip, in good condition. Lower slides. */
|
||||||
grip: number;
|
grip: number;
|
||||||
@ -47,111 +55,130 @@ export interface CarSpec {
|
|||||||
// --- What it costs you ---
|
// --- What it costs you ---
|
||||||
/**
|
/**
|
||||||
* Share of incoming damage the car actually takes. Armour is this number.
|
* 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;
|
fragility: number;
|
||||||
/**
|
/**
|
||||||
* How much attention it draws, as a multiplier on how fast anyone watching
|
* How much attention it draws, as a multiplier on how fast anyone watching
|
||||||
* makes their mind up about you.
|
* makes their mind up about you.
|
||||||
*
|
*
|
||||||
* The counterweight to everything above. A farm truck at 0.4 is part of the
|
* Deliberately *not* correlated with how good the vehicle is. An ordinary
|
||||||
* scenery; an armoured car at 3.6 is a thing people phone in before you have
|
* thing doing an ordinary thing is invisible whatever its specification; the
|
||||||
* finished the street. This is what stops the catalogue being a straight
|
* two that cost you here are the one nobody in this country drives and the
|
||||||
* upgrade ladder — and being undercover in an armoured car *should* be close
|
* one with a gun mount.
|
||||||
* to a contradiction in terms.
|
|
||||||
*/
|
*/
|
||||||
presence: number;
|
presence: number;
|
||||||
|
|
||||||
// --- What it looks like ---
|
// --- What it looks like ---
|
||||||
colour: number;
|
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 };
|
size: { width: number; height: number; length: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The catalogue, cheapest first. */
|
||||||
* 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[] = [
|
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',
|
id: 'tractor',
|
||||||
name: 'Field tractor',
|
name: 'Field tractor',
|
||||||
blurb: 'Slow, loud, and utterly beneath anybody’s notice.',
|
blurb: 'Barely faster than walking, built out of girders, part of the scenery.',
|
||||||
cost: 0,
|
cost: 220,
|
||||||
mass: 1500,
|
mass: 1600,
|
||||||
engineForce: 1500,
|
engineForce: 1300,
|
||||||
brakeForce: 22,
|
brakeForce: 24,
|
||||||
maxSteer: 0.66,
|
maxSteer: 0.7,
|
||||||
grip: 3.6,
|
grip: 3.4,
|
||||||
fragility: 1.25,
|
fragility: 0.8,
|
||||||
presence: 0.4,
|
presence: 0.45,
|
||||||
colour: 0x6a7042,
|
colour: 0x6a7042,
|
||||||
size: { width: 1.0, height: 1.25, length: 0.92 },
|
size: { width: 1.02, height: 1.24, length: 0.9 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'saloon',
|
id: 'estate',
|
||||||
name: 'Grey saloon',
|
name: 'Large estate',
|
||||||
blurb: 'The car half the country drives. Quick enough, quiet enough.',
|
blurb: 'A family car with some weight to it. Quick enough, dull enough.',
|
||||||
cost: 260,
|
cost: 520,
|
||||||
mass: 1100,
|
mass: 1350,
|
||||||
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,
|
engineForce: 3000,
|
||||||
brakeForce: 30,
|
brakeForce: 33,
|
||||||
maxSteer: 0.54,
|
maxSteer: 0.56,
|
||||||
grip: 4.2,
|
grip: 4.5,
|
||||||
fragility: 0.7,
|
fragility: 0.85,
|
||||||
presence: 1.15,
|
presence: 0.7,
|
||||||
colour: 0x7d5a3a,
|
colour: 0x7c8378,
|
||||||
size: { width: 1.08, height: 1.1, length: 1.12 },
|
size: { width: 1.02, height: 1, length: 1.12 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'runner',
|
id: 'lorry',
|
||||||
name: 'Border runner',
|
name: 'Lorry',
|
||||||
blurb: 'Built for one thing. You will not be outrun, and you will be seen.',
|
blurb: 'Three tonnes of cab and flatbed. Steers like a barge, stops like one.',
|
||||||
cost: 1150,
|
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,
|
mass: 1000,
|
||||||
engineForce: 4100,
|
engineForce: 5200,
|
||||||
brakeForce: 38,
|
brakeForce: 42,
|
||||||
maxSteer: 0.62,
|
maxSteer: 0.6,
|
||||||
grip: 5.4,
|
grip: 5.8,
|
||||||
fragility: 1.1,
|
fragility: 1.35,
|
||||||
presence: 1.7,
|
presence: 2.4,
|
||||||
colour: 0x9c3b30,
|
colour: 0xb03a2e,
|
||||||
size: { width: 0.98, height: 0.9, length: 1.06 },
|
size: { width: 1, height: 0.8, length: 1.06 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'armoured',
|
id: 'apc',
|
||||||
name: 'Armoured car',
|
name: 'Armoured carrier',
|
||||||
blurb: 'Nothing short of a tower will stop it. Everybody phones it in.',
|
blurb: 'Nothing short of a tower will stop it. Everybody phones it in.',
|
||||||
cost: 2200,
|
cost: 2600,
|
||||||
mass: 2600,
|
mass: 4000,
|
||||||
engineForce: 4600,
|
engineForce: 3000,
|
||||||
brakeForce: 34,
|
brakeForce: 28,
|
||||||
maxSteer: 0.44,
|
maxSteer: 0.34,
|
||||||
grip: 4.0,
|
grip: 3.6,
|
||||||
fragility: 0.3,
|
fragility: 0.22,
|
||||||
presence: 3.6,
|
presence: 3.8,
|
||||||
colour: 0x4d5348,
|
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 const carById = (id: string): CarSpec => CARS.find((c) => c.id === id) ?? CARS[0]!;
|
||||||
|
|
||||||
export interface GarageState {
|
export interface GarageState {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user