haxtory/src/main.js
type-two 2d46960211 Trade Wars, and a branch you can see both sides of
TRADE WARS 2002 is real now. Ten sectors, seven ports, twenty-four turns.
The port classes are the actual ones — three letters for fuel ore,
organics and equipment, B where the port buys from you and S where it
sells to you — and the whole economy is finding two adjacent ports that
disagree with each other about what something is worth. The Ferrengi are
in sector nine and they want your cargo, and you can fight, run or pay.

SPECTRE is a real split screen. Two viewports scissored into the same
render target, two cameras, one post pass over both, and a hard seam down
the middle. The speculative timeline lives on render layer 1, which only
the second camera can see — so the right-hand pane genuinely contains
things the left-hand pane never will, which is the entire point of the
section. Steering is mirrored. The token you carry out of the branch that
gets discarded is only reachable in the pane that stops existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:43:50 +10:00

402 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// main.js — the router. 64 hops, one TTL, no way back up except through.
import * as THREE from '../vendor/three.module.js';
import * as A from './audio.js';
import * as In from './input.js';
import * as HUD from './hud.js';
import { Link } from './link.js';
import { World } from './kit.js';
import { lookup as phileLookup, TOTAL as PHILE_TOTAL } from './philes.js';
import coldboot from './levels/coldboot.js';
import stack from './levels/stack.js';
import timewait from './levels/timewait.js';
import war from './levels/war.js';
import smash from './levels/smash.js';
import worm from './levels/worm.js';
import cuckoo from './levels/cuckoo.js';
import board from './levels/board.js';
import handshake from './levels/handshake.js';
import copper from './levels/copper.js';
import bluebox from './levels/bluebox.js';
import climb from './levels/climb.js';
import root from './levels/root.js';
export const ORDER = [
coldboot, stack, timewait, war, smash, worm,
cuckoo, board, handshake, copper, bluebox, climb, root,
];
const BY_ID = Object.fromEntries(ORDER.map(l => [l.id, l]));
const canvas = document.getElementById('gl');
const termCanvas = document.getElementById('term');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' });
renderer.setClearColor(0x000000, 1);
renderer.autoClear = true;
const link = new Link(renderer);
In.attach(canvas);
HUD.boot();
// ───────────────────────────────────────────────────────────── game state
export const game = {
ttl: 64,
hop: 1,
level: null,
flags: Object.create(null), // what you learned, what you carry, who you met
philes: new Set(), // the collectibles. the writing lives here.
cargo: [], // BBS files. affects MTU.
mtu: 1500,
deaths: 0,
reached: new Set(['coldboot']),
paused: false,
time: 0,
};
// dev handle. HS.run(n) drives n frames by hand when rAF is asleep.
window.HS = {
game, link, A, In, HUD, THREE,
goto: (id) => goto(id),
run(n = 60, dt = 1 / 60) { for (let i = 0; i < n; i++) step(dt); },
};
function refreshHud() {
HUD.status({
ttl: game.ttl, hop: game.hop, hops: 64, link: link.label,
extra: game.cargo.length ? `MTU ${game.mtu} · ${game.cargo.length} file${game.cargo.length > 1 ? 's' : ''}` : '',
});
}
// How many pieces you arrive in. Two files is free; after that the network starts
// charging you for the privilege of being interesting.
export function fragments() {
game.mtu = Math.max(400, 1500 - game.cargo.length * 180);
if (game.cargo.length <= 2) return 1;
return 1 + Math.ceil((game.cargo.length - 2) / 3);
}
let fragNote = null;
// TTL only ever goes down. There is no item for this.
export function spendTTL(n = 1, why = '') {
game.ttl -= n;
refreshHud();
if (why) HUD.say(`<i>${why}</i> &nbsp;${n} TTL`, 1800);
if (game.ttl <= 0) expire();
return game.ttl;
}
let expiring = false;
async function expire() {
if (expiring) return;
expiring = true;
game.deaths++;
A.noCarrier();
link.glitch = 0.9;
await HUD.flash('NO CARRIER', 'nc', 2400);
link.glitch = 0;
// ICMP Time Exceeded is sent to where you came from. It always is.
HUD.say('icmp time exceeded — in transit', 2600);
game.ttl = Math.max(12, 65 - game.hop);
expiring = false;
await goto(game.level.id, { restart: true });
}
export { expire };
// ────────────────────────────────────────────────────────────── the loop
const ctx = {
THREE, A, In, HUD, link, game, renderer,
scene: null, camera: null, world: null, term: null, termCanvas,
spendTTL,
refreshHud: () => refreshHud(),
setBaud(i) { link.setBaud(i); refreshHud(); return link.spec; },
goto: (id, o) => goto(id, o),
next: () => {
const i = ORDER.indexOf(game.level);
return goto(ORDER[Math.min(ORDER.length - 1, i + 1)].id);
},
say: HUD.say, speak: HUD.speak, hint: HUD.hint, card: HUD.card, sleep: HUD.sleep,
hush: HUD.hush, flash: HUD.flash, expire: () => expire(),
phile(id, title, body) {
if (game.philes.has(id)) return false;
game.philes.add(id);
const p = phileLookup(id, title, body);
HUD.say(`<b>phile</b> ${p.title} <i>[${game.philes.size}/${PHILE_TOTAL}]</i> &nbsp; <k>P</k> to read`, 3000);
A.blip(1800, 0.05, 0.05); setTimeout(() => A.blip(2400, 0.06, 0.05), 70);
phileLog.push(p);
return true;
},
};
const phileLog = [];
window.HS.philes = phileLog;
window.HS.ctx = ctx;
function disposeScene(s) {
if (!s) return;
s.traverse(o => {
if (o.geometry) o.geometry.dispose();
const mats = o.material ? (Array.isArray(o.material) ? o.material : [o.material]) : [];
for (const m of mats) {
for (const k of ['map', 'alphaMap', 'emissiveMap']) if (m[k] && m[k].dispose && !m[k].__keep) m[k].dispose();
m.dispose();
}
});
}
let switching = false;
export async function goto(id, { restart = false } = {}) {
if (switching) return;
switching = true;
const nextLevel = BY_ID[id];
if (!nextLevel) { console.warn('no level', id); switching = false; return; }
HUD.hint('');
HUD.hush();
await HUD.veil(1, 420);
A.stopPad();
if (game.level && game.level.unmount) { try { await game.level.unmount(ctx); } catch (e) { console.error(e); } }
In.releaseText(); In.clearRemap(); In.unlock();
ctx.splitCam = null;
termCanvas.style.display = 'none';
disposeScene(ctx.scene);
ctx.scene = new THREE.Scene();
ctx.camera = new THREE.PerspectiveCamera(72, link.aspect, 0.05, 4000);
ctx.world = new World();
game.level = nextLevel;
game.reached.add(id);
if (!restart) game.hop = nextLevel.hop;
game.ttl = Math.max(1, Math.min(game.ttl, 65 - game.hop));
// ── MTU. Carry too much and you do not fit down the next hop in one piece.
// Everything you took off the board is lore, and lore is paid for in distance.
if (!restart) {
const frags = fragments();
if (frags > 1) {
game.ttl -= (frags - 1);
fragNote = `<b>fragmented</b> ${game.mtu} byte MTU · ${frags} pieces · ${frags - 1} TTL`;
if (game.ttl <= 0) { expire(); switching = false; return; }
} else fragNote = null;
}
if (nextLevel.baud !== undefined && nextLevel.baud !== null) link.setBaud(nextLevel.baud);
link.noTint();
if (nextLevel.tint) link.tint(nextLevel.tint[0], nextLevel.tint[1]);
document.body.classList.toggle('ink-dark', nextLevel.ink === 'dark');
refreshHud();
HUD.showHud(nextLevel.hud !== false);
// A level builds its scene synchronously and only then awaits its intro card, so
// start lifting the veil as soon as mount is under way — otherwise the player
// stares at a black screen for the length of the card that is playing behind it.
const mounting = (async () => { try { await nextLevel.mount(ctx); } catch (e) { console.error('mount failed:', id, e); } })();
HUD.veil(0, 520);
await mounting;
refreshHud();
saveGame();
if (fragNote) { HUD.say(fragNote, 3600); fragNote = null; }
switching = false;
}
let last = performance.now();
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
step(dt);
}
// One tick, separated from rAF so it can be driven by hand — rAF is throttled to
// zero in a hidden tab, which makes the game look broken when it is only asleep.
export function step(dt) {
game.time += dt;
if (In.hit('Escape')) { In.unlock(); if (readerOpen) toggleReader(); }
if (In.hit('KeyM') && !In.hasTextCapture()) toggleMap();
if (In.hit('KeyP') && !In.hasTextCapture()) toggleReader();
if (In.hit('Backquote')) toggleDev();
if (!game.paused && game.level && game.level.update) {
try { game.level.update(dt, game.time, ctx); } catch (e) { console.error(e); game.level.update = null; }
}
if (ctx.term && termCanvas.style.display !== 'none') ctx.term.draw(game.time);
if (ctx.scene && ctx.camera) {
// a level can ask for two viewports by setting ctx.splitCam
if (ctx.splitCam) link.renderSplit(ctx.scene, ctx.camera, ctx.splitCam, game.time);
else link.render(ctx.scene, ctx.camera, game.time);
}
In.endFrame();
}
addEventListener('resize', () => {
link.resize();
if (ctx.camera && ctx.camera.isPerspectiveCamera) { ctx.camera.aspect = link.aspect; ctx.camera.updateProjectionMatrix(); }
if (ctx.term) ctx.term.resize();
});
// ─────────────────────────────────────────────────── traceroute level select
const mapEl = document.getElementById('map');
let mapOpen = false;
function toggleMap() {
mapOpen = !mapOpen;
game.paused = mapOpen;
mapEl.style.display = mapOpen ? 'flex' : 'none';
if (mapOpen) {
In.unlock();
const rows = ORDER.map((l, i) => {
const seen = game.reached.has(l.id);
const here = game.level === l;
const hopn = String(l.hop).padStart(2, '0');
const name = seen ? l.title : '* * *';
const via = seen ? l.via : '';
return `<div class="row ${seen ? 'seen' : ''} ${here ? 'here' : ''}" data-id="${l.id}">
<span class="n">${hopn}</span>
<span class="nm">${name}</span>
<span class="via">${via || ''}</span>
<span class="ms">${seen ? (l.ms || (12 + i * 17) + ' ms') : ''}</span></div>`;
}).join('');
const frags = fragments();
const cargo = game.cargo.length
? `<div class="cargo">carrying ${game.cargo.length} · MTU ${game.mtu} · ${frags > 1
? `<span class="bad">fragmenting into ${frags}, ${frags - 1} TTL every hop</span>`
: 'fits in one piece'}<br>` +
game.cargo.map((f, i) => `<span class="drop" data-c="${i}">${f} ✕</span>`).join(' ') + '</div>'
: '';
mapEl.innerHTML = `<div class="trace">
<div class="hdr">traceroute to root (0.0.0.0), 64 hops max, ${game.mtu} byte packets</div>
${rows}
${cargo}
<div class="ftr">TTL ${game.ttl} remaining · ${game.philes.size} philes · ${game.deaths} expiries
<br><span class="dim">click a reached hop to return · P for philes · M to close</span></div></div>`;
mapEl.querySelectorAll('.row.seen').forEach(r => r.addEventListener('click', () => {
const id = r.dataset.id;
toggleMap();
goto(id);
}));
// you can put something down. that is the whole trade.
mapEl.querySelectorAll('.drop').forEach(el => el.addEventListener('click', () => {
game.cargo.splice(+el.dataset.c, 1);
fragments(); refreshHud(); saveGame();
toggleMap(); toggleMap();
}));
}
}
// ────────────────────────────────────────────────────── the phile reader
// You spend the game collecting text files. There has to be somewhere to read them.
const readerEl = document.getElementById('reader');
let readerOpen = false, readerAt = 0;
function toggleReader() {
readerOpen = !readerOpen;
game.paused = readerOpen;
readerEl.style.display = readerOpen ? 'flex' : 'none';
if (readerOpen) { In.unlock(); drawReader(); }
}
function drawReader() {
if (!phileLog.length) {
readerEl.innerHTML = `<div class="rd"><div class="hdr">philes 0/${PHILE_TOTAL}</div>
<div class="empty">you have not picked anything up yet.<br><br>
they are lying around. nothing tells you where.</div>
<div class="ftr"><span class="dim">P to close</span></div></div>`;
return;
}
readerAt = Math.max(0, Math.min(phileLog.length - 1, readerAt));
const p = phileLog[readerAt];
const list = phileLog.map((q, i) =>
`<div class="it ${i === readerAt ? 'on' : ''}" data-i="${i}">${q.title}</div>`).join('');
readerEl.innerHTML = `<div class="rd">
<div class="hdr">philes ${phileLog.length}/${PHILE_TOTAL}</div>
<div class="cols">
<div class="list">${list}</div>
<div class="body"><h2>${p.title}</h2>${p.from ? `<div class="from">${p.from}</div>` : ''}<pre>${p.body || '(no text)'}</pre></div>
</div>
<div class="ftr"><span class="dim">click a title · P to close</span></div></div>`;
readerEl.querySelectorAll('.it').forEach(el =>
el.addEventListener('click', () => { readerAt = +el.dataset.i; drawReader(); }));
}
// ─────────────────────────────────────────────── ESTABLISHED — the save
// You do not save. You connect. Sitting still long enough to complete a handshake
// with the world is your checkpoint — which in practice means every hop, because
// arriving somewhere is the only moment the state is worth anything.
const SAVE_KEY = 'handshake.save.v1';
function saveGame() {
try {
localStorage.setItem(SAVE_KEY, JSON.stringify({
level: game.level ? game.level.id : 'coldboot',
ttl: game.ttl, hop: game.hop, baud: link.index,
philes: [...game.philes], phileLog,
cargo: game.cargo, mtu: game.mtu, flags: game.flags, deaths: game.deaths,
}));
} catch (e) { /* private browsing, a full disk, somebody's kiosk. not worth dying over. */ }
}
export function loadSave() {
try { return JSON.parse(localStorage.getItem(SAVE_KEY) || 'null'); } catch (e) { return null; }
}
function applySave(s) {
game.ttl = s.ttl; game.hop = s.hop;
game.philes = new Set(s.philes || []);
phileLog.length = 0; (s.phileLog || []).forEach(p => phileLog.push(p));
game.cargo = s.cargo || []; game.mtu = s.mtu ?? 1500;
game.flags = s.flags || Object.create(null);
game.deaths = s.deaths || 0;
link.setBaud(s.baud ?? 6);
ORDER.forEach(l => { if (l.hop <= s.hop) game.reached.add(l.id); });
}
export function wipeSave() { try { localStorage.removeItem(SAVE_KEY); } catch (e) {} }
// ─────────────────────────────────────────────────────────────── dev
const devEl = document.getElementById('dev');
let devOpen = false;
function toggleDev() {
devOpen = !devOpen;
devEl.style.display = devOpen ? 'block' : 'none';
if (devOpen) {
devEl.innerHTML = ORDER.map((l, i) =>
`<button data-id="${l.id}">${String(i + 1).padStart(2, '0')} ${l.title}</button>`).join('') +
`<div class="baud">baud: ${[0,1,2,3,4,5,6].map(b => `<button data-baud="${b}">${b}</button>`).join('')}</div>`;
devEl.querySelectorAll('[data-id]').forEach(b => b.onclick = () => { toggleDev(); goto(b.dataset.id); });
devEl.querySelectorAll('[data-baud]').forEach(b => b.onclick = () => { link.setBaud(+b.dataset.baud); refreshHud(); });
}
}
// ─────────────────────────────────────────────────────────────── boot
const bootEl = document.getElementById('boot');
const save = loadSave();
if (save) {
const l = ORDER.find(x => x.id === save.level);
document.getElementById('resume').innerHTML =
`<b>R</b> — RESUME &nbsp; hop ${String(save.hop).padStart(2, '0')} · ${l ? l.title : save.level} · TTL ${save.ttl} · ${(save.philes || []).length} philes`;
document.getElementById('resume').style.display = 'block';
}
let started = false;
async function start(resume) {
if (started) return;
started = true;
bootEl.style.display = 'none';
A.boot(); A.resume();
requestAnimationFrame(frame);
if (resume && save) { applySave(save); await goto(save.level, { restart: true }); }
else { wipeSave(); await goto('coldboot'); }
}
bootEl.addEventListener('click', () => start(false));
addEventListener('keydown', function once(e) {
if (started) { removeEventListener('keydown', once); return; }
if (e.key === 'Tab') return;
removeEventListener('keydown', once);
start(save && e.key.toLowerCase() === 'r');
});
// carrier detect blinking on the boot screen, because of course
{
const cd = document.getElementById('cd');
setInterval(() => { if (cd) cd.style.opacity = cd.style.opacity === '0.15' ? '1' : '0.15'; }, 620);
}