From 2d469602112699517d1d5f2d8eebf045958c0a7f Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 2 Aug 2026 17:43:50 +1000 Subject: [PATCH] Trade Wars, and a branch you can see both sides of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/levels/board.js | 138 ++++++++++++++++++++++++++++++++++++++++++-- src/levels/climb.js | 35 +++++++++-- src/link.js | 41 ++++++++++++- src/main.js | 7 ++- src/philes.js | 13 +++++ 5 files changed, 223 insertions(+), 11 deletions(-) diff --git a/src/levels/board.js b/src/levels/board.js index 688055f..9ace14e 100644 --- a/src/levels/board.js +++ b/src/levels/board.js @@ -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) { diff --git a/src/levels/climb.js b/src/levels/climb.js index e14184d..de28bc4 100644 --- a/src/levels/climb.js +++ b/src/levels/climb.js @@ -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('branch 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('branch you are going down both sides of this at once.
one of them is not going to have happened.', 5800); + ctx.hint('A Dsteer — the right-hand you is mirrored'); } - 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('A Dsteer   SPACEseize   Euse'); 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(); diff --git a/src/link.js b/src/link.js index c3d10b0..cc39738 100644 --- a/src/link.js +++ b/src/link.js @@ -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(); } + } } diff --git a/src/main.js b/src/main.js index 7818928..7f0c7a3 100644 --- a/src/main.js +++ b/src/main.js @@ -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(); } diff --git a/src/philes.js b/src/philes.js index a0adffa..ab6dd64 100644 --- a/src/philes.js +++ b/src/philes.js @@ -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',