Make the car you picked the car you are driving
deriveHandling now takes the spec as well as the condition, and every figure is the catalogue's own number scaled by wear - so the table means what it says rather than describing a single car with a skin on it. A tractor turns tighter and grips less than an armoured car, and stays that way as both of them fall apart. Fragility reaches both damage paths, walls and bullets alike, since being shot at is precisely the situation armour is bought for. The same head-on leaves the tractor at 15% chassis and the armoured car at 77%. Wear scales the car's figures rather than replacing them, and bottoms out at a third rather than at nothing. A car that stops entirely at 0% is a fail state wearing a dial, and the whole point of the garage is that a bad car is a situation rather than an ending. Mass moves with the spec; the collider deliberately does not. Different colliders would mean rebuilding the vehicle controller mid-campaign, and every barricade gap and building alley in the world is sized against one car. The mesh changes shape and paint, so a tractor reads as a tractor from the mirror without the world needing re-measuring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ea386bca2a
commit
6a0c16fcf4
33
src/main.ts
33
src/main.ts
@ -35,6 +35,7 @@ import {
|
|||||||
type Offer,
|
type Offer,
|
||||||
} from './sim/quests';
|
} from './sim/quests';
|
||||||
import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia';
|
import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia';
|
||||||
|
import { createGarage, current as drivingNow } from './sim/garage';
|
||||||
import { createRadio, pollRadio } from './sim/radio';
|
import { createRadio, pollRadio } from './sim/radio';
|
||||||
import { createOpportunities, stepOpportunities } from './sim/opportunities';
|
import { createOpportunities, stepOpportunities } from './sim/opportunities';
|
||||||
import { createHeatProps } from './heatProps';
|
import { createHeatProps } from './heatProps';
|
||||||
@ -167,6 +168,16 @@ async function boot() {
|
|||||||
const unitView = createUnitView(view.scene);
|
const unitView = createUnitView(view.scene);
|
||||||
const unitBodies = createUnitBodies(physics);
|
const unitBodies = createUnitBodies(physics);
|
||||||
const pursuit = createPursuit();
|
const pursuit = createPursuit();
|
||||||
|
const garage = createGarage();
|
||||||
|
|
||||||
|
/** Put whatever is in the garage under the player: weight, and paint. */
|
||||||
|
const equip = () => {
|
||||||
|
const spec = drivingNow(garage);
|
||||||
|
physics.setCarMass(spec.mass);
|
||||||
|
view.setCarLook(spec);
|
||||||
|
return spec;
|
||||||
|
};
|
||||||
|
let car = equip();
|
||||||
|
|
||||||
// Bullets stop at buildings, so combat needs a fast "is this inside a wall"
|
// Bullets stop at buildings, so combat needs a fast "is this inside a wall"
|
||||||
// lookup. A grid built once at boot beats scanning a thousand obstacles.
|
// lookup. A grid built once at boot beats scanning a thousand obstacles.
|
||||||
@ -493,7 +504,14 @@ async function boot() {
|
|||||||
|
|
||||||
// Tarmac or open ground — decided last step, since the road lookup needs
|
// Tarmac or open ground — decided last step, since the road lookup needs
|
||||||
// a position and the car has not moved yet this one.
|
// a position and the car has not moved yet this one.
|
||||||
drive(physics, driveState, cmd, deriveHandling(condition), dt, surfaceFor(currentSegment !== null));
|
drive(
|
||||||
|
physics,
|
||||||
|
driveState,
|
||||||
|
cmd,
|
||||||
|
deriveHandling(condition, car),
|
||||||
|
dt,
|
||||||
|
surfaceFor(currentSegment !== null),
|
||||||
|
);
|
||||||
physics.step(dt);
|
physics.step(dt);
|
||||||
captureChassis();
|
captureChassis();
|
||||||
|
|
||||||
@ -506,12 +524,12 @@ async function boot() {
|
|||||||
|
|
||||||
const lastImpact = physics.drainImpactForce();
|
const lastImpact = physics.drainImpactForce();
|
||||||
audio.impact(lastImpact);
|
audio.impact(lastImpact);
|
||||||
condition = applyWear(condition, {
|
condition = applyWear(
|
||||||
dt,
|
condition,
|
||||||
distance,
|
{ dt, distance, throttle: Math.abs(cmd.throttle), impactForce: lastImpact },
|
||||||
throttle: Math.abs(cmd.throttle),
|
// Armour is this number: what the car actually feels of a knock.
|
||||||
impactForce: lastImpact,
|
car.fragility,
|
||||||
});
|
);
|
||||||
|
|
||||||
// Heat: the road remembers being used — including being driven alongside.
|
// Heat: the road remembers being used — including being driven alongside.
|
||||||
// Behind your own lines it remembers nothing: nobody there is watching.
|
// Behind your own lines it remembers nothing: nobody there is watching.
|
||||||
@ -890,6 +908,7 @@ async function boot() {
|
|||||||
},
|
},
|
||||||
condition,
|
condition,
|
||||||
chatterRng,
|
chatterRng,
|
||||||
|
car.fragility,
|
||||||
);
|
);
|
||||||
condition = shooting.condition;
|
condition = shooting.condition;
|
||||||
const listener = { x: at.x, z: at.z, heading: headingOf() };
|
const listener = { x: at.x, z: at.z, heading: headingOf() };
|
||||||
|
|||||||
@ -20,6 +20,17 @@ export interface PhysicsWorld {
|
|||||||
telemetry(): Telemetry;
|
telemetry(): Telemetry;
|
||||||
/** A body the sim moves by hand, which still collides with the player. */
|
/** A body the sim moves by hand, which still collides with the player. */
|
||||||
addKinematicBox(box: KinematicBox): RAPIER.RigidBody;
|
addKinematicBox(box: KinematicBox): RAPIER.RigidBody;
|
||||||
|
/**
|
||||||
|
* Swap what the player is driving.
|
||||||
|
*
|
||||||
|
* Only the mass properties change, not the collider: a tractor and an
|
||||||
|
* armoured car occupy visibly different boxes on screen, but giving them
|
||||||
|
* different colliders means rebuilding the vehicle controller mid-campaign,
|
||||||
|
* and every barricade gap and building alley in the world is sized against
|
||||||
|
* one car. Mass is what actually changes how the thing drives — what it
|
||||||
|
* shrugs off, how long it takes to stop — so that is what moves.
|
||||||
|
*/
|
||||||
|
setCarMass(mass: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KinematicBox {
|
export interface KinematicBox {
|
||||||
@ -157,6 +168,22 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
|
|||||||
return v;
|
return v;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCarMass(mass: number) {
|
||||||
|
chassis.setAdditionalMassProperties(
|
||||||
|
mass,
|
||||||
|
{ x: 0, y: -0.35, z: 0 },
|
||||||
|
// Scaled from the reference car's tensor, so a heavier vehicle also
|
||||||
|
// resists being spun round rather than merely being harder to shift.
|
||||||
|
{
|
||||||
|
x: 1369 * (mass / CAR.mass),
|
||||||
|
y: 1621 * (mass / CAR.mass),
|
||||||
|
z: 342 * (mass / CAR.mass),
|
||||||
|
},
|
||||||
|
{ x: 0, y: 0, z: 0, w: 1 },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
addKinematicBox(box) {
|
addKinematicBox(box) {
|
||||||
// Kinematic rather than dynamic: the unit sim owns where these are, but
|
// Kinematic rather than dynamic: the unit sim owns where these are, but
|
||||||
// they still shove the player's car when they meet it.
|
// they still shove the player's car when they meet it.
|
||||||
|
|||||||
@ -12,6 +12,8 @@ export interface SceneView {
|
|||||||
wheels: THREE.Object3D[];
|
wheels: THREE.Object3D[];
|
||||||
/** Only the dynamic crates need per-frame syncing; blocks are instanced. */
|
/** Only the dynamic crates need per-frame syncing; blocks are instanced. */
|
||||||
crates: Array<{ index: number; mesh: THREE.Mesh }>;
|
crates: Array<{ index: number; mesh: THREE.Mesh }>;
|
||||||
|
/** Dress the car as whatever is being driven: shape and paint. */
|
||||||
|
setCarLook(spec: { colour: number; size: { width: number; height: number; length: number } }): void;
|
||||||
/** Keeps the shadow frustum centred on the car. */
|
/** Keeps the shadow frustum centred on the car. */
|
||||||
followSun(): void;
|
followSun(): void;
|
||||||
/** Eases the world's colour and visibility toward the current territory. */
|
/** Eases the world's colour and visibility toward the current territory. */
|
||||||
@ -198,9 +200,14 @@ export function createScene(model: WorldModel): SceneView {
|
|||||||
|
|
||||||
// --- Car ---
|
// --- Car ---
|
||||||
const car = new THREE.Group();
|
const car = new THREE.Group();
|
||||||
|
const bodyMat = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x8c3b34,
|
||||||
|
roughness: 0.55,
|
||||||
|
metalness: 0.15,
|
||||||
|
});
|
||||||
const body = new THREE.Mesh(
|
const body = new THREE.Mesh(
|
||||||
new THREE.BoxGeometry(CAR.halfWidth * 2, CAR.halfHeight * 2, CAR.halfLength * 2),
|
new THREE.BoxGeometry(CAR.halfWidth * 2, CAR.halfHeight * 2, CAR.halfLength * 2),
|
||||||
new THREE.MeshStandardMaterial({ color: 0x8c3b34, roughness: 0.55, metalness: 0.15 }),
|
bodyMat,
|
||||||
);
|
);
|
||||||
body.castShadow = true;
|
body.castShadow = true;
|
||||||
car.add(body);
|
car.add(body);
|
||||||
@ -251,6 +258,16 @@ export function createScene(model: WorldModel): SceneView {
|
|||||||
car,
|
car,
|
||||||
wheels,
|
wheels,
|
||||||
crates,
|
crates,
|
||||||
|
setCarLook(spec) {
|
||||||
|
// The mesh changes shape; the collider deliberately does not — see
|
||||||
|
// `setCarMass`. A tractor should *look* like a tractor from the mirror
|
||||||
|
// without every barricade gap in the world having to be re-sized.
|
||||||
|
bodyMat.color.setHex(spec.colour);
|
||||||
|
body.scale.set(spec.size.width, spec.size.height, spec.size.length);
|
||||||
|
cabin.position.y = CAR.halfHeight * spec.size.height + 0.25;
|
||||||
|
cabin.scale.set(spec.size.width, spec.size.height, spec.size.length);
|
||||||
|
},
|
||||||
|
|
||||||
followSun() {
|
followSun() {
|
||||||
// Keep the shadow frustum centred on the car rather than the origin.
|
// Keep the shadow frustum centred on the car rather than the origin.
|
||||||
sun.position.set(car.position.x + 45, 70, car.position.z + 25);
|
sun.position.set(car.position.x + 45, 70, car.position.z + 25);
|
||||||
|
|||||||
@ -12,6 +12,17 @@
|
|||||||
* what keeps it meaningful is the exchange rate rather than the impossibility.
|
* what keeps it meaningful is the exchange rate rather than the impossibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
const REFERENCE_CAR: CarSpec = carById('saloon');
|
||||||
|
|
||||||
export interface Subsystems {
|
export interface Subsystems {
|
||||||
/** 1 = factory fresh, 0 = ruined. */
|
/** 1 = factory fresh, 0 = ruined. */
|
||||||
engine: number;
|
engine: number;
|
||||||
@ -70,23 +81,42 @@ export const freshCondition = (): CarCondition => ({
|
|||||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||||
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||||
|
|
||||||
export function deriveHandling(c: CarCondition): Handling {
|
/**
|
||||||
|
* Share of a car's figures still available when that subsystem is ruined.
|
||||||
|
*
|
||||||
|
* Condition scales what the car can do rather than replacing it, so a wrecked
|
||||||
|
* tractor is still a tractor and a wrecked armoured car is still heavy. Not
|
||||||
|
* zero: a car that stops entirely at 0% is a fail state wearing a dial, and the
|
||||||
|
* point of the garage is that being in a bad car is a situation rather than an
|
||||||
|
* ending.
|
||||||
|
*/
|
||||||
|
const RUINED = { drive: 0.35, brake: 0.33, steer: 0.65, grip: 0.35 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the car can do, from what it is and what state it is in.
|
||||||
|
*
|
||||||
|
* Both halves matter and they are different kinds of thing: the spec is the
|
||||||
|
* car you chose at the garage, the condition is what this sortie has done to
|
||||||
|
* it. Everything here is the spec's own figure scaled by wear, so the
|
||||||
|
* catalogue's numbers mean what they say.
|
||||||
|
*/
|
||||||
|
export function deriveHandling(c: CarCondition, spec: CarSpec = REFERENCE_CAR): Handling {
|
||||||
const { engine, tires, chassis } = c.level;
|
const { engine, tires, chassis } = c.level;
|
||||||
return {
|
return {
|
||||||
// A tired engine simply cannot push as hard.
|
// A tired engine simply cannot push as hard.
|
||||||
engineForce: lerp(900, 2600, engine),
|
engineForce: lerp(spec.engineForce * RUINED.drive, spec.engineForce, engine),
|
||||||
// Worn pads take longer to haul the car down. These are Rapier brake
|
// Worn pads take longer to haul the car down. These are Rapier brake
|
||||||
// impulses, which have to be large next to a 1100kg chassis — the first
|
// impulses, which have to be large next to a 1100kg chassis — the first
|
||||||
// values here were so weak the car would not come to a standstill at all.
|
// values here were so weak the car would not come to a standstill at all.
|
||||||
// Tuned to about 0.8g fresh: roughly 25m from 70km/h. Much past this and
|
// Tuned to about 0.8g fresh: roughly 25m from 70km/h. Much past this and
|
||||||
// the brake pedal stops feeling like a brake and starts feeling like a wall.
|
// the brake pedal stops feeling like a brake and starts feeling like a wall.
|
||||||
brakeForce: lerp(12, 36, tires),
|
brakeForce: lerp(spec.brakeForce * RUINED.brake, spec.brakeForce, tires),
|
||||||
// Lifting off has to actually slow you down. Without this the car coasts
|
// Lifting off has to actually slow you down. Without this the car coasts
|
||||||
// almost forever and every stop needs a deliberate stab at the brake.
|
// almost forever and every stop needs a deliberate stab at the brake.
|
||||||
coastBrake: lerp(4, 9, engine),
|
coastBrake: lerp(4, 9, engine),
|
||||||
maxSteer: lerp(0.4, 0.62, chassis),
|
maxSteer: lerp(spec.maxSteer * RUINED.steer, spec.maxSteer, chassis),
|
||||||
// Bald tyres are the most legible failure: the back end starts to leave.
|
// Bald tyres are the most legible failure: the back end starts to leave.
|
||||||
frictionSlip: lerp(1.6, 5, tires),
|
frictionSlip: lerp(spec.grip * RUINED.grip, spec.grip, tires),
|
||||||
sideFrictionStiffness: lerp(0.5, 1, tires),
|
sideFrictionStiffness: lerp(0.5, 1, tires),
|
||||||
/*
|
/*
|
||||||
* A bent chassis pulls to one side. Sign is stable for a given car.
|
* A bent chassis pulls to one side. Sign is stable for a given car.
|
||||||
@ -207,8 +237,13 @@ const MAX_DAMAGE_PER_STEP = 0.85;
|
|||||||
* the ratio the whole risk/reward loop rests on: enough slack to gamble with,
|
* the ratio the whole risk/reward loop rests on: enough slack to gamble with,
|
||||||
* not enough to ignore.
|
* not enough to ignore.
|
||||||
*/
|
*/
|
||||||
export function applyWear(c: CarCondition, w: WearInput): CarCondition {
|
export function applyWear(
|
||||||
const impact = w.impactForce / IMPACT_REFERENCE;
|
c: CarCondition,
|
||||||
|
w: WearInput,
|
||||||
|
/** How much of a knock this car actually feels. Armour is a number below 1. */
|
||||||
|
fragility = 1,
|
||||||
|
): CarCondition {
|
||||||
|
const impact = (w.impactForce / IMPACT_REFERENCE) * fragility;
|
||||||
const damage: Subsystems = {
|
const damage: Subsystems = {
|
||||||
// Ordered by what a collision actually ruins: the shell takes the worst of
|
// Ordered by what a collision actually ruins: the shell takes the worst of
|
||||||
// it, the tyres and suspension a good share, the engine least of all.
|
// it, the tyres and suspension a good share, the engine least of all.
|
||||||
|
|||||||
@ -116,6 +116,12 @@ export function stepCombat(
|
|||||||
step: CombatStep,
|
step: CombatStep,
|
||||||
condition: CarCondition,
|
condition: CarCondition,
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
|
/**
|
||||||
|
* How much of a round the car actually feels. An armoured car is what this
|
||||||
|
* number is for: being shot at is precisely the situation it is bought for,
|
||||||
|
* so armour has to count here as much as it does against a wall.
|
||||||
|
*/
|
||||||
|
fragility = 1,
|
||||||
): CombatResult {
|
): CombatResult {
|
||||||
const { dt } = step;
|
const { dt } = step;
|
||||||
let playerHit = false;
|
let playerHit = false;
|
||||||
@ -185,16 +191,19 @@ export function stepCombat(
|
|||||||
// Routed through the same wear model as everything else, so a bullet
|
// Routed through the same wear model as everything else, so a bullet
|
||||||
// costs you ceiling too — it is permanent in the same way a crash is.
|
// costs you ceiling too — it is permanent in the same way a crash is.
|
||||||
updated = applyWear(updated, { dt, distance: 0, throttle: 0, impactForce: 0 });
|
updated = applyWear(updated, { dt, distance: 0, throttle: 0, impactForce: 0 });
|
||||||
|
const engineHit = HIT_ENGINE * fragility;
|
||||||
|
const tyreHit = HIT_TIRES * fragility;
|
||||||
|
const chassisHit = HIT_CHASSIS * fragility;
|
||||||
updated = {
|
updated = {
|
||||||
level: {
|
level: {
|
||||||
engine: Math.max(0, updated.level.engine - HIT_ENGINE),
|
engine: Math.max(0, updated.level.engine - engineHit),
|
||||||
tires: Math.max(0, updated.level.tires - HIT_TIRES),
|
tires: Math.max(0, updated.level.tires - tyreHit),
|
||||||
chassis: Math.max(0, updated.level.chassis - HIT_CHASSIS),
|
chassis: Math.max(0, updated.level.chassis - chassisHit),
|
||||||
},
|
},
|
||||||
ceiling: {
|
ceiling: {
|
||||||
engine: Math.max(0, updated.ceiling.engine - HIT_ENGINE * 0.3),
|
engine: Math.max(0, updated.ceiling.engine - engineHit * 0.3),
|
||||||
tires: Math.max(0, updated.ceiling.tires - HIT_TIRES * 0.3),
|
tires: Math.max(0, updated.ceiling.tires - tyreHit * 0.3),
|
||||||
chassis: Math.max(0, updated.ceiling.chassis - HIT_CHASSIS * 0.3),
|
chassis: Math.max(0, updated.ceiling.chassis - chassisHit * 0.3),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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 } from './garage';
|
||||||
|
import { applyWear, deriveHandling, freshCondition } from './car';
|
||||||
|
|
||||||
describe('the catalogue', () => {
|
describe('the catalogue', () => {
|
||||||
it('gives you something to drive for nothing', () => {
|
it('gives you something to drive for nothing', () => {
|
||||||
@ -90,3 +91,57 @@ describe('buying and taking cars', () => {
|
|||||||
expect(carById('no-such-car').id).toBe(CARS[0]!.id);
|
expect(carById('no-such-car').id).toBe(CARS[0]!.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('what the car you picked actually changes', () => {
|
||||||
|
const fresh = freshCondition();
|
||||||
|
const tractor = carById('tractor');
|
||||||
|
const armoured = carById('armoured');
|
||||||
|
|
||||||
|
it('drives like the car in the catalogue, not like one car with a skin', () => {
|
||||||
|
const slow = deriveHandling(fresh, tractor);
|
||||||
|
const quick = deriveHandling(fresh, armoured);
|
||||||
|
expect(slow.engineForce).toBeCloseTo(tractor.engineForce, 6);
|
||||||
|
expect(quick.engineForce).toBeCloseTo(armoured.engineForce, 6);
|
||||||
|
// A tractor turns tighter than an armoured car and grips less.
|
||||||
|
expect(slow.maxSteer).toBeGreaterThan(quick.maxSteer);
|
||||||
|
expect(slow.frictionSlip).toBeLessThan(quick.frictionSlip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still leaves a ruined car driveable, whichever car it is', () => {
|
||||||
|
// The garage exists so that being in a bad way is a situation rather than
|
||||||
|
// an ending. A car that stops entirely at 0% is a fail state wearing a dial.
|
||||||
|
const ruined = {
|
||||||
|
level: { engine: 0, tires: 0, chassis: 0 },
|
||||||
|
ceiling: { engine: 1, tires: 1, chassis: 1 },
|
||||||
|
};
|
||||||
|
for (const spec of CARS) {
|
||||||
|
const h = deriveHandling(ruined, spec);
|
||||||
|
expect(h.engineForce).toBeGreaterThan(0);
|
||||||
|
expect(h.maxSteer).toBeGreaterThan(0);
|
||||||
|
expect(h.brakeForce).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes armour worth the attention it costs', () => {
|
||||||
|
// The same crash, in the cheapest car and the dearest.
|
||||||
|
const crash = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 2.47e6 };
|
||||||
|
const inTractor = applyWear(fresh, crash, tractor.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);
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
const knock = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 6e5 };
|
||||||
|
const tin = applyWear(fresh, knock, tractor.fragility);
|
||||||
|
const steel = applyWear(fresh, knock, armoured.fragility);
|
||||||
|
expect(1 - tin.level.chassis).toBeGreaterThan((1 - steel.level.chassis) * 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user