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>
This commit is contained in:
type-two 2026-08-02 17:43:50 +10:00
parent 594fd584fa
commit 2d46960211
5 changed files with 223 additions and 11 deletions

View File

@ -151,6 +151,17 @@ i am telling you that because i want somebody to know it happened.`,
phile: ['thenumber', 'just the number'] },
];
// ── TRADE WARS 2002. Ten sectors, seven ports, and the Ferrengi in nine.
const TW_TURNS = 24;
const TW_MAP = {
1: [2, 3], 2: [1, 4, 5], 3: [1, 5, 6], 4: [2, 7], 5: [2, 3, 7, 8],
6: [3, 8], 7: [4, 5, 9], 8: [5, 6, 9], 9: [7, 8, 10], 10: [9],
};
// class string is fuel ore / organics / equipment — B: the port buys from you,
// S: the port sells to you. These are the real classes.
const TW_PORTS = { 2: 'BBS', 3: 'SBB', 5: 'SSB', 6: 'BSS', 7: 'SBS', 8: 'BBB', 9: 'SSS', 10: 'BBB' };
const TW_BASE = { ore: 18, org: 32, equ: 61 };
const FILES = [
{ name: 'PHRACK24.ZIP', size: '84,102', note: 'issue 24. the one with the E911 document in it.' },
{ name: 'TAP-42.TXT', size: '11,904', note: 'youth international party line, later TAP.' },
@ -165,7 +176,7 @@ export default {
async mount(ctx) {
S = { t: 0, busyTries: 0, cargo: 0, uploads: 0, nup: false, elite: false, bombed: false, done: false,
sysopSeen: 0, day: 1, ratioOff: false };
sysopSeen: 0, day: 1, ratioOff: false, twDone: false };
window.__S = S; // debug handle
ctx.scene.background = new THREE.Color(0x000000);
@ -531,11 +542,130 @@ export default {
term.clear();
term.writeln('|11 doors\n');
term.writeln('|08 1 |15LEGEND OF THE RED DRAGON|08 v3.29');
term.writeln('|08 2 |15TRADE WARS 2002|08 closed for maintenance since 1994');
term.writeln('|08 2 |15TRADE WARS 2002|08 v2.00b ' +
(S.twDone ? '|08(you have used your turns today)' : '|10' + TW_TURNS + ' turns available'));
term.writeln('');
const n = (await term.ask('|07door: |15')).trim();
if (n !== '1') return;
await this.lord(ctx, term);
if (n === '1') await this.lord(ctx, term);
else if (n === '2' && !S.twDone) await this.tradewars(ctx, term);
},
// ── TRADE WARS 2002.
// Ten sectors, seven ports, a fixed number of turns, and a species of merchant
// who will take your cargo off you in sector nine. The port classes are the real
// ones: three letters for fuel ore, organics and equipment, B if the port buys
// from you and S if it sells to you, and the whole economy is working out which
// pair of adjacent ports disagree with each other most.
async tradewars(ctx, term) {
const g = {
sector: 1, credits: 1000, turns: TW_TURNS, holds: 20,
cargo: { ore: 0, org: 0, equ: 0 }, fights: 0, best: 1000,
};
const NAME = { ore: 'fuel ore', org: 'organics', equ: 'equipment' };
const KEY = ['ore', 'org', 'equ'];
const price = (k, sells) => Math.round(TW_BASE[k] * (sells ? 1 : 0.78) * (0.85 + ((g.sector * 7 + k.length * 13) % 30) / 100));
term.clear();
await term.paint(`|12
TRADE WARS 2002 |08v2.00b (c) 1986 Chris Sherrick
|08
|07 you have |15${g.turns}|07 turns, |15${g.holds}|07 holds and |15${g.credits}|07 credits.
|07 buy low. sell high. do not go to sector nine with a full hold.
`, Math.max(1800, ctx.link.rate));
for (;;) {
if (g.turns <= 0) break;
const port = TW_PORTS[g.sector];
const held = g.cargo.ore + g.cargo.org + g.cargo.equ;
term.writeln('');
term.writeln(`|11 Sector |15${g.sector}|11 |08warps to |15${TW_MAP[g.sector].join(' ')}`);
term.writeln(`|08 credits |15${g.credits}|08 holds |15${held}/${g.holds}|08 turns |15${g.turns}`);
term.writeln(`|08 cargo: ore |15${g.cargo.ore}|08 org |15${g.cargo.org}|08 equ |15${g.cargo.equ}`);
if (port) term.writeln(`|10 Port class |15${port}|10 — ` +
KEY.map((k, i) => `${NAME[k]} ${port[i] === 'S' ? 'for sale ' + price(k, true) : 'wanted ' + price(k, false)}`).join(' · '));
else term.writeln('|08 no port in this sector.');
term.writeln('|08 [|151-10|08] warp [|15T|08] trade [|15Q|08] quit');
const cmd = (await term.ask('|07 command: |15')).trim().toUpperCase();
if (cmd === 'Q') break;
if (cmd === 'T') {
if (!port) { term.writeln('|12 there is no port here.'); continue; }
term.writeln('|08 which — [|15O|08]re [|15G|08] organics [|15E|08] equipment');
const wk = (await term.ask('|07 : |15')).trim().toUpperCase();
const k = wk === 'O' ? 'ore' : wk === 'G' ? 'org' : wk === 'E' ? 'equ' : null;
if (!k) continue;
const idx = KEY.indexOf(k);
const sells = port[idx] === 'S';
const p = price(k, sells);
const qty = parseInt(await term.ask(`|07 how many (${sells ? 'buying at ' + p : 'selling at ' + p}): |15`), 10) || 0;
if (qty <= 0) continue;
if (sells) {
const canHold = g.holds - held;
const n = Math.min(qty, canHold, Math.floor(g.credits / p));
if (n <= 0) { term.writeln('|12 no room, or no money.'); continue; }
g.cargo[k] += n; g.credits -= n * p;
await term.paint(`|10 bought ${n} ${NAME[k]} for ${n * p}.\n`, 3000);
} else {
const n = Math.min(qty, g.cargo[k]);
if (n <= 0) { term.writeln('|12 you have none.'); continue; }
g.cargo[k] -= n; g.credits += n * p;
await term.paint(`|10 sold ${n} ${NAME[k]} for ${n * p}.\n`, 3000);
}
g.best = Math.max(g.best, g.credits);
g.turns--;
A.blip(1200, 0.02, 0.04);
continue;
}
const dest = parseInt(cmd, 10);
if (!TW_MAP[g.sector].includes(dest)) { term.writeln('|12 no warp link to that sector.'); continue; }
g.turns--;
g.sector = dest;
A.blip(700 + dest * 40, 0.03, 0.045);
await term.paint(`|03 warping to sector ${dest}...\n`, 4000);
// ── the Ferrengi. They are in sector nine and they always were.
if (dest === 9 && (g.cargo.ore + g.cargo.org + g.cargo.equ) > 0 && !g.robbed) {
g.robbed = true;
await term.paint('|12\n *** FERRENGI BATTLESHIP DECLOAKING ***\n', 900);
await ctx.sleep(700);
A.burst(120, 90, 0.5, 0.2);
await term.paint('|07 "your cargo. all of it. now."\n', 2400);
term.writeln('|08 [|15F|08]ight [|15R|08]un [|15P|08]ay');
const a = (await term.ask('|07 : |15')).trim().toUpperCase()[0];
if (a === 'F') {
g.fights++;
if (Math.random() < 0.35) {
await term.paint('|10 you got a shot into their reactor. they run.\n', 2400);
g.credits += 500;
} else {
await term.paint('|12 they take everything and leave you the ship.\n', 2400);
g.cargo = { ore: 0, org: 0, equ: 0 };
}
} else if (a === 'P') {
const toll = Math.min(g.credits, 400);
g.credits -= toll;
await term.paint(`|03 you pay ${toll} and they let you through.\n`, 2400);
} else {
g.turns -= 2;
g.sector = 7;
await term.paint('|03 you burn two turns getting out of there.\n', 2400);
}
}
}
S.twDone = true;
const profit = g.credits - 1000;
term.writeln('');
await term.paint(`|11 out of turns.\n|07 you finished with |15${g.credits}|07 credits — ` +
(profit > 0 ? `|10up ${profit}` : profit < 0 ? `|12down ${-profit}` : '|08exactly even') + '|07.\n',
Math.max(1800, ctx.link.rate));
if (profit > 0) {
ctx.phile('tradewars');
ctx.say('there is a second whole game in the corner of this level.', 4600);
}
await term.ask('|08 [enter]|07');
},
async lord(ctx, term) {

View File

@ -103,6 +103,18 @@ export default {
S.specToken = new THREE.Mesh(new THREE.OctahedronGeometry(0.9, 0), glow(0xff8ad8, 1.6));
S.specGroup.add(S.specToken);
S.specGroup.visible = false;
// the second camera: the same world, from the branch that will be thrown away
S.specCam = new THREE.PerspectiveCamera(72, 1, 0.05, 4000);
S.specLight = new THREE.PointLight(0xff8ad8, 30, 80, 2); scene.add(S.specLight);
S.specLight.position.set(0, 0, -900);
const specTag = sprite('SPECULATIVE', { color: '#ff8ad8', size: 1.0 });
specTag.position.set(0, 7.5, -26); S.specGroup.add(specTag);
// Layer 1 is the timeline that does not happen. Only the speculative camera can
// see it — same scene, two views, and one of them contains things the other
// never will. That is the entire point of the section.
S.specGroup.traverse(o => o.layers.set(1));
S.specLight.layers.set(1);
S.specCam.layers.enable(1);
// ── ROWHAMMER
S.wall = new THREE.Mesh(new THREE.BoxGeometry(26, 22, 1.2),
@ -354,16 +366,24 @@ export default {
// ── the three modern toys
modern(dt, ctx) {
if (S.z > SPECTRE_AT && S.z < SPECTRE_AT + 120) {
if (S.z > SPECTRE_AT && S.z < SPECTRE_AT + 130) {
if (!S.spec) {
S.spec = { got: false, squashed: false };
S.specGroup.visible = true;
ctx.say('<b>branch</b> you are going to run down both sides of this. one of them is not going to have happened.', 5600);
// ── two viewports. left is the branch that happens. right is the one that
// does not, and you are in both of them, and only one of you is real.
ctx.splitCam = S.specCam;
ctx.say('<b>branch</b> you are going down both sides of this at once.<br>one of them is not going to have happened.', 5800);
ctx.hint('<k>A</k> <k>D</k>steer — <i>the right-hand you is mirrored</i>');
}
S.ghostMe.position.set(-S.x, 0, -8);
S.specToken.position.set(-4.5, 0, -26 + Math.sin(S.t * 2) * 3);
// the speculative you, mirrored, in a world tinted for the timeline that dies
S.ghostMe.position.set(-S.x, 0, -9);
S.specToken.position.set(-4.5, 0, -30 + Math.sin(S.t * 1.6) * 5);
S.specToken.rotation.y += dt * 3;
if (!S.spec.got && Math.abs(-S.x - S.specToken.position.x) < 2.4 && S.z > SPECTRE_AT + 40) {
S.specCam.position.set(-S.x, Math.sin(S.t * 0.7) * 0.6, 0);
S.specCam.rotation.z = -cam.rotation.z;
S.specLight.position.set(-S.x, 0, -14);
if (!S.spec.got && Math.abs(-S.x - S.specToken.position.x) < 2.6 && S.z > SPECTRE_AT + 45) {
S.spec.got = true;
S.specToken.visible = false;
A.burst(1760, 2640, 0.24, 0.18);
@ -372,6 +392,9 @@ export default {
} else if (S.spec && !S.spec.squashed) {
S.spec.squashed = true;
S.specGroup.visible = false;
ctx.splitCam = null;
ctx.link.unsplit(ctx.camera);
ctx.hint('<k>A</k> <k>D</k>steer &nbsp; <k>SPACE</k>seize &nbsp; <k>E</k>use');
A.thud(90, 0.4, 0.25);
if (S.spec.got) {
S.gateOpen = true;
@ -456,6 +479,8 @@ export default {
},
async unmount(ctx) {
ctx.splitCam = null;
ctx.link.unsplit(ctx.camera);
S.hiss?.stop();
S.gates?.forEach(g => g.seizing?.stop(0.05));
A.stopPad();

View File

@ -59,8 +59,8 @@ uniform float uTintMix;
uniform float uTime;
uniform float uFade; // 1 = normal, 0 = black
uniform float uGlitch; // packet corruption
uniform float uSplit; // 1 = two viewports, draw the seam
// 4x4 ordered Bayer. Dither is not a filter, it is a commitment.
// three renders into a linear target; nothing downstream of a raw ShaderMaterial
// will encode it for us, so we do it here and work in display space from then on.
vec3 toSRGB(vec3 c){
@ -68,6 +68,7 @@ vec3 toSRGB(vec3 c){
return mix(c * 12.92, 1.055 * pow(c, vec3(0.41666)) - 0.055, step(vec3(0.0031308), c));
}
// 4x4 ordered Bayer. Dither is not a filter, it is a commitment.
float bayer(vec2 p){
int x = int(mod(p.x,4.0)); int y = int(mod(p.y,4.0));
int i = x + y*4;
@ -118,6 +119,12 @@ void main(){
col = mix(col, col * uTint, uTintMix);
if(uSplit > 0.5){
float d = abs(uv.x - 0.5);
if(d < 0.0016) col = vec3(0.0);
else if(d < 0.004) col *= 0.35;
}
if(uScan > 0.01){
float line = sin(uv.y * uOut.y * 1.0) * 0.5 + 0.5;
col *= 1.0 - uScan * 0.28 * line;
@ -152,6 +159,7 @@ export class Link {
uTime: { value: 0 },
uFade: { value: 1 },
uGlitch: { value: 0 },
uSplit: { value: 0 },
};
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-1,-1,0, 3,-1,0, -1,3,0]), 3));
@ -221,4 +229,35 @@ export class Link {
r.setRenderTarget(null);
r.render(this.scene, this.cam);
}
// Two viewports into the same target, scissored, then one post pass over both.
// For the branch you run down twice: left is what happens, right is what does not.
renderSplit(scene, camL, camR, t) {
this.uni.uTime.value = t;
this.uni.uFade.value = this.fade;
this.uni.uGlitch.value = this.glitch;
const r = this.renderer, tgt = this.target;
const w = tgt.width, h = tgt.height, hw = Math.floor(w / 2);
const aspect = hw / h;
for (const c of [camL, camR]) {
if (c.isPerspectiveCamera && c.aspect !== aspect) { c.aspect = aspect; c.updateProjectionMatrix(); }
}
r.setRenderTarget(tgt);
r.setScissorTest(true);
r.setViewport(0, 0, hw, h); r.setScissor(0, 0, hw, h);
r.render(scene, camL);
r.setViewport(hw, 0, w - hw, h); r.setScissor(hw, 0, w - hw, h);
r.render(scene, camR);
r.setScissorTest(false);
r.setViewport(0, 0, w, h); r.setScissor(0, 0, w, h);
r.setRenderTarget(null);
this.uni.uSplit.value = 1;
r.render(this.scene, this.cam);
this.uni.uSplit.value = 0;
}
// put a camera back the way the rest of the game expects it
unsplit(camera) {
if (camera && camera.isPerspectiveCamera) { camera.aspect = this.aspect; camera.updateProjectionMatrix(); }
}
}

View File

@ -157,6 +157,7 @@ export async function goto(id, { restart = false } = {}) {
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);
@ -220,7 +221,11 @@ export function step(dt) {
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) link.render(ctx.scene, ctx.camera, 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();
}

View File

@ -461,6 +461,19 @@ nobody has built a better multiplayer economy since. they have all built
faster ones.`,
},
'tradewars': {
title: 'TRADE WARS 2002',
from: 'a door game, running on somebody\'s spare computer',
body: `ten sectors. seven ports. a fixed number of turns a day and then you have
to hang up and let somebody else have the line.
the entire economy is one idea: find two adjacent ports that disagree with
each other about what something is worth, and run between them until they
stop disagreeing.
people played this for years. on one phone line. taking turns.`,
},
'fido': {
title: 'you posted to fidonet',
from: 'zone 1, net 129, node 22',