Phase 2: bases, quest board, and known-vs-new targets
Four allied bases, placed by greedy farthest-point so no choke point can strand the player, with the first at the spawn junction so the loop is available at once. Missions hand in at any base, not the issuing one. The board offers known and new targets side by side and describes each route in words. Crucially it never reads heat directly — it reads sim/intel.ts, a record of what the player has actually observed and when. Notes age while roads keep escalating unwatched, so a known route is knowable, stale, and never a promise. Recon missions survey everything within 130m, which is how a new target becomes a known one without driving every road there. New targets pay 1.8x. That multiplier is the phase's tuning dial: too low and nobody takes the unknown, too high and nobody takes the known. Payment is parts, which repair the car. To keep "decline, not reset" intact, each subsystem gains a ceiling that falls permanently with damage — repairs restore up to what the car is still capable of, never to what it was. Without an economy the board's rewards had no stakes; with one, the risky job is what keeps the car running a while longer. Adds Dijkstra over the road graph, taking a cost function so a "safest route" preview is a small change later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
475db8773c
commit
73e8ac68f1
86
README.md
86
README.md
@ -4,6 +4,7 @@ Endless driving survival game.
|
|||||||
|
|
||||||
- **Phase 0** — a drivable car with persistent wear that is felt through the wheel.
|
- **Phase 0** — a drivable car with persistent wear that is felt through the wheel.
|
||||||
- **Phase 1** — a road network whose roads remember being driven, and escalate.
|
- **Phase 1** — a road network whose roads remember being driven, and escalate.
|
||||||
|
- **Phase 2** — bases, a quest board, and known-vs-new target selection.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
@ -20,6 +21,10 @@ seeds may be numbers or words.
|
|||||||
| `A` / `D` | steer |
|
| `A` / `D` | steer |
|
||||||
| `Space` | handbrake (rear wheels only) |
|
| `Space` | handbrake (rear wheels only) |
|
||||||
| `R` | respawn the car — **does not** repair it |
|
| `R` | respawn the car — **does not** repair it |
|
||||||
|
| `1`–`3` | accept a mission, when parked at a base |
|
||||||
|
|
||||||
|
Drive to a green beacon (a base) and stop. Take a job, drive to the amber
|
||||||
|
beacon, stop and hold position for three seconds, then return to **any** base.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test # sim + headless physics
|
npm test # sim + headless physics
|
||||||
@ -78,6 +83,45 @@ The road network is a jittered grid with roughly a quarter of its edges removed,
|
|||||||
keeping the whole thing connected. The loops are the point: "take a different
|
keeping the whole thing connected. The loops are the point: "take a different
|
||||||
road this time" is not a decision unless alternative routes exist.
|
road this time" is not a decision unless alternative routes exist.
|
||||||
|
|
||||||
|
## Missions and intel
|
||||||
|
|
||||||
|
The board at each base offers a mix of **known targets** and **new** ones, and
|
||||||
|
describes the route to each in words rather than numbers — "barricaded · last
|
||||||
|
word 6 min ago", "no one has been down that way".
|
||||||
|
|
||||||
|
The important part is *where those words come from*. The board never reads heat
|
||||||
|
directly. It reads `sim/intel.ts`, which is a snapshot of what the player has
|
||||||
|
actually observed and when. Drive a road and you write down what was on it;
|
||||||
|
leave it alone and your note ages while the road keeps escalating without you.
|
||||||
|
So a known route is genuinely knowable, genuinely stale, and never a guarantee —
|
||||||
|
which is the tension the brief asks for.
|
||||||
|
|
||||||
|
**Recon missions** are the tool for converting a new target into a known one:
|
||||||
|
completing one surveys every road within 130 m, filling in the map without
|
||||||
|
having to drive each one.
|
||||||
|
|
||||||
|
Two mission types run end to end: **supply runs** and **surveys**. Both are
|
||||||
|
travel → hold position at the target → return. Hand-in works at *any* base, not
|
||||||
|
the issuing one, because the brief is explicit that a blocked road home must
|
||||||
|
never strand the player.
|
||||||
|
|
||||||
|
### Rewards, and why repairs exist
|
||||||
|
|
||||||
|
New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. That multiplier is
|
||||||
|
the phase's tuning dial: if you never take the new target it is too low, and if
|
||||||
|
you never take the known one it is too high.
|
||||||
|
|
||||||
|
Payment is in **parts**, which repair the car — the only thing in the game that
|
||||||
|
pushes condition back up. Repairs go to the worst subsystem first and stop at
|
||||||
|
its **ceiling**, which drops permanently with every bit of damage taken
|
||||||
|
(`PERMANENT_SHARE`, currently 30%). So a car can be patched back to what it is
|
||||||
|
still capable of, but never to what it was. The HUD bars show this directly: `█`
|
||||||
|
is current condition, `·` is what repairs could still reach, `×` is gone.
|
||||||
|
|
||||||
|
This is what gives the board's reward a reason to matter. Without an economy the
|
||||||
|
choice between a safe route and a risky one has no stakes; with one, the risky
|
||||||
|
job is what keeps the car alive a while longer.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.**
|
The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.**
|
||||||
@ -88,11 +132,14 @@ ever opening a browser.
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
sim/ pure model — roads, heat, world generation, condition → handling
|
sim/ pure model, no engine imports:
|
||||||
|
world, roads, heat — the map and its memory
|
||||||
|
intel, routing, quests — what the player knows and is asked to do
|
||||||
|
bases, car — where missions come from, and decline
|
||||||
physics/ Rapier world, raycast vehicle, input → wheel forces
|
physics/ Rapier world, raycast vehicle, input → wheel forces
|
||||||
render/ three.js scene, roads, chase camera
|
render/ three.js scene, roads, markers, chase camera
|
||||||
core/ fixed-timestep loop, seeded RNG, keyboard
|
core/ fixed-timestep loop, seeded RNG, keyboard
|
||||||
ui/ debug HUD
|
ui/ debug HUD, quest board
|
||||||
carSpec.ts shared car dimensions, so body and mesh cannot drift apart
|
carSpec.ts shared car dimensions, so body and mesh cannot drift apart
|
||||||
heatProps.ts integration layer: heat levels → colliders + meshes
|
heatProps.ts integration layer: heat levels → colliders + meshes
|
||||||
```
|
```
|
||||||
@ -115,8 +162,16 @@ one place where "how broken the car is" turns into "how it drives".
|
|||||||
rather than hours. Turn them down in `applyWear` once the curve reads right.
|
rather than hours. Turn them down in `applyWear` once the curve reads right.
|
||||||
- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step
|
- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step
|
||||||
rate changes.
|
rate changes.
|
||||||
- **Condition and heat are not yet persisted** across reloads. IndexedDB comes
|
- **Nothing is persisted** across reloads — condition, heat, intel and completed
|
||||||
with the campaign layer.
|
runs all reset. IndexedDB comes with the campaign layer, and `sim/intel.ts`
|
||||||
|
plus `sim/heat.ts` are the two things that most need it.
|
||||||
|
- **Repairs are automatic on hand-in.** Parts get spent the moment you report
|
||||||
|
back. The brief wants repair to be a physical, on-foot act with scavenged
|
||||||
|
parts; that is Phase 6.
|
||||||
|
- **The board's route preview assumes the shortest path.** If you drive a
|
||||||
|
different way, the intel you were shown described a route you did not take.
|
||||||
|
`findRoute` already accepts a cost function, so a "safest route" preview is a
|
||||||
|
small change when it is wanted.
|
||||||
- **Junctions have no heat of their own.** A point near a junction is credited to
|
- **Junctions have no heat of their own.** A point near a junction is credited to
|
||||||
whichever segment is nearest, so a heavily used crossroads never fortifies as a
|
whichever segment is nearest, so a heavily used crossroads never fortifies as a
|
||||||
crossroads. Known gap, deferred on purpose.
|
crossroads. Known gap, deferred on purpose.
|
||||||
@ -138,11 +193,18 @@ one place where "how broken the car is" turns into "how it drives".
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
Phase 1's gate: **you catch yourself avoiding a road because of its history, not
|
Two gates are open at once, and both need a person, not a test.
|
||||||
its distance.** That cannot be checked by a test — it needs you driving the same
|
|
||||||
routes for a while and noticing what you start doing.
|
|
||||||
|
|
||||||
If it fails, the likely culprits, in order: escalation is too slow to matter
|
**Phase 1:** you catch yourself avoiding a road because of its history, not its
|
||||||
within a session (`METRES_PER_HEAT`), decay is so fast that nothing accumulates
|
distance. If it fails, check `METRES_PER_HEAT` (escalation too slow to matter in
|
||||||
(`DECAY_PER_SECOND`), or the props are not actually inconvenient enough to route
|
one session), `DECAY_PER_SECOND` (nothing accumulates), or whether the props are
|
||||||
around. Tune before building Phase 2 on top.
|
actually inconvenient enough to route around.
|
||||||
|
|
||||||
|
**Phase 2:** you take the new target a meaningful fraction of the time, for
|
||||||
|
reasons other than curiosity — because the known one has genuinely got
|
||||||
|
expensive. If the known option is always strictly better, raise `NOVELTY_BONUS`.
|
||||||
|
If you never take a known target, lower it.
|
||||||
|
|
||||||
|
Watch for one failure mode in particular: if you find yourself taking whichever
|
||||||
|
job is *nearest* and ignoring the intel line entirely, the board is decorative
|
||||||
|
and the phase has not landed, whatever the reward numbers say.
|
||||||
|
|||||||
@ -5,6 +5,8 @@ export interface DriverInput {
|
|||||||
steer: number;
|
steer: number;
|
||||||
handbrake: boolean;
|
handbrake: boolean;
|
||||||
respawn: boolean;
|
respawn: boolean;
|
||||||
|
/** 1-based board selection pressed this frame, or null. Consumed on read. */
|
||||||
|
select: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEYS = {
|
const KEYS = {
|
||||||
@ -16,30 +18,47 @@ const KEYS = {
|
|||||||
respawn: ['KeyR'],
|
respawn: ['KeyR'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4'];
|
||||||
|
|
||||||
export function createInput(): { read(): DriverInput; dispose(): void } {
|
export function createInput(): { read(): DriverInput; dispose(): void } {
|
||||||
const down = new Set<string>();
|
const down = new Set<string>();
|
||||||
const held = (codes: readonly string[]) => codes.some((c) => down.has(c));
|
const held = (codes: readonly string[]) => codes.some((c) => down.has(c));
|
||||||
|
// Buffered rather than polled: a keypress must not be missed just because it
|
||||||
|
// landed between two fixed steps, and must not fire twice if it spans three.
|
||||||
|
let pendingSelect: number | null = null;
|
||||||
|
|
||||||
const onDown = (e: KeyboardEvent) => {
|
const onDown = (e: KeyboardEvent) => {
|
||||||
|
if (!down.has(e.code)) {
|
||||||
|
const index = SELECT_KEYS.indexOf(e.code);
|
||||||
|
if (index >= 0) pendingSelect = index + 1;
|
||||||
|
}
|
||||||
down.add(e.code);
|
down.add(e.code);
|
||||||
if (Object.values(KEYS).some((codes) => (codes as readonly string[]).includes(e.code))) {
|
if (Object.values(KEYS).some((codes) => (codes as readonly string[]).includes(e.code))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onUp = (e: KeyboardEvent) => down.delete(e.code);
|
const onUp = (e: KeyboardEvent) => down.delete(e.code);
|
||||||
const onBlur = () => down.clear();
|
const onBlur = () => {
|
||||||
|
down.clear();
|
||||||
|
pendingSelect = null;
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', onDown);
|
window.addEventListener('keydown', onDown);
|
||||||
window.addEventListener('keyup', onUp);
|
window.addEventListener('keyup', onUp);
|
||||||
window.addEventListener('blur', onBlur);
|
window.addEventListener('blur', onBlur);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
read: () => ({
|
read: () => {
|
||||||
|
const select = pendingSelect;
|
||||||
|
pendingSelect = null;
|
||||||
|
return {
|
||||||
throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0),
|
throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0),
|
||||||
steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0),
|
steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0),
|
||||||
handbrake: held(KEYS.handbrake),
|
handbrake: held(KEYS.handbrake),
|
||||||
respawn: held(KEYS.respawn),
|
respawn: held(KEYS.respawn),
|
||||||
}),
|
select,
|
||||||
|
};
|
||||||
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
window.removeEventListener('keydown', onDown);
|
window.removeEventListener('keydown', onDown);
|
||||||
window.removeEventListener('keyup', onUp);
|
window.removeEventListener('keyup', onUp);
|
||||||
|
|||||||
123
src/main.ts
123
src/main.ts
@ -1,7 +1,11 @@
|
|||||||
import { generateWorld } from './sim/world';
|
import { generateWorld } from './sim/world';
|
||||||
import { applyWear, deriveHandling, freshCondition } from './sim/car';
|
import { applyWear, deriveHandling, freshCondition, repair } from './sim/car';
|
||||||
import { createHeat, stepHeat } from './sim/heat';
|
import { createHeat, stepHeat } from './sim/heat';
|
||||||
import { routeAt, segmentAt } from './sim/roads';
|
import { routeAt, segmentAt } from './sim/roads';
|
||||||
|
import { baseAt, placeBases } from './sim/bases';
|
||||||
|
import { buildGraph } from './sim/routing';
|
||||||
|
import { createIntel, observe, survey } from './sim/intel';
|
||||||
|
import { accept, createQuests, offersAt, stepQuest, type Offer } from './sim/quests';
|
||||||
import { createHeatProps } from './heatProps';
|
import { createHeatProps } from './heatProps';
|
||||||
import { seedFromString } from './core/rng';
|
import { seedFromString } from './core/rng';
|
||||||
import { startLoop } from './core/loop';
|
import { startLoop } from './core/loop';
|
||||||
@ -9,7 +13,9 @@ import { createInput } from './core/input';
|
|||||||
import { createPhysics } from './physics/physics';
|
import { createPhysics } from './physics/physics';
|
||||||
import { createDriveState, drive } from './physics/drive';
|
import { createDriveState, drive } from './physics/drive';
|
||||||
import { createScene, updateCamera } from './render/scene';
|
import { createScene, updateCamera } from './render/scene';
|
||||||
|
import { createMarkers } from './render/markers';
|
||||||
import { createHud } from './ui/hud';
|
import { createHud } from './ui/hud';
|
||||||
|
import { createBoard } from './ui/board';
|
||||||
import { CAR, WHEELS } from './carSpec';
|
import { CAR, WHEELS } from './carSpec';
|
||||||
|
|
||||||
function resolveSeed(): number {
|
function resolveSeed(): number {
|
||||||
@ -22,14 +28,20 @@ function resolveSeed(): number {
|
|||||||
async function boot() {
|
async function boot() {
|
||||||
const seed = resolveSeed();
|
const seed = resolveSeed();
|
||||||
const model = generateWorld(seed);
|
const model = generateWorld(seed);
|
||||||
|
const bases = placeBases(model.roads, model.spawn);
|
||||||
|
const graph = buildGraph(model.roads);
|
||||||
|
|
||||||
const physics = await createPhysics(model);
|
const physics = await createPhysics(model);
|
||||||
const view = createScene(model);
|
const view = createScene(model);
|
||||||
|
const markers = createMarkers(view.scene, bases);
|
||||||
const input = createInput();
|
const input = createInput();
|
||||||
const hud = createHud(seed);
|
const hud = createHud(seed);
|
||||||
|
const board = createBoard();
|
||||||
const driveState = createDriveState();
|
const driveState = createDriveState();
|
||||||
const heat = createHeat(model.roads);
|
const heat = createHeat(model.roads);
|
||||||
const heatProps = createHeatProps(model.roads, physics, view.scene);
|
const heatProps = createHeatProps(model.roads, physics, view.scene);
|
||||||
|
const intel = createIntel(model.roads);
|
||||||
|
const quests = createQuests();
|
||||||
|
|
||||||
let condition = freshCondition();
|
let condition = freshCondition();
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
@ -37,6 +49,18 @@ async function boot() {
|
|||||||
/** The road being travelled along — includes the verge, not just the tarmac. */
|
/** The road being travelled along — includes the verge, not just the tarmac. */
|
||||||
let currentSegment: number | null = null;
|
let currentSegment: number | null = null;
|
||||||
let onTarmac = false;
|
let onTarmac = false;
|
||||||
|
/** Offers are generated once per arrival at a base, not every frame. */
|
||||||
|
let openBase: number | null = null;
|
||||||
|
let offers: Offer[] = [];
|
||||||
|
let notice = '';
|
||||||
|
let noticeUntil = 0;
|
||||||
|
|
||||||
|
const say = (text: string, seconds = 4) => {
|
||||||
|
notice = text;
|
||||||
|
noticeUntil = elapsed + seconds;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeOf = (id: number) => model.roads.nodes[id]!;
|
||||||
|
|
||||||
document.getElementById('boot')?.remove();
|
document.getElementById('boot')?.remove();
|
||||||
|
|
||||||
@ -65,7 +89,81 @@ async function boot() {
|
|||||||
const at = physics.chassis.translation();
|
const at = physics.chassis.translation();
|
||||||
currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null;
|
currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null;
|
||||||
onTarmac = segmentAt(model.roads, at.x, at.z) !== null;
|
onTarmac = segmentAt(model.roads, at.x, at.z) !== null;
|
||||||
heatProps.sync(stepHeat(heat, { dt, segmentId: currentSegment, distance }), heat, currentSegment);
|
heatProps.sync(
|
||||||
|
stepHeat(heat, { dt, segmentId: currentSegment, distance }),
|
||||||
|
heat,
|
||||||
|
currentSegment,
|
||||||
|
);
|
||||||
|
// Driving a road is how you learn what is on it.
|
||||||
|
if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed);
|
||||||
|
|
||||||
|
const base = baseAt(bases, at.x, at.z);
|
||||||
|
const stopped = Math.abs(speed) < 2.5;
|
||||||
|
|
||||||
|
// --- Quest board: only when parked at a base with nothing in hand ---
|
||||||
|
if (base && stopped && !quests.active) {
|
||||||
|
if (openBase !== base.nodeId) {
|
||||||
|
openBase = base.nodeId;
|
||||||
|
// Seeded by base and runs completed, not by time: leaving and coming
|
||||||
|
// straight back must not reroll a board you did not like.
|
||||||
|
offers = offersAt(
|
||||||
|
quests,
|
||||||
|
model.roads,
|
||||||
|
graph,
|
||||||
|
intel,
|
||||||
|
base,
|
||||||
|
elapsed,
|
||||||
|
seed ^ (base.nodeId * 31) ^ (quests.completed * 7919),
|
||||||
|
);
|
||||||
|
board.show(base, offers);
|
||||||
|
}
|
||||||
|
const chosen = cmd.select === null ? undefined : offers[cmd.select - 1];
|
||||||
|
if (chosen) {
|
||||||
|
accept(quests, chosen, base);
|
||||||
|
board.hide();
|
||||||
|
openBase = null;
|
||||||
|
say(`${chosen.type === 'recon' ? 'Survey' : 'Supply run'} accepted.`);
|
||||||
|
}
|
||||||
|
} else if (openBase !== null) {
|
||||||
|
openBase = null;
|
||||||
|
board.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Active mission ---
|
||||||
|
const quest = quests.active;
|
||||||
|
if (quest) {
|
||||||
|
const target = nodeOf(quest.targetNode);
|
||||||
|
const event = stepQuest(quests, {
|
||||||
|
dt,
|
||||||
|
distanceToTarget: Math.hypot(target.x - at.x, target.z - at.z),
|
||||||
|
atBase: base,
|
||||||
|
speed: Math.abs(speed),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (event?.kind === 'arrived') {
|
||||||
|
intel.visitedNodes.add(quest.targetNode);
|
||||||
|
say('At the target. Stop the car and hold position.');
|
||||||
|
} else if (event?.kind === 'worked') {
|
||||||
|
if (event.type === 'recon') {
|
||||||
|
const count = survey(intel, heat, model.roads, target.x, target.z, elapsed);
|
||||||
|
say(`Survey complete — ${count} roads mapped. Report back to any base.`);
|
||||||
|
} else {
|
||||||
|
say('Cargo delivered. Report back to any base.');
|
||||||
|
}
|
||||||
|
} else if (event?.kind === 'completed') {
|
||||||
|
const outcome = repair(condition, quests.parts);
|
||||||
|
const spent = quests.parts - outcome.unused;
|
||||||
|
condition = outcome.condition;
|
||||||
|
quests.parts = outcome.unused;
|
||||||
|
say(
|
||||||
|
spent > 0.001
|
||||||
|
? `Handed in. Repairs used ${spent.toFixed(2)} parts.`
|
||||||
|
: 'Handed in. Nothing left worth repairing.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markers.setObjective(quests.active ? nodeOf(quests.active.targetNode) : null);
|
||||||
},
|
},
|
||||||
|
|
||||||
render(_alpha, frameDt) {
|
render(_alpha, frameDt) {
|
||||||
@ -97,14 +195,33 @@ async function boot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
view.followSun();
|
view.followSun();
|
||||||
|
markers.update(elapsed);
|
||||||
updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed());
|
updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed());
|
||||||
view.renderer.render(view.scene, view.camera);
|
view.renderer.render(view.scene, view.camera);
|
||||||
|
|
||||||
|
const quest = quests.active;
|
||||||
|
const target = quest ? nodeOf(quest.targetNode) : null;
|
||||||
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
||||||
|
heat: {
|
||||||
segmentId: currentSegment,
|
segmentId: currentSegment,
|
||||||
onTarmac,
|
onTarmac,
|
||||||
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
|
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
|
||||||
level: currentSegment === null ? null : heat.level[currentSegment]!,
|
level: currentSegment === null ? null : heat.level[currentSegment]!,
|
||||||
hottest: Math.max(...heat.value),
|
},
|
||||||
|
quest: quest
|
||||||
|
? {
|
||||||
|
type: quest.type,
|
||||||
|
stage: quest.stage,
|
||||||
|
novel: quest.novel,
|
||||||
|
worked: quest.worked,
|
||||||
|
distance: target
|
||||||
|
? Math.hypot(target.x - p.x, target.z - p.z)
|
||||||
|
: 0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
completed: quests.completed,
|
||||||
|
parts: quests.parts,
|
||||||
|
notice: elapsed < noticeUntil ? notice : '',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,7 +6,13 @@ import type { DriverInput } from '../core/input';
|
|||||||
import { generateWorld } from '../sim/world';
|
import { generateWorld } from '../sim/world';
|
||||||
|
|
||||||
const STEP = 1 / 60;
|
const STEP = 1 / 60;
|
||||||
const IDLE: DriverInput = { throttle: 0, steer: 0, handbrake: false, respawn: false };
|
const IDLE: DriverInput = {
|
||||||
|
throttle: 0,
|
||||||
|
steer: 0,
|
||||||
|
handbrake: false,
|
||||||
|
respawn: false,
|
||||||
|
select: null,
|
||||||
|
};
|
||||||
|
|
||||||
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
||||||
async function run(input: Partial<DriverInput>, seconds: number) {
|
async function run(input: Partial<DriverInput>, seconds: number) {
|
||||||
|
|||||||
55
src/render/markers.ts
Normal file
55
src/render/markers.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Base } from '../sim/bases';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bases and the active objective, as things you can see from a distance.
|
||||||
|
* The player navigates by looking, so both need to be visible over scenery.
|
||||||
|
*/
|
||||||
|
const BEACON_HEIGHT = 26;
|
||||||
|
|
||||||
|
function beacon(colour: number, radius: number): THREE.Mesh {
|
||||||
|
return new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(radius, radius, BEACON_HEIGHT, 12, 1, true),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: colour,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.18,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarkers(scene: THREE.Scene, bases: Base[]) {
|
||||||
|
for (const base of bases) {
|
||||||
|
const hut = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(7, 3.4, 7),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x3f5a46, roughness: 0.85 }),
|
||||||
|
);
|
||||||
|
hut.position.set(base.x, 1.7, base.z);
|
||||||
|
hut.castShadow = true;
|
||||||
|
hut.receiveShadow = true;
|
||||||
|
scene.add(hut);
|
||||||
|
|
||||||
|
const light = beacon(0x5fd08a, 5);
|
||||||
|
light.position.set(base.x, BEACON_HEIGHT / 2, base.z);
|
||||||
|
scene.add(light);
|
||||||
|
}
|
||||||
|
|
||||||
|
const objective = beacon(0xffc247, 6);
|
||||||
|
objective.position.y = BEACON_HEIGHT / 2;
|
||||||
|
objective.visible = false;
|
||||||
|
scene.add(objective);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Point the objective beacon at a target, or hide it when idle. */
|
||||||
|
setObjective(target: { x: number; z: number } | null) {
|
||||||
|
objective.visible = target !== null;
|
||||||
|
if (target) objective.position.set(target.x, BEACON_HEIGHT / 2, target.z);
|
||||||
|
},
|
||||||
|
/** Slow spin, so a beacon reads as a marker rather than scenery. */
|
||||||
|
update(elapsed: number) {
|
||||||
|
objective.rotation.y = elapsed * 0.6;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
69
src/sim/bases.ts
Normal file
69
src/sim/bases.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Allied bases — where missions come from. Pure, no engine imports.
|
||||||
|
*
|
||||||
|
* There is deliberately more than one. The brief is explicit that no single
|
||||||
|
* choke point may strand the player: if the roads around your nearest base turn
|
||||||
|
* hostile, another base further out has to still offer a way back into the loop.
|
||||||
|
* That only works if they are spread out, hence farthest-point selection rather
|
||||||
|
* than random placement.
|
||||||
|
*/
|
||||||
|
import type { RoadNetwork } from './roads';
|
||||||
|
|
||||||
|
export interface Base {
|
||||||
|
nodeId: number;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAMES = ['Anvil', 'Birch', 'Cinder', 'Dovetail', 'Ember', 'Foxglove'];
|
||||||
|
|
||||||
|
export function placeBases(roads: RoadNetwork, spawn: { x: number; z: number }, count = 4): Base[] {
|
||||||
|
const nodes = roads.nodes;
|
||||||
|
|
||||||
|
// The first base is the one the player starts at, so the loop is available
|
||||||
|
// immediately rather than after a hunt.
|
||||||
|
const first = nodes.reduce((best, n) =>
|
||||||
|
Math.hypot(n.x - spawn.x, n.z - spawn.z) < Math.hypot(best.x - spawn.x, best.z - spawn.z)
|
||||||
|
? n
|
||||||
|
: best,
|
||||||
|
);
|
||||||
|
|
||||||
|
const chosen = [first];
|
||||||
|
while (chosen.length < Math.min(count, nodes.length)) {
|
||||||
|
// Greedy farthest-point: repeatedly take whichever node is furthest from
|
||||||
|
// everything already chosen. Spreads bases across the map without clumping.
|
||||||
|
let best = nodes[0]!;
|
||||||
|
let bestDistance = -1;
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (chosen.some((c) => c.id === n.id)) continue;
|
||||||
|
const nearest = Math.min(...chosen.map((c) => Math.hypot(c.x - n.x, c.z - n.z)));
|
||||||
|
if (nearest > bestDistance) {
|
||||||
|
bestDistance = nearest;
|
||||||
|
best = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chosen.push(best);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chosen.map((n, i) => ({
|
||||||
|
nodeId: n.id,
|
||||||
|
x: n.x,
|
||||||
|
z: n.z,
|
||||||
|
name: NAMES[i % NAMES.length]!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest base within `radius` metres, or null. */
|
||||||
|
export function baseAt(bases: Base[], x: number, z: number, radius = 18): Base | null {
|
||||||
|
let best: Base | null = null;
|
||||||
|
let bestDistance = radius;
|
||||||
|
for (const b of bases) {
|
||||||
|
const d = Math.hypot(b.x - x, b.z - z);
|
||||||
|
if (d < bestDistance) {
|
||||||
|
bestDistance = d;
|
||||||
|
best = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
@ -1,18 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Car condition and what it does to handling. Pure — no engine imports.
|
* Car condition and what it does to handling. Pure — no engine imports.
|
||||||
*
|
*
|
||||||
* Design pillar this serves: "Decline, not reset." Condition only ever falls.
|
* Design pillar this serves: "Decline, not reset." Repairs exist, but every
|
||||||
* Phase 0's whole job is to make that decline *felt* through the wheel, so the
|
* subsystem carries a *ceiling* that only ever falls. Patching a car brings it
|
||||||
* handling numbers the physics layer uses are derived here rather than constants.
|
* back up to what it is still capable of, which is never quite what it was.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface CarCondition {
|
export interface Subsystems {
|
||||||
/** 1 = factory fresh, 0 = ruined. */
|
/** 1 = factory fresh, 0 = ruined. */
|
||||||
engine: number;
|
engine: number;
|
||||||
tires: number;
|
tires: number;
|
||||||
chassis: number;
|
chassis: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CarCondition {
|
||||||
|
/** Current state of each subsystem. */
|
||||||
|
level: Subsystems;
|
||||||
|
/** The best each subsystem can be restored to. Falls permanently with damage. */
|
||||||
|
ceiling: Subsystems;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Handling {
|
export interface Handling {
|
||||||
/** Newtons of drive force available per driven wheel at full throttle. */
|
/** Newtons of drive force available per driven wheel at full throttle. */
|
||||||
engineForce: number;
|
engineForce: number;
|
||||||
@ -27,23 +34,29 @@ export interface Handling {
|
|||||||
steeringPull: number;
|
steeringPull: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const freshCondition = (): CarCondition => ({ engine: 1, tires: 1, chassis: 1 });
|
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
|
||||||
|
|
||||||
|
export const freshCondition = (): CarCondition => ({
|
||||||
|
level: { engine: 1, tires: 1, chassis: 1 },
|
||||||
|
ceiling: { engine: 1, tires: 1, chassis: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
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 {
|
export function deriveHandling(c: CarCondition): Handling {
|
||||||
|
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, c.engine),
|
engineForce: lerp(900, 2600, engine),
|
||||||
// Worn pads take longer to haul the car down.
|
// Worn pads take longer to haul the car down.
|
||||||
brakeForce: lerp(4, 14, c.tires),
|
brakeForce: lerp(4, 14, tires),
|
||||||
maxSteer: lerp(0.35, 0.55, c.chassis),
|
maxSteer: lerp(0.35, 0.55, 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, c.tires),
|
frictionSlip: lerp(1.6, 5, tires),
|
||||||
sideFrictionStiffness: lerp(0.5, 1, c.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.
|
||||||
steeringPull: (1 - c.chassis) * 0.06,
|
steeringPull: (1 - chassis) * 0.06,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,15 +71,58 @@ export interface WearInput {
|
|||||||
impactForce: number;
|
impactForce: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share of any damage that can never be repaired out. This is the pillar in a
|
||||||
|
* single number: at 0 the car is a rental, at 1 repairs do nothing at all.
|
||||||
|
*/
|
||||||
|
const PERMANENT_SHARE = 0.3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a new condition. Numbers here are deliberately aggressive so a
|
* Returns a new condition. Numbers here are deliberately aggressive so a
|
||||||
* 15-minute session shows visible decline — tune down once the feel is right.
|
* 15-minute session shows visible decline — tune down once the feel is right.
|
||||||
*/
|
*/
|
||||||
export function applyWear(c: CarCondition, w: WearInput): CarCondition {
|
export function applyWear(c: CarCondition, w: WearInput): CarCondition {
|
||||||
const impact = w.impactForce / 1e5;
|
const impact = w.impactForce / 1e5;
|
||||||
return {
|
const damage: Subsystems = {
|
||||||
engine: clamp01(c.engine - w.throttle * w.dt * 6e-4 - impact * 0.01),
|
engine: w.throttle * w.dt * 6e-4 + impact * 0.01,
|
||||||
tires: clamp01(c.tires - w.distance * 8e-5 - impact * 0.02),
|
tires: w.distance * 8e-5 + impact * 0.02,
|
||||||
chassis: clamp01(c.chassis - impact * 0.05),
|
chassis: impact * 0.05,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const level = {} as Subsystems;
|
||||||
|
const ceiling = {} as Subsystems;
|
||||||
|
for (const part of SUBSYSTEMS) {
|
||||||
|
ceiling[part] = clamp01(c.ceiling[part] - damage[part] * PERMANENT_SHARE);
|
||||||
|
level[part] = Math.min(clamp01(c.level[part] - damage[part]), ceiling[part]);
|
||||||
}
|
}
|
||||||
|
return { level, ceiling };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spends parts on the worst subsystem first, never above its ceiling.
|
||||||
|
* Returns the new condition and whatever could not be used.
|
||||||
|
*/
|
||||||
|
export function repair(c: CarCondition, parts: number): { condition: CarCondition; unused: number } {
|
||||||
|
const level = { ...c.level };
|
||||||
|
let remaining = parts;
|
||||||
|
|
||||||
|
// Worst-first: a car with one ruined subsystem drives worse than one that is
|
||||||
|
// evenly tired, so that is where a scarce part belongs.
|
||||||
|
for (let pass = 0; pass < SUBSYSTEMS.length && remaining > 1e-9; pass++) {
|
||||||
|
const worst = [...SUBSYSTEMS]
|
||||||
|
.filter((p) => level[p] < c.ceiling[p] - 1e-9)
|
||||||
|
.sort((a, b) => level[a] - level[b])[0];
|
||||||
|
if (!worst) break;
|
||||||
|
|
||||||
|
const room = c.ceiling[worst] - level[worst];
|
||||||
|
const spend = Math.min(room, remaining);
|
||||||
|
level[worst] += spend;
|
||||||
|
remaining -= spend;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { condition: { level, ceiling: c.ceiling }, unused: remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rough single number for the HUD and for deciding when a car is finished. */
|
||||||
|
export const overallCondition = (c: CarCondition): number =>
|
||||||
|
(c.level.engine + c.level.tires + c.level.chassis) / 3;
|
||||||
|
|||||||
104
src/sim/intel.ts
Normal file
104
src/sim/intel.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* What the player knows about the map, as distinct from what is true.
|
||||||
|
*
|
||||||
|
* The brief insists the player can never be certain a place is as they left it.
|
||||||
|
* So heat is never read directly by the UI — the board reads *this*, a snapshot
|
||||||
|
* of what was last observed and how long ago. Roads you have driven are known
|
||||||
|
* but ageing; roads you have not are blank.
|
||||||
|
*/
|
||||||
|
import { levelFor, type HeatLevel, type HeatState } from './heat';
|
||||||
|
import type { RoadNetwork } from './roads';
|
||||||
|
|
||||||
|
export interface Intel {
|
||||||
|
/** Heat value at the moment each segment was last observed. */
|
||||||
|
rememberedHeat: number[];
|
||||||
|
/** Level at the moment each segment was last observed. */
|
||||||
|
rememberedLevel: HeatLevel[];
|
||||||
|
/** Elapsed time when each segment was last observed; -1 for never. */
|
||||||
|
seenAt: number[];
|
||||||
|
/** Target nodes the player has actually reached. */
|
||||||
|
visitedNodes: Set<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIntel(roads: RoadNetwork): Intel {
|
||||||
|
return {
|
||||||
|
rememberedHeat: new Array(roads.segments.length).fill(0),
|
||||||
|
rememberedLevel: new Array(roads.segments.length).fill('clear'),
|
||||||
|
seenAt: new Array(roads.segments.length).fill(-1),
|
||||||
|
visitedNodes: new Set(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasSeen = (intel: Intel, segmentId: number): boolean => intel.seenAt[segmentId]! >= 0;
|
||||||
|
|
||||||
|
/** Record what a segment looks like right now. */
|
||||||
|
export function observe(intel: Intel, heat: HeatState, segmentId: number, now: number): void {
|
||||||
|
intel.rememberedHeat[segmentId] = heat.value[segmentId]!;
|
||||||
|
intel.rememberedLevel[segmentId] = heat.level[segmentId]!;
|
||||||
|
intel.seenAt[segmentId] = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recon sweep: everything within `radius` of the target is written down.
|
||||||
|
* This is the mechanism the brief describes for turning a new target into a
|
||||||
|
* known one without having to drive every road there first.
|
||||||
|
*/
|
||||||
|
export function survey(
|
||||||
|
intel: Intel,
|
||||||
|
heat: HeatState,
|
||||||
|
roads: RoadNetwork,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
now: number,
|
||||||
|
radius = 130,
|
||||||
|
): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const s of roads.segments) {
|
||||||
|
const near =
|
||||||
|
Math.hypot(s.ax - x, s.az - z) < radius || Math.hypot(s.bx - x, s.bz - z) < radius;
|
||||||
|
if (!near) continue;
|
||||||
|
observe(intel, heat, s.id, now);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteIntel {
|
||||||
|
/** Worst level remembered anywhere on the route. */
|
||||||
|
worst: HeatLevel;
|
||||||
|
/** Segments on the route never observed at all. */
|
||||||
|
unknownCount: number;
|
||||||
|
/** Seconds since the freshest observation went stale, or null if all unknown. */
|
||||||
|
stalest: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarises a route the way a briefing would: the worst thing anyone has seen
|
||||||
|
* on it, how much of it nobody has looked at, and how old that word is.
|
||||||
|
*/
|
||||||
|
export function summariseRoute(intel: Intel, segments: number[], now: number): RouteIntel {
|
||||||
|
let worstRank = 0;
|
||||||
|
let unknownCount = 0;
|
||||||
|
let stalest: number | null = null;
|
||||||
|
|
||||||
|
for (const id of segments) {
|
||||||
|
if (!hasSeen(intel, id)) {
|
||||||
|
unknownCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Judge by the remembered value, not the remembered label, so a road left
|
||||||
|
// just under a threshold does not read as safer than one just over it.
|
||||||
|
const rank = ['clear', 'patrol', 'barricade', 'turret'].indexOf(
|
||||||
|
levelFor(intel.rememberedHeat[id]!, intel.rememberedLevel[id]!),
|
||||||
|
);
|
||||||
|
worstRank = Math.max(worstRank, rank);
|
||||||
|
const age = now - intel.seenAt[id]!;
|
||||||
|
stalest = stalest === null ? age : Math.max(stalest, age);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
worst: (['clear', 'patrol', 'barricade', 'turret'] as const)[worstRank]!,
|
||||||
|
unknownCount,
|
||||||
|
stalest,
|
||||||
|
};
|
||||||
|
}
|
||||||
231
src/sim/quests.test.ts
Normal file
231
src/sim/quests.test.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateWorld } from './world';
|
||||||
|
import { placeBases, baseAt } from './bases';
|
||||||
|
import { buildGraph, findRoute } from './routing';
|
||||||
|
import { createIntel, observe, summariseRoute, survey, hasSeen } from './intel';
|
||||||
|
import { createHeat, stepHeat } from './heat';
|
||||||
|
import { accept, createQuests, offersAt, stepQuest, WORK_SECONDS } from './quests';
|
||||||
|
|
||||||
|
const world = generateWorld(1337);
|
||||||
|
const graph = buildGraph(world.roads);
|
||||||
|
const bases = placeBases(world.roads, world.spawn);
|
||||||
|
|
||||||
|
describe('bases', () => {
|
||||||
|
it('puts one where the player starts, so the loop is available at once', () => {
|
||||||
|
expect(baseAt(bases, world.spawn.x, world.spawn.z)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spreads the rest out, so no single choke point can strand the player', () => {
|
||||||
|
const spacing: number[] = [];
|
||||||
|
for (let i = 0; i < bases.length; i++) {
|
||||||
|
for (let j = i + 1; j < bases.length; j++) {
|
||||||
|
spacing.push(Math.hypot(bases[i]!.x - bases[j]!.x, bases[i]!.z - bases[j]!.z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(Math.min(...spacing)).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches every base by road from every other', () => {
|
||||||
|
for (const from of bases) {
|
||||||
|
for (const to of bases) {
|
||||||
|
if (from === to) continue;
|
||||||
|
expect(findRoute(graph, from.nodeId, to.nodeId)).not.toBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('routing', () => {
|
||||||
|
it('finds a route whose segments actually chain together', () => {
|
||||||
|
const route = findRoute(graph, bases[0]!.nodeId, bases[2]!.nodeId)!;
|
||||||
|
expect(route.nodes[0]).toBe(bases[0]!.nodeId);
|
||||||
|
expect(route.nodes.at(-1)).toBe(bases[2]!.nodeId);
|
||||||
|
expect(route.segments).toHaveLength(route.nodes.length - 1);
|
||||||
|
for (let i = 0; i < route.segments.length; i++) {
|
||||||
|
const s = world.roads.segments[route.segments[i]!]!;
|
||||||
|
const pair = [s.a, s.b];
|
||||||
|
expect(pair).toContain(route.nodes[i]);
|
||||||
|
expect(pair).toContain(route.nodes[i + 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports true metres, and no longer than the straight line is short', () => {
|
||||||
|
const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!;
|
||||||
|
const straight = Math.hypot(bases[0]!.x - bases[1]!.x, bases[0]!.z - bases[1]!.z);
|
||||||
|
expect(route.length).toBeGreaterThanOrEqual(straight - 1e-6);
|
||||||
|
const summed = route.segments.reduce((t, id) => t + world.roads.segments[id]!.length, 0);
|
||||||
|
expect(route.length).toBeCloseTo(summed, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a detour when the direct road is weighted as expensive', () => {
|
||||||
|
const direct = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!;
|
||||||
|
const avoided = new Set(direct.segments);
|
||||||
|
const detour = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId, (id, length) =>
|
||||||
|
avoided.has(id) ? length * 100 : length,
|
||||||
|
)!;
|
||||||
|
// Loops in the network mean an alternative exists and costs more.
|
||||||
|
expect(detour.segments).not.toEqual(direct.segments);
|
||||||
|
expect(detour.length).toBeGreaterThan(direct.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('intel', () => {
|
||||||
|
it('starts blank and only records what was observed', () => {
|
||||||
|
const intel = createIntel(world.roads);
|
||||||
|
const heat = createHeat(world.roads);
|
||||||
|
expect(hasSeen(intel, 4)).toBe(false);
|
||||||
|
observe(intel, heat, 4, 10);
|
||||||
|
expect(hasSeen(intel, 4)).toBe(true);
|
||||||
|
expect(hasSeen(intel, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remembers what a road was, not what it has since become', () => {
|
||||||
|
const intel = createIntel(world.roads);
|
||||||
|
const heat = createHeat(world.roads);
|
||||||
|
observe(intel, heat, 0, 0);
|
||||||
|
expect(intel.rememberedLevel[0]).toBe('clear');
|
||||||
|
|
||||||
|
for (let m = 0; m < 400; m++) stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 });
|
||||||
|
expect(heat.level[0]).toBe('turret');
|
||||||
|
// The player was not there to see it escalate.
|
||||||
|
expect(intel.rememberedLevel[0]).toBe('clear');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surveys a whole area at once, which is what recon is for', () => {
|
||||||
|
const intel = createIntel(world.roads);
|
||||||
|
const heat = createHeat(world.roads);
|
||||||
|
const node = world.roads.nodes[24]!;
|
||||||
|
const mapped = survey(intel, heat, world.roads, node.x, node.z, 5);
|
||||||
|
expect(mapped).toBeGreaterThan(1);
|
||||||
|
expect(intel.seenAt.filter((t) => t >= 0)).toHaveLength(mapped);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarises a route by its worst road and how much is unscouted', () => {
|
||||||
|
const intel = createIntel(world.roads);
|
||||||
|
const heat = createHeat(world.roads);
|
||||||
|
const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!;
|
||||||
|
|
||||||
|
expect(summariseRoute(intel, route.segments, 0).unknownCount).toBe(route.segments.length);
|
||||||
|
|
||||||
|
for (let m = 0; m < 400; m++) {
|
||||||
|
stepHeat(heat, { dt: 1 / 60, segmentId: route.segments[0]!, distance: 1 });
|
||||||
|
}
|
||||||
|
for (const id of route.segments) observe(intel, heat, id, 100);
|
||||||
|
|
||||||
|
const summary = summariseRoute(intel, route.segments, 400);
|
||||||
|
expect(summary.unknownCount).toBe(0);
|
||||||
|
expect(summary.worst).toBe('turret');
|
||||||
|
expect(summary.stalest).toBeCloseTo(300, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('quest board', () => {
|
||||||
|
it('always offers something new to go and look at', () => {
|
||||||
|
const state = createQuests();
|
||||||
|
const intel = createIntel(world.roads);
|
||||||
|
const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 1);
|
||||||
|
expect(offers.length).toBeGreaterThan(0);
|
||||||
|
expect(offers.some((o) => o.novel)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers both a known and an unknown target once somewhere has been visited', () => {
|
||||||
|
const state = createQuests();
|
||||||
|
const intel = createIntel(world.roads);
|
||||||
|
intel.visitedNodes.add(world.roads.nodes[10]!.id);
|
||||||
|
intel.visitedNodes.add(world.roads.nodes[30]!.id);
|
||||||
|
const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 7);
|
||||||
|
expect(offers.some((o) => o.novel)).toBe(true);
|
||||||
|
expect(offers.some((o) => !o.novel)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays more for an unknown target, or nobody would ever take one', () => {
|
||||||
|
// Same target, same route — the only difference is whether it is known.
|
||||||
|
const state = createQuests();
|
||||||
|
const blank = createIntel(world.roads);
|
||||||
|
const known = createIntel(world.roads);
|
||||||
|
for (const n of world.roads.nodes) known.visitedNodes.add(n.id);
|
||||||
|
|
||||||
|
const rewardPerKm = (intel: typeof blank) => {
|
||||||
|
const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 3);
|
||||||
|
return offers.map((o) => o.reward / (o.route.length / 1000));
|
||||||
|
};
|
||||||
|
const novelRates = rewardPerKm(blank);
|
||||||
|
const knownRates = rewardPerKm(known);
|
||||||
|
expect(Math.min(...novelRates)).toBeGreaterThan(Math.max(...knownRates));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never offers the base you are standing in as a target', () => {
|
||||||
|
const state = createQuests();
|
||||||
|
const intel = createIntel(world.roads);
|
||||||
|
for (const base of bases) {
|
||||||
|
for (const offer of offersAt(state, world.roads, graph, intel, base, 0, 11)) {
|
||||||
|
expect(offer.targetNode).not.toBe(base.nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mission lifecycle', () => {
|
||||||
|
const start = () => {
|
||||||
|
const state = createQuests();
|
||||||
|
const intel = createIntel(world.roads);
|
||||||
|
const offer = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 5)[0]!;
|
||||||
|
accept(state, offer, bases[0]!);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('runs travel → work → return → hand in', () => {
|
||||||
|
const state = start();
|
||||||
|
expect(state.active!.stage).toBe('travel');
|
||||||
|
|
||||||
|
expect(stepQuest(state, { dt: 1 / 60, distanceToTarget: 400, atBase: null, speed: 20 })).toBeNull();
|
||||||
|
expect(stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 20 })).toEqual({
|
||||||
|
kind: 'arrived',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Still rolling: no work gets done.
|
||||||
|
stepQuest(state, { dt: 1, distanceToTarget: 5, atBase: null, speed: 20 });
|
||||||
|
expect(state.active!.worked).toBe(0);
|
||||||
|
|
||||||
|
let worked = null;
|
||||||
|
for (let i = 0; i < WORK_SECONDS * 60 + 2 && !worked; i++) {
|
||||||
|
worked = stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 });
|
||||||
|
}
|
||||||
|
expect(worked).toEqual({ kind: 'worked', type: state.active!.type });
|
||||||
|
expect(state.active!.stage).toBe('return');
|
||||||
|
|
||||||
|
const done = stepQuest(state, {
|
||||||
|
dt: 1 / 60,
|
||||||
|
distanceToTarget: 900,
|
||||||
|
atBase: bases[2]!,
|
||||||
|
speed: 0,
|
||||||
|
});
|
||||||
|
expect(done?.kind).toBe('completed');
|
||||||
|
expect(state.active).toBeNull();
|
||||||
|
expect(state.completed).toBe(1);
|
||||||
|
expect(state.parts).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a hand-in at any base, so a blocked road home cannot strand you', () => {
|
||||||
|
const state = start();
|
||||||
|
stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 });
|
||||||
|
for (let i = 0; i < WORK_SECONDS * 60 + 2; i++) {
|
||||||
|
stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 });
|
||||||
|
}
|
||||||
|
const elsewhere = bases.find((b) => b.nodeId !== state.active!.originBase)!;
|
||||||
|
expect(
|
||||||
|
stepQuest(state, { dt: 1 / 60, distanceToTarget: 999, atBase: elsewhere, speed: 0 })?.kind,
|
||||||
|
).toBe('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abandons half-finished work if you drive away', () => {
|
||||||
|
const state = start();
|
||||||
|
stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 });
|
||||||
|
stepQuest(state, { dt: 1, distanceToTarget: 5, atBase: null, speed: 0 });
|
||||||
|
expect(state.active!.worked).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
stepQuest(state, { dt: 1 / 60, distanceToTarget: 200, atBase: null, speed: 25 });
|
||||||
|
expect(state.active!.stage).toBe('travel');
|
||||||
|
expect(state.active!.worked).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
184
src/sim/quests.ts
Normal file
184
src/sim/quests.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Missions and target selection. Pure — no engine imports.
|
||||||
|
*
|
||||||
|
* Phase 2's job is to turn heat into a decision at the point of choice: a known
|
||||||
|
* target whose route you can assess against a new one you cannot. The board
|
||||||
|
* therefore always offers both, and pays more for the unknown — otherwise the
|
||||||
|
* known option is strictly better and the choice is not a choice.
|
||||||
|
*/
|
||||||
|
import { makeRng, randRange, type Rng } from '../core/rng';
|
||||||
|
import type { Base } from './bases';
|
||||||
|
import { summariseRoute, type Intel, type RouteIntel } from './intel';
|
||||||
|
import { findRoute, type Graph, type Route } from './routing';
|
||||||
|
import type { RoadNetwork } from './roads';
|
||||||
|
|
||||||
|
export type MissionType = 'delivery' | 'recon';
|
||||||
|
|
||||||
|
export interface Offer {
|
||||||
|
id: number;
|
||||||
|
type: MissionType;
|
||||||
|
/** Node the player has to reach. */
|
||||||
|
targetNode: number;
|
||||||
|
/** True when the player has never been to this target. */
|
||||||
|
novel: boolean;
|
||||||
|
route: Route;
|
||||||
|
intel: RouteIntel;
|
||||||
|
/** Parts awarded on hand-in. */
|
||||||
|
reward: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveQuest extends Offer {
|
||||||
|
originBase: number;
|
||||||
|
stage: 'travel' | 'working' | 'return';
|
||||||
|
/** Seconds of work completed at the target. */
|
||||||
|
worked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestState {
|
||||||
|
active: ActiveQuest | null;
|
||||||
|
completed: number;
|
||||||
|
/** Unspent repair parts. */
|
||||||
|
parts: number;
|
||||||
|
nextId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createQuests = (): QuestState => ({
|
||||||
|
active: null,
|
||||||
|
completed: 0,
|
||||||
|
parts: 0,
|
||||||
|
nextId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Seconds the car must be stopped at the target to finish the job. */
|
||||||
|
export const WORK_SECONDS = 3;
|
||||||
|
/** How close counts as "there", for both targets and bases. */
|
||||||
|
export const ARRIVAL_RADIUS = 18;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Novel targets pay this much more. This multiplier *is* the phase's tuning
|
||||||
|
* dial: if the board's "new target" option is never taken, it is too low; if
|
||||||
|
* known targets are never taken, too high.
|
||||||
|
*/
|
||||||
|
const NOVELTY_BONUS = 1.8;
|
||||||
|
const PARTS_PER_KM = 0.1;
|
||||||
|
const RECON_BONUS = 1.15;
|
||||||
|
|
||||||
|
function rewardFor(type: MissionType, route: Route, novel: boolean): number {
|
||||||
|
const base = 0.08 + (route.length / 1000) * PARTS_PER_KM;
|
||||||
|
return base * (novel ? NOVELTY_BONUS : 1) * (type === 'recon' ? RECON_BONUS : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the board for a base: always at least one previously visited target
|
||||||
|
* and one the player has never been to, so the comparison the phase is testing
|
||||||
|
* is on screen every time.
|
||||||
|
*/
|
||||||
|
export function offersAt(
|
||||||
|
state: QuestState,
|
||||||
|
roads: RoadNetwork,
|
||||||
|
graph: Graph,
|
||||||
|
intel: Intel,
|
||||||
|
base: Base,
|
||||||
|
now: number,
|
||||||
|
seed: number,
|
||||||
|
): Offer[] {
|
||||||
|
const rng: Rng = makeRng(seed);
|
||||||
|
const candidates = roads.nodes.filter((n) => n.id !== base.nodeId);
|
||||||
|
const visited = candidates.filter((n) => intel.visitedNodes.has(n.id));
|
||||||
|
const fresh = candidates.filter((n) => !intel.visitedNodes.has(n.id));
|
||||||
|
|
||||||
|
const pick = (pool: typeof candidates): number | null =>
|
||||||
|
pool.length === 0 ? null : pool[Math.floor(rng() * pool.length)]!.id;
|
||||||
|
|
||||||
|
const chosen: Array<{ node: number; novel: boolean }> = [];
|
||||||
|
const known = pick(visited);
|
||||||
|
if (known !== null) chosen.push({ node: known, novel: false });
|
||||||
|
for (const _ of [0, 1]) {
|
||||||
|
const novel = pick(fresh.filter((n) => !chosen.some((c) => c.node === n.id)));
|
||||||
|
if (novel !== null) chosen.push({ node: novel, novel: true });
|
||||||
|
}
|
||||||
|
// Early on there is nothing visited yet; top up so the board is never thin.
|
||||||
|
while (chosen.length < 3) {
|
||||||
|
const any = pick(candidates.filter((n) => !chosen.some((c) => c.node === n.id)));
|
||||||
|
if (any === null) break;
|
||||||
|
chosen.push({ node: any, novel: !intel.visitedNodes.has(any) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const offers: Offer[] = [];
|
||||||
|
for (const { node, novel } of chosen) {
|
||||||
|
const route = findRoute(graph, base.nodeId, node);
|
||||||
|
if (!route) continue;
|
||||||
|
const type: MissionType = rng() < 0.5 ? 'recon' : 'delivery';
|
||||||
|
offers.push({
|
||||||
|
id: state.nextId++,
|
||||||
|
type,
|
||||||
|
targetNode: node,
|
||||||
|
novel,
|
||||||
|
route,
|
||||||
|
intel: summariseRoute(intel, route.segments, now),
|
||||||
|
reward: rewardFor(type, route, novel) * randRange(rng, 0.9, 1.1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return offers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accept(state: QuestState, offer: Offer, base: Base): void {
|
||||||
|
state.active = { ...offer, originBase: base.nodeId, stage: 'travel', worked: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestStep {
|
||||||
|
dt: number;
|
||||||
|
/** Distance from the car to the active target, metres. */
|
||||||
|
distanceToTarget: number;
|
||||||
|
/** Base the car is currently sitting at, or null. */
|
||||||
|
atBase: Base | null;
|
||||||
|
/** Metres per second, so "stopped" can be required for work. */
|
||||||
|
speed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuestEvent =
|
||||||
|
| { kind: 'arrived' }
|
||||||
|
| { kind: 'worked'; type: MissionType }
|
||||||
|
| { kind: 'completed'; parts: number };
|
||||||
|
|
||||||
|
/** Advances the active mission. Returns whatever just happened, if anything. */
|
||||||
|
export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null {
|
||||||
|
const quest = state.active;
|
||||||
|
if (!quest) return null;
|
||||||
|
|
||||||
|
const stopped = step.speed < 2.5;
|
||||||
|
|
||||||
|
if (quest.stage === 'travel') {
|
||||||
|
if (step.distanceToTarget < ARRIVAL_RADIUS) {
|
||||||
|
quest.stage = 'working';
|
||||||
|
return { kind: 'arrived' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quest.stage === 'working') {
|
||||||
|
// Wandering off resets the job — you have to actually stop and do it.
|
||||||
|
if (step.distanceToTarget > ARRIVAL_RADIUS * 1.5) {
|
||||||
|
quest.stage = 'travel';
|
||||||
|
quest.worked = 0;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!stopped) return null;
|
||||||
|
quest.worked += step.dt;
|
||||||
|
if (quest.worked >= WORK_SECONDS) {
|
||||||
|
quest.stage = 'return';
|
||||||
|
return { kind: 'worked', type: quest.type };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand in at *any* base, not the one that issued it. The brief is explicit
|
||||||
|
// that a blocked road home must never strand the player.
|
||||||
|
if (step.atBase && stopped) {
|
||||||
|
state.active = null;
|
||||||
|
state.completed++;
|
||||||
|
state.parts += quest.reward;
|
||||||
|
return { kind: 'completed', parts: quest.reward };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
95
src/sim/routing.ts
Normal file
95
src/sim/routing.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Shortest paths over the road graph. Pure, no engine imports.
|
||||||
|
*
|
||||||
|
* This exists so the quest board can say something concrete about a route
|
||||||
|
* *before* the player commits to it — which is the whole point of Phase 2. A
|
||||||
|
* target is not a decision until you can weigh what getting there costs.
|
||||||
|
*/
|
||||||
|
import type { RoadNetwork } from './roads';
|
||||||
|
|
||||||
|
export interface Route {
|
||||||
|
/** Node ids from start to goal, inclusive. */
|
||||||
|
nodes: number[];
|
||||||
|
/** Segment ids traversed, in order. */
|
||||||
|
segments: number[];
|
||||||
|
/** Total length in metres. */
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Link {
|
||||||
|
to: number;
|
||||||
|
segment: number;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Graph = Map<number, Link[]>;
|
||||||
|
|
||||||
|
export function buildGraph(roads: RoadNetwork): Graph {
|
||||||
|
const graph: Graph = new Map(roads.nodes.map((n) => [n.id, []]));
|
||||||
|
for (const s of roads.segments) {
|
||||||
|
graph.get(s.a)!.push({ to: s.b, segment: s.id, length: s.length });
|
||||||
|
graph.get(s.b)!.push({ to: s.a, segment: s.id, length: s.length });
|
||||||
|
}
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dijkstra with a plain linear scan for the frontier. The graph is ~50 nodes;
|
||||||
|
* a heap would be more code than it is worth and this never runs per-frame.
|
||||||
|
*
|
||||||
|
* `cost` lets a caller weight segments by something other than distance — a
|
||||||
|
* "safest route" preview would pass remembered heat here.
|
||||||
|
*/
|
||||||
|
export function findRoute(
|
||||||
|
graph: Graph,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
cost: (segmentId: number, length: number) => number = (_, length) => length,
|
||||||
|
): Route | null {
|
||||||
|
const distance = new Map<number, number>([[from, 0]]);
|
||||||
|
const previous = new Map<number, { node: number; segment: number }>();
|
||||||
|
const settled = new Set<number>();
|
||||||
|
|
||||||
|
while (settled.size < graph.size) {
|
||||||
|
let current: number | null = null;
|
||||||
|
let best = Infinity;
|
||||||
|
for (const [node, d] of distance) {
|
||||||
|
if (!settled.has(node) && d < best) {
|
||||||
|
best = d;
|
||||||
|
current = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current === null) break;
|
||||||
|
if (current === to) break;
|
||||||
|
settled.add(current);
|
||||||
|
|
||||||
|
for (const link of graph.get(current) ?? []) {
|
||||||
|
if (settled.has(link.to)) continue;
|
||||||
|
const next = best + cost(link.segment, link.length);
|
||||||
|
if (next < (distance.get(link.to) ?? Infinity)) {
|
||||||
|
distance.set(link.to, next);
|
||||||
|
previous.set(link.to, { node: current, segment: link.segment });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!distance.has(to)) return null;
|
||||||
|
|
||||||
|
const nodes = [to];
|
||||||
|
const segments: number[] = [];
|
||||||
|
let length = 0;
|
||||||
|
let cursor = to;
|
||||||
|
while (cursor !== from) {
|
||||||
|
const step = previous.get(cursor);
|
||||||
|
if (!step) return null;
|
||||||
|
// Accumulate true metres here, not the search cost — a caller weighting by
|
||||||
|
// heat still wants the distance reported honestly.
|
||||||
|
const link = graph.get(step.node)!.find((l) => l.segment === step.segment && l.to === cursor);
|
||||||
|
length += link?.length ?? 0;
|
||||||
|
segments.unshift(step.segment);
|
||||||
|
nodes.unshift(step.node);
|
||||||
|
cursor = step.node;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes, segments, length };
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { generateWorld } from './world';
|
import { generateWorld } from './world';
|
||||||
import { applyWear, deriveHandling, freshCondition } from './car';
|
import { applyWear, deriveHandling, freshCondition, repair, SUBSYSTEMS, type CarCondition } from './car';
|
||||||
|
|
||||||
describe('world generation', () => {
|
describe('world generation', () => {
|
||||||
it('is reproducible from a seed', () => {
|
it('is reproducible from a seed', () => {
|
||||||
@ -20,12 +20,12 @@ describe('world generation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('car condition', () => {
|
describe('car condition', () => {
|
||||||
it('never recovers', () => {
|
it('never recovers on its own', () => {
|
||||||
let c = freshCondition();
|
let c = freshCondition();
|
||||||
for (let i = 0; i < 600; i++) {
|
for (let i = 0; i < 600; i++) {
|
||||||
const next = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 1, impactForce: 0 });
|
const next = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 1, impactForce: 0 });
|
||||||
expect(next.engine).toBeLessThanOrEqual(c.engine);
|
expect(next.level.engine).toBeLessThanOrEqual(c.level.engine);
|
||||||
expect(next.tires).toBeLessThanOrEqual(c.tires);
|
expect(next.level.tires).toBeLessThanOrEqual(c.level.tires);
|
||||||
c = next;
|
c = next;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -35,15 +35,76 @@ describe('car condition', () => {
|
|||||||
for (let i = 0; i < 200; i++) {
|
for (let i = 0; i < 200; i++) {
|
||||||
c = applyWear(c, { dt: 1 / 60, distance: 5, throttle: 1, impactForce: 5e5 });
|
c = applyWear(c, { dt: 1 / 60, distance: 5, throttle: 1, impactForce: 5e5 });
|
||||||
}
|
}
|
||||||
expect(c.chassis).toBe(0);
|
expect(c.level.chassis).toBe(0);
|
||||||
expect(c.tires).toBe(0);
|
expect(c.level.tires).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes a worn car measurably worse to drive', () => {
|
it('makes a worn car measurably worse to drive', () => {
|
||||||
const fresh = deriveHandling(freshCondition());
|
const fresh = deriveHandling(freshCondition());
|
||||||
const worn = deriveHandling({ engine: 0.3, tires: 0.3, chassis: 0.3 });
|
const worn = deriveHandling({
|
||||||
|
level: { engine: 0.3, tires: 0.3, chassis: 0.3 },
|
||||||
|
ceiling: { engine: 1, tires: 1, chassis: 1 },
|
||||||
|
});
|
||||||
expect(worn.engineForce).toBeLessThan(fresh.engineForce);
|
expect(worn.engineForce).toBeLessThan(fresh.engineForce);
|
||||||
expect(worn.frictionSlip).toBeLessThan(fresh.frictionSlip);
|
expect(worn.frictionSlip).toBeLessThan(fresh.frictionSlip);
|
||||||
expect(worn.steeringPull).toBeGreaterThan(fresh.steeringPull);
|
expect(worn.steeringPull).toBeGreaterThan(fresh.steeringPull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('repair', () => {
|
||||||
|
const damaged = () => {
|
||||||
|
let c = freshCondition();
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2e5 });
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('lowers the ceiling permanently, so a car never comes back whole', () => {
|
||||||
|
const c = damaged();
|
||||||
|
expect(c.ceiling.chassis).toBeLessThan(1);
|
||||||
|
// No amount of parts can undo it.
|
||||||
|
const restored = repair(c, 99).condition;
|
||||||
|
expect(restored.level.chassis).toBeLessThan(1);
|
||||||
|
expect(restored.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lifts a subsystem above its ceiling', () => {
|
||||||
|
const c = damaged();
|
||||||
|
const restored = repair(c, 99).condition;
|
||||||
|
for (const part of SUBSYSTEMS) {
|
||||||
|
expect(restored.level[part]).toBeLessThanOrEqual(restored.ceiling[part] + 1e-9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands back parts it could not use', () => {
|
||||||
|
const { unused } = repair(freshCondition(), 5);
|
||||||
|
expect(unused).toBeCloseTo(5, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spends a scarce part on the worst subsystem', () => {
|
||||||
|
const c: CarCondition = {
|
||||||
|
level: { engine: 0.9, tires: 0.2, chassis: 0.8 },
|
||||||
|
ceiling: { engine: 1, tires: 1, chassis: 1 },
|
||||||
|
};
|
||||||
|
const restored = repair(c, 0.1).condition;
|
||||||
|
expect(restored.level.tires).toBeCloseTo(0.3, 6);
|
||||||
|
expect(restored.level.engine).toBe(0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ratchets down over repeated damage-and-repair cycles', () => {
|
||||||
|
let c = freshCondition();
|
||||||
|
let previousCeiling = 1;
|
||||||
|
for (let cycle = 0; cycle < 5; cycle++) {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2e4 });
|
||||||
|
}
|
||||||
|
c = repair(c, 99).condition;
|
||||||
|
// Fully repaired every cycle, and still worse off every cycle.
|
||||||
|
expect(c.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
|
||||||
|
expect(c.ceiling.chassis).toBeLessThan(previousCeiling);
|
||||||
|
previousCeiling = c.ceiling.chassis;
|
||||||
|
}
|
||||||
|
expect(previousCeiling).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
87
src/ui/board.ts
Normal file
87
src/ui/board.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import type { Base } from '../sim/bases';
|
||||||
|
import type { HeatLevel } from '../sim/heat';
|
||||||
|
import type { Offer } from '../sim/quests';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The quest board. Deliberately words, not numbers: the player is reading a
|
||||||
|
* briefing, not a stat sheet. "Barricaded, four minutes ago" is a decision;
|
||||||
|
* "heat 0.62" is a spreadsheet.
|
||||||
|
*/
|
||||||
|
const LEVEL_WORDS: Record<HeatLevel, string> = {
|
||||||
|
clear: 'quiet',
|
||||||
|
patrol: 'patrolled',
|
||||||
|
barricade: 'barricaded',
|
||||||
|
turret: 'fortified',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ago(seconds: number): string {
|
||||||
|
if (seconds < 90) return 'just now';
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
return `${minutes} min ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(offer: Offer): string {
|
||||||
|
const { worst, unknownCount, stalest } = offer.intel;
|
||||||
|
const total = offer.route.segments.length;
|
||||||
|
|
||||||
|
if (unknownCount === total) return 'no one has been down that way';
|
||||||
|
if (unknownCount > 0) {
|
||||||
|
return `${LEVEL_WORDS[worst]} as far as we know · ${unknownCount} of ${total} roads unscouted`;
|
||||||
|
}
|
||||||
|
return `${LEVEL_WORDS[worst]}${stalest === null ? '' : ` · last word ${ago(stalest)}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBoard() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.id = 'board';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
#board {
|
||||||
|
position: fixed; left: 50%; bottom: 36px; transform: translateX(-50%);
|
||||||
|
width: min(680px, calc(100vw - 32px));
|
||||||
|
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
color: #dbe2e8; background: rgba(12,15,19,.92);
|
||||||
|
border: 1px solid #2f3944; border-radius: 6px; padding: 14px 16px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#board.open { display: block; }
|
||||||
|
#board h2 { font-size: 13px; font-weight: 600; margin: 0 0 10px; color: #8fa3b5; }
|
||||||
|
#board ol { list-style: none; margin: 0; padding: 0; }
|
||||||
|
#board li { padding: 7px 0; border-top: 1px solid #202832; }
|
||||||
|
#board .key { color: #4ea3ff; }
|
||||||
|
#board .novel { color: #e2b04a; }
|
||||||
|
#board .meta { color: #7d8b98; }
|
||||||
|
#board footer { margin-top: 10px; color: #6d7883; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
return {
|
||||||
|
show(base: Base, offers: Offer[]) {
|
||||||
|
el.classList.add('open');
|
||||||
|
el.innerHTML = `
|
||||||
|
<h2>${base.name} — quest board</h2>
|
||||||
|
<ol>${offers
|
||||||
|
.map(
|
||||||
|
(offer, i) => `
|
||||||
|
<li>
|
||||||
|
<span class="key">[${i + 1}]</span>
|
||||||
|
${offer.type === 'recon' ? 'Survey' : 'Supply run'} —
|
||||||
|
${offer.novel ? '<span class="novel">new target</span>' : 'known target'}
|
||||||
|
· ${(offer.route.length / 1000).toFixed(1)} km
|
||||||
|
· ${offer.reward.toFixed(2)} parts
|
||||||
|
<div class="meta">${describe(offer)}</div>
|
||||||
|
</li>`,
|
||||||
|
)
|
||||||
|
.join('')}</ol>
|
||||||
|
<footer>press 1–${offers.length} to accept</footer>`;
|
||||||
|
},
|
||||||
|
hide() {
|
||||||
|
el.classList.remove('open');
|
||||||
|
},
|
||||||
|
get isOpen() {
|
||||||
|
return el.classList.contains('open');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,20 +1,52 @@
|
|||||||
import type { CarCondition } from '../sim/car';
|
import { SUBSYSTEMS, type CarCondition } from '../sim/car';
|
||||||
import type { HeatLevel } from '../sim/heat';
|
import type { HeatLevel } from '../sim/heat';
|
||||||
|
import type { MissionType } from '../sim/quests';
|
||||||
|
import { WORK_SECONDS } from '../sim/quests';
|
||||||
|
|
||||||
const BAR_WIDTH = 12;
|
const BAR_WIDTH = 12;
|
||||||
|
|
||||||
function bar(value: number): string {
|
/**
|
||||||
|
* Draws a bar with a notch for the ceiling: the fillable part is what repairs
|
||||||
|
* can still reach, and the gap past it is gone for good.
|
||||||
|
*/
|
||||||
|
function bar(value: number, ceiling = 1): string {
|
||||||
const filled = Math.round(value * BAR_WIDTH);
|
const filled = Math.round(value * BAR_WIDTH);
|
||||||
return '█'.repeat(filled) + '·'.repeat(BAR_WIDTH - filled);
|
const reachable = Math.round(ceiling * BAR_WIDTH);
|
||||||
|
return (
|
||||||
|
'█'.repeat(filled) + '·'.repeat(Math.max(0, reachable - filled)) + '×'.repeat(BAR_WIDTH - reachable)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeatReadout {
|
export interface HudModel {
|
||||||
|
heat: {
|
||||||
segmentId: number | null;
|
segmentId: number | null;
|
||||||
/** False while on the verge — still counts as using the road. */
|
/** False while on the verge — still counts as using the road. */
|
||||||
onTarmac: boolean;
|
onTarmac: boolean;
|
||||||
value: number;
|
value: number;
|
||||||
level: HeatLevel | null;
|
level: HeatLevel | null;
|
||||||
hottest: number;
|
};
|
||||||
|
quest: {
|
||||||
|
type: MissionType;
|
||||||
|
stage: 'travel' | 'working' | 'return';
|
||||||
|
novel: boolean;
|
||||||
|
worked: number;
|
||||||
|
distance: number;
|
||||||
|
} | null;
|
||||||
|
completed: number;
|
||||||
|
parts: number;
|
||||||
|
notice: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function questLines(quest: NonNullable<HudModel['quest']>): string[] {
|
||||||
|
const label = quest.type === 'recon' ? 'Survey' : 'Supply run';
|
||||||
|
const kind = quest.novel ? 'new target' : 'known target';
|
||||||
|
if (quest.stage === 'return') {
|
||||||
|
return [`${label} done — return to any base`];
|
||||||
|
}
|
||||||
|
if (quest.stage === 'working') {
|
||||||
|
return [`${label} — working ${quest.worked.toFixed(1)}/${WORK_SECONDS}s (hold still)`];
|
||||||
|
}
|
||||||
|
return [`${label} (${kind}) — ${(quest.distance / 1000).toFixed(2)} km to target`];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHud(seed: number) {
|
export function createHud(seed: number) {
|
||||||
@ -22,30 +54,37 @@ export function createHud(seed: number) {
|
|||||||
let last = 0;
|
let last = 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update(speedMs: number, condition: CarCondition, now: number, heat: HeatReadout) {
|
update(speedMs: number, condition: CarCondition, now: number, model: HudModel) {
|
||||||
// The HUD is debug scaffolding, not the real interface — 10 Hz is plenty.
|
// The HUD is debug scaffolding, not the real interface — 10 Hz is plenty.
|
||||||
if (now - last < 0.1) return;
|
if (now - last < 0.1) return;
|
||||||
last = now;
|
last = now;
|
||||||
el.textContent = [
|
|
||||||
|
const lines = [
|
||||||
`${Math.abs(speedMs * 3.6).toFixed(0).padStart(3)} km/h`,
|
`${Math.abs(speedMs * 3.6).toFixed(0).padStart(3)} km/h`,
|
||||||
'',
|
'',
|
||||||
`engine ${bar(condition.engine)} ${(condition.engine * 100).toFixed(0)}%`,
|
...SUBSYSTEMS.map(
|
||||||
`tires ${bar(condition.tires)} ${(condition.tires * 100).toFixed(0)}%`,
|
(part) =>
|
||||||
`chassis ${bar(condition.chassis)} ${(condition.chassis * 100).toFixed(0)}%`,
|
`${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` +
|
||||||
|
`${(condition.level[part] * 100).toFixed(0)}%`,
|
||||||
|
),
|
||||||
|
`parts ${model.parts.toFixed(2)} runs ${model.completed}`,
|
||||||
|
'',
|
||||||
|
...(model.quest ? questLines(model.quest) : ['no mission — find a base']),
|
||||||
'',
|
'',
|
||||||
// Debug only. The real game signals heat through the road itself —
|
// Debug only. The real game signals heat through the road itself —
|
||||||
// see the design brief: no numeric meter ships.
|
// see the design brief: no numeric meter ships.
|
||||||
`[debug] road ${
|
`[debug] road ${
|
||||||
heat.segmentId === null
|
model.heat.segmentId === null
|
||||||
? 'off-route'
|
? 'off-route'
|
||||||
: `#${heat.segmentId} ${heat.onTarmac ? '(on road)' : '(alongside)'}`
|
: `#${model.heat.segmentId} ${model.heat.onTarmac ? '(on road)' : '(alongside)'}`
|
||||||
}`,
|
}`,
|
||||||
`[debug] heat ${bar(heat.value)} ${heat.level ?? '—'}`,
|
`[debug] heat ${bar(model.heat.value)} ${model.heat.level ?? '—'}`,
|
||||||
`[debug] hottest ${bar(heat.hottest)}`,
|
|
||||||
'',
|
'',
|
||||||
`seed ${seed}`,
|
`seed ${seed}`,
|
||||||
'WASD drive · space handbrake · R respawn',
|
'WASD drive · space handbrake · R respawn',
|
||||||
].join('\n');
|
];
|
||||||
|
if (model.notice) lines.push('', `» ${model.notice}`);
|
||||||
|
el.textContent = lines.join('\n');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user