The uname escalation. rms does not get angrier, he gets tireder — each interjection shorter and flatter and typed faster than the last, down to "GNU/linux.", then "...", then "no. i am not doing this again." And then he stops trying to change your mind and goes and rebuilds uname so that it says GNU/Linux, and installs it, and does not ask anybody. Which is, in fairness, the entire strategy. He never won the argument about naming and was never going to. What he did instead was write a compiler, a linker, a shell, a libc and a debugger, put all of them under a licence that forces everyone downstream to keep them free, and wait. That part worked completely. THE RETRAIN — the last cross-cutting system in the design doc that was not in. Real modems renegotiated mid-call: the copper got wet, somebody picked up an extension, the exchange rerouted you through a worse pair, and the two ends stopped and did the whole handshake again in the middle of your download. So now, once per run, somewhere after hop 51, wherever you happen to be standing, the line goes and takes a baud rate off you. The way back is the beat you were taught at hop 47: SPACE on the ANSam phase reversal, 450 ms, four clean ones. Spamming it fails, correctly. Terminal levels opt out with noRetrain, because taking SPACE off somebody mid-sentence is not a mechanic, it is a bug with a story attached. Fixed while testing: the reversal flash was decaying on a setTimeout while the beat it was cueing ran on dt. Two clocks for one event. Frame-based now, which is both more correct and the reason the win path is reachable. 75 philes. All 14 hops swept clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
802 lines
34 KiB
JavaScript
802 lines
34 KiB
JavaScript
// HOPS 35–44 — THE BOARD
|
||
// A BBS. One phone line. If somebody else is on it, you get a busy signal and you
|
||
// do not get in, and you hang up and you redial, and that is the level teaching you
|
||
// to want something. It is the first place in this game with people in it.
|
||
|
||
import * as THREE from '../../vendor/three.module.js';
|
||
import * as A from '../audio.js';
|
||
import * as In from '../input.js';
|
||
import { Term } from '../term.js';
|
||
|
||
let S = {};
|
||
|
||
// ── the login screen. eighty columns, sixteen colours, drawn one character at a
|
||
// time at whatever speed you negotiated two hops ago.
|
||
const W = 72;
|
||
const visible = s => s.replace(/\|(?:B[0-7]|\d\d)/g, '').length;
|
||
const row = s => `|08 █${s}${' '.repeat(Math.max(0, W - visible(s)))}|08█\n`;
|
||
const LOGO =
|
||
'\n|08 ' + '▄'.repeat(W + 2) + '\n' +
|
||
row('') +
|
||
row('|09 ▀█▀ █ █ █▀▀ █ ▄▀▄ █▀▀ ▀█▀ ▀█▀ █▀▄ █ █ █▀█ █ █') +
|
||
row('|11 █ █▀█ █▀▀ █ █▀█ ▀▀█ █ █ █▀▄ █ █ █ █ █▀▄') +
|
||
row('|09 ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀') +
|
||
row('') +
|
||
row('|03 1 node · 2400 8N1 · est. 1987 · sysop |15TANDEM') +
|
||
row('|02 " the switch does not care who you are "') +
|
||
row('') +
|
||
'|08 ' + '▀'.repeat(W + 2) + '\n';
|
||
|
||
const MESSAGES = [
|
||
{ from: 'JOYBUBBLES', subj: 'the sound of a tired switch',
|
||
body: `you can hear when a crossbar is worn. it comes back a half tone flat and it
|
||
takes an extra beat to answer. i know six offices by ear and i have never
|
||
seen any of them and i never will and it does not matter one bit.
|
||
|
||
people keep telling me i am missing something. i am not missing anything.
|
||
i am the only one actually listening.`,
|
||
phile: ['joy-ears', 'the sound of a tired switch'] },
|
||
|
||
{ from: 'TANDEM', subj: 'NEW USER POLICY — READ',
|
||
body: `we do not take new users off the nodelist any more. too much heat.
|
||
|
||
if somebody vouched for you they gave you the word. type it at the ELITE
|
||
prompt. if nobody vouched for you then you are not elite and that is fine,
|
||
there is a whole board out here and most of it is better anyway.
|
||
|
||
(and no, i will not tell you the word. i am not going to type |15EMPTYSET|07 into
|
||
a public message base, am i.)`,
|
||
phile: ['nup-leak', 'NEW USER POLICY — READ'],
|
||
gives: 'nup' },
|
||
|
||
{ from: 'ANONYMOUS', subj: '!!!! K-RAD ANSI !!!! MUST SEE !!!!',
|
||
body: `>>> loading ansi <<<`,
|
||
bomb: true },
|
||
|
||
{ from: 'CAP N CRUNCH', subj: 're: whistle',
|
||
body: `it is a toy. it came free in a box of cereal. it makes one note.
|
||
|
||
the note happens to be two thousand six hundred cycles, which happens to be
|
||
the note the long distance network uses to tell itself a trunk is idle.
|
||
|
||
nobody designed that coincidence. somebody just did not think anybody would
|
||
be listening. that is the whole of it. that is every single one of these.`,
|
||
phile: ['crunch', 're: whistle'] },
|
||
|
||
{ from: 'THE MENTOR', subj: 'a thing i wrote after they took me in',
|
||
body: `we explore, and you call us criminals.
|
||
we seek after knowledge, and you call us criminals.
|
||
we exist without skin colour, without nationality, without religious bias,
|
||
and you call us criminals.
|
||
|
||
you build atomic bombs, you wage wars, you murder, cheat and lie to us and
|
||
try to make us believe it is for our own good, yet we are the criminals.
|
||
|
||
yes, i am a criminal. my crime is that of curiosity.`,
|
||
phile: ['conscience', 'the conscience of a hacker, 1986'] },
|
||
|
||
{ from: 'ratio_cop', subj: 'you have taken 4 and uploaded 0',
|
||
body: `the ratio is one to three. it is on the door, it is in the bulletins, it is
|
||
in this message, and you still did it.
|
||
|
||
i am not angry. i am going to leech your account down to a 1:1 and you are
|
||
going to feel it, and then we will be friends again.`,
|
||
phile: null },
|
||
];
|
||
|
||
// ── day two. Somebody answered you, the sysop is thinking out loud, and a new user
|
||
// has turned up because of something you wrote.
|
||
const DAY2 = [
|
||
{ from: 'FIDONET', subj: 'RE: (no subject)', fido: true,
|
||
body: '' }, // filled in at runtime from what you actually posted
|
||
|
||
{ from: 'TANDEM', subj: 'the phone bill',
|
||
body: `somebody asked why there is still only one line.
|
||
|
||
because a second line is forty dollars a month before a single call, and
|
||
this board makes nothing, and it was never going to make anything, and
|
||
that was always fine.
|
||
|
||
i am not fishing. i am telling you why you get a busy signal.`,
|
||
phile: ['oneline', 'the phone bill'] },
|
||
|
||
{ from: 'newuser', subj: 'found this place',
|
||
body: `somebody on another board pasted something you wrote on the fidonet echo
|
||
and it had this board's number under it.
|
||
|
||
i have been calling for two hours. it is engaged every time. i am going to
|
||
keep calling.`,
|
||
phile: ['echo', 'found this place'] },
|
||
|
||
{ from: 'JOYBUBBLES', subj: 'the tone',
|
||
body: `you will not get this for a while yet and when you do it will already have
|
||
stopped working.
|
||
|
||
that is alright. it does not stop being true that it worked.`,
|
||
phile: null },
|
||
];
|
||
|
||
// ── day three. Goodbyes. He asked people not to and they did anyway.
|
||
const DAY3 = [
|
||
{ from: 'TANDEM', subj: 'LAST CALL — read the bulletin',
|
||
body: `i said do not say anything sad in the message base.
|
||
|
||
you have all completely ignored me. it is the last thing you will ever
|
||
ignore me about and i find that i do not mind.`,
|
||
phile: ['tandem-last', 'LAST CALL'] },
|
||
|
||
{ from: 'gh0st', subj: 'where im going',
|
||
body: `got a shell account at the university. it is $18 a month and it is on all
|
||
the time and there is no ratio and nobody is waiting for the line.
|
||
|
||
it is better. it is so much better. i hate it.`,
|
||
phile: ['shell', 'where im going'] },
|
||
|
||
{ from: 'ratio_cop', subj: 'account closed',
|
||
body: `for the record you finished at 1:1 and i want that on the wall.
|
||
|
||
i was never actually angry. it was a job somebody had to do and i liked
|
||
having a job on here.`,
|
||
phile: null },
|
||
|
||
{ from: 'the girl from 2111', subj: '(no subject)',
|
||
body: `i cannot see the ansi. i never could. somebody read it out to me once, all
|
||
of it, the whole login screen, every block, and it took eleven minutes.
|
||
|
||
i am telling you that because i want somebody to know it happened.`,
|
||
phile: ['read-out', 'somebody read it out to her'] },
|
||
|
||
{ from: 'anonymous', subj: '',
|
||
body: `5551212`,
|
||
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.' },
|
||
{ name: 'BLUEBOX.EXE', size: ' 6,288', note: 'MF tones through a PC speaker. it will not work. try it anyway.' },
|
||
{ name: 'LOD-TJ-1.TXT', size: '38,441', note: 'legion of doom technical journal, volume one.' },
|
||
{ name: 'TONELOC.ZIP', size: '52,010', note: 'wardialer. give it an exchange and go to bed.' },
|
||
];
|
||
|
||
export default {
|
||
id: 'board', hop: 35, title: 'THE BOARD', via: 'sysop.2400.8n1', ms: '2400 ms',
|
||
baud: null, noRetrain: true, // whatever you earned at the handshake. that is the point.
|
||
|
||
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, twDone: false };
|
||
window.__S = S; // debug handle
|
||
|
||
ctx.scene.background = new THREE.Color(0x000000);
|
||
ctx.camera.position.set(0, 0, 5);
|
||
|
||
ctx.termCanvas.style.display = 'block';
|
||
const term = new Term(ctx.termCanvas);
|
||
ctx.term = term;
|
||
S.term = term;
|
||
In.captureText(e => {
|
||
if (term.busy) { term.skip = true; return true; } // ^K aborts the redraw, as it always did
|
||
return term.key(e);
|
||
});
|
||
ctx.hint('');
|
||
|
||
S.script = this.run(ctx, term);
|
||
},
|
||
|
||
// the whole level is one long await. it reads like a transcript because it is one.
|
||
async run(ctx, term) {
|
||
const baud = () => Math.max(300, ctx.link.rate);
|
||
|
||
// ── dial in. the first attempt does not get through, because it never did.
|
||
term.clear();
|
||
term.writeln('');
|
||
term.write('|07atdt |15');
|
||
await ctx.sleep(500);
|
||
const dt = A.dialTone();
|
||
await ctx.sleep(900);
|
||
dt.stop(0.05);
|
||
for (const d of '5551212') { term.write(d); A.dtmf(d, 0.1); await ctx.sleep(180); }
|
||
term.writeln('');
|
||
await ctx.sleep(400);
|
||
|
||
while (S.busyTries < 1) {
|
||
const bs = A.busySignal();
|
||
term.writeln('|12BUSY');
|
||
term.writeln('|08');
|
||
ctx.say('one line. somebody else is on it. you cannot go in.', 4200);
|
||
await ctx.sleep(3600);
|
||
bs.stop();
|
||
S.busyTries++;
|
||
term.writeln('|07ath');
|
||
await ctx.sleep(700);
|
||
term.writeln('|07atdt 5551212');
|
||
const rb = A.ringback();
|
||
await ctx.sleep(3000);
|
||
rb.stop();
|
||
}
|
||
|
||
// ── train up, at whatever rate the handshake gave you
|
||
const ans = A.ansam({ gain: 0.10 });
|
||
await ctx.sleep(700);
|
||
ans.stop();
|
||
const c = A.carrier(0.08);
|
||
await ctx.sleep(900);
|
||
c.stop();
|
||
term.writeln(`|10CONNECT ${ctx.link.rate}`);
|
||
await ctx.sleep(600);
|
||
|
||
// ── the login screen paints. at 2400 this takes about eight seconds and the
|
||
// game does not skip it, and it is the best eight seconds in the game.
|
||
ctx.say(`painting at ${ctx.link.label} — ${Math.round(baud() / 10)} characters a second`, 4200);
|
||
term.clear();
|
||
await term.paint(LOGO, baud());
|
||
term.writeln('');
|
||
const name = await term.ask('|07handle: |15');
|
||
S.handle = (name || 'SYN').toUpperCase().slice(0, 20);
|
||
term.writeln('');
|
||
await term.paint(`|03welcome back, |15${S.handle}|03. you were last on |15never|03.\n`, baud() * 2);
|
||
await ctx.sleep(400);
|
||
ctx.phile('board-login', 'THE LAST TRUNK — login screen');
|
||
|
||
// ── day one
|
||
await this.menuLoop(ctx, term);
|
||
if (S.done) return;
|
||
|
||
// ── and then you come back. that is the whole thing about a board.
|
||
await this.night(ctx, term, 2);
|
||
await this.day2(ctx, term);
|
||
if (S.done) return;
|
||
await this.menuLoop(ctx, term);
|
||
if (S.done) return;
|
||
|
||
await this.night(ctx, term, 3);
|
||
await this.day3(ctx, term);
|
||
await this.menuLoop(ctx, term);
|
||
await this.lastCall(ctx, term);
|
||
},
|
||
|
||
async menuLoop(ctx, term) {
|
||
for (;;) {
|
||
if (S.done) return;
|
||
const k = await this.menu(ctx, term);
|
||
if (k === 'G') { await this.logoff(ctx, term); return; }
|
||
if (k === 'M') await this.messages(ctx, term);
|
||
else if (k === 'F') await this.files(ctx, term);
|
||
else if (k === 'D') await this.doors(ctx, term);
|
||
else if (k === 'E') await this.elite(ctx, term);
|
||
else if (k === 'R') this.reset(ctx, term);
|
||
else { term.writeln('|12?'); await ctx.sleep(400); }
|
||
}
|
||
},
|
||
|
||
// ── the 04:00 mail run. Store and forward. You are asleep for this part, and so
|
||
// is everybody else, and the modems do it anyway.
|
||
async night(ctx, term, toDay) {
|
||
term.clear();
|
||
ctx.hush();
|
||
await ctx.sleep(700);
|
||
await term.paint('|08\n\n 04:00 — the mail run\n\n', 900);
|
||
const hops = ['1:129/22', '1:129/0', '1:1/0', '2:250/1', '3:632/0'];
|
||
for (const h of hops) {
|
||
await term.paint(`|08 dialling ${h} ... `, 1400);
|
||
const rb = A.ringback();
|
||
await ctx.sleep(700);
|
||
rb.stop();
|
||
A.relayClick(0.2, 0.7);
|
||
await term.paint('|02ok\n', 2000);
|
||
await ctx.sleep(220);
|
||
}
|
||
await ctx.sleep(900);
|
||
await term.paint('|08\n nobody is awake for any of this.\n', 1100);
|
||
await ctx.sleep(2400);
|
||
S.day = toDay;
|
||
S.busyTries = 1; // you learned to call later
|
||
S.sysopSeen = 0;
|
||
},
|
||
|
||
// ── day two. The board remembers you, which is most of what a board was.
|
||
async day2(ctx, term) {
|
||
const baud = () => Math.max(300, ctx.link.rate);
|
||
term.clear();
|
||
await term.paint('|07atdt 5551212\n', 2000);
|
||
const rb = A.ringback();
|
||
await ctx.sleep(1600);
|
||
rb.stop();
|
||
const ans = A.ansam({ gain: 0.10 });
|
||
await ctx.sleep(600); ans.stop();
|
||
term.writeln(`|10CONNECT ${ctx.link.rate}`);
|
||
await ctx.sleep(500);
|
||
term.clear();
|
||
await term.paint(LOGO, Math.max(2400, baud()));
|
||
await term.paint(`\n|03 welcome back, |15${S.handle}|03. you were last on |15yesterday|03.\n`, baud() * 2);
|
||
await term.paint(`|03 calls: |152|03. you are user |15#${41 + (S.handle.length % 7)}|03 of 44.\n`, baud() * 2);
|
||
await ctx.sleep(600);
|
||
|
||
// the ratio cop was not bluffing
|
||
if (S.cargo > S.uploads * 3) {
|
||
await term.paint('|12\n *** your ratio has been adjusted to 1:1. ***\n', 1400);
|
||
await term.paint('|08 he said he would. you did it anyway.\n', 1600);
|
||
S.uploads = Math.max(S.uploads, Math.ceil(S.cargo / 3));
|
||
ctx.phile('ratio');
|
||
await ctx.sleep(1800);
|
||
}
|
||
ctx.say('you came back. that is the entire mechanic. that is what a board was.', 5200);
|
||
},
|
||
|
||
// ── day three. LAST CALL.
|
||
async day3(ctx, term) {
|
||
const baud = () => Math.max(300, ctx.link.rate);
|
||
term.clear();
|
||
await term.paint('|07atdt 5551212\n', 2000);
|
||
const rb = A.ringback();
|
||
await ctx.sleep(1400);
|
||
rb.stop();
|
||
term.writeln(`|10CONNECT ${ctx.link.rate}`);
|
||
await ctx.sleep(500);
|
||
term.clear();
|
||
await term.paint(LOGO, Math.max(2400, baud()));
|
||
await term.paint(`
|
||
|12 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||
|12 L A S T C A L L
|
||
|12 ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||
`, Math.max(2400, baud()));
|
||
await ctx.sleep(900);
|
||
await term.paint(`|07
|
||
i am taking the board down at the end of the week.
|
||
|
||
it is not one thing. the second line was never coming. the long distance
|
||
is forty dollars a month and i am paying it out of a job i do not much
|
||
like. and about a third of you have got shell accounts now and are not
|
||
calling any more, and i do not blame you, because it is better, it is
|
||
genuinely better, there is more out there in an afternoon than there has
|
||
been on here in nine years.
|
||
|
||
nine years. forty-four users. i know all of your handles and about six of
|
||
your real names and i have never met a single one of you.
|
||
|
||
take what you want out of the file area. the ratio is off. the elite
|
||
board is open — the word was |15EMPTYSET|07 and it does not matter now.
|
||
|
||
do not say anything sad in the message base. i will only read it.
|
||
|
||
|15 — TANDEM
|
||
`, Math.max(1200, baud()));
|
||
S.elite = true;
|
||
S.ratioOff = true;
|
||
ctx.game.flags.lastCall = true;
|
||
ctx.phile('lastcall');
|
||
ctx.say('nine years. forty-four users. one phone line.', 5600);
|
||
await term.ask('|08 [enter]|07');
|
||
},
|
||
|
||
async lastCall(ctx, term) {
|
||
if (S.done) return;
|
||
S.done = true;
|
||
term.clear();
|
||
await term.paint('|07\n the board goes down on friday.\n', 1400);
|
||
await ctx.sleep(1600);
|
||
await term.paint('|08 it went down on friday.\n', 1400);
|
||
await ctx.sleep(2200);
|
||
term.writeln('');
|
||
term.writeln('|12NO CARRIER');
|
||
A.noCarrier();
|
||
await ctx.sleep(3000);
|
||
ctx.spendTTL(1);
|
||
await ctx.card('THE BOARD', 'nine years · forty-four users · one phone line', 4600);
|
||
ctx.next();
|
||
},
|
||
|
||
async menu(ctx, term) {
|
||
term.writeln('');
|
||
term.writeln('|08──────────────────────────────────────────────────────────────────');
|
||
term.writeln(`|11 [|15M|11]essages [|15F|11]iles [|15D|11]oors [|15E|11]lite [|15G|11]oodbye${In.isRemapped() ? ' [|12R|11]eset' : ''}`);
|
||
term.writeln('|08──────────────────────────────────────────────────────────────────');
|
||
S.sysopSeen++;
|
||
// the sysop can see you typing. before you press enter. he always could.
|
||
if (S.sysopSeen === 3) {
|
||
term.onKey = (e, partial) => {
|
||
if (partial !== null && partial !== undefined) {
|
||
const old = { x: term.cx, y: term.cy, f: term.fg };
|
||
term.goto(0, 0);
|
||
term.write('|08sysop is watching: |07' + String(partial).padEnd(40, ' '));
|
||
term.goto(old.x, old.y); term.fg = old.f;
|
||
}
|
||
};
|
||
}
|
||
const k = (await term.ask('|07command: |15')).trim().toUpperCase()[0] || '?';
|
||
if (S.sysopSeen === 3) {
|
||
term.onKey = null;
|
||
await ctx.sleep(600);
|
||
term.writeln('');
|
||
term.writeln('|14*** the sysop is breaking into chat ***');
|
||
A.burst(880, 1320, 0.14, 0.16);
|
||
await ctx.sleep(900);
|
||
await term.paint(`|15TANDEM|07: i can see what you type before you send it.\n`, 900);
|
||
await term.paint(`|15TANDEM|07: everybody's sysop always could. nobody ever mentions it.\n`, 900);
|
||
await ctx.sleep(500);
|
||
await term.paint(`|15TANDEM|07: anyway. don't take the ansi one. \n`, 900);
|
||
await ctx.sleep(600);
|
||
term.writeln('|14*** chat ended ***');
|
||
ctx.phile('sysop-chat', 'TANDEM broke into chat');
|
||
await ctx.sleep(800);
|
||
}
|
||
return k;
|
||
},
|
||
|
||
today() {
|
||
if (S.day === 2) {
|
||
// the reply is written from what you actually posted, or it is not there
|
||
const fido = DAY2.find(m => m.fido);
|
||
const msg = S.posted;
|
||
fido.body = msg
|
||
? `> ${msg}\n\nfour days. three boards and a long distance call somebody else paid\nfor. i do not know your area code and i am never going to.\n\nreceived. hello.`
|
||
: `you did not post anything on the 04:00 run, so nobody wrote back.\n\nthat is not a punishment either. it is just how store and forward works:\nnothing goes out, nothing comes in, and the modems dial anyway.`;
|
||
fido.subj = msg ? 'RE: (no subject)' : '(nothing on the run)';
|
||
fido.phile = msg ? ['fido-reply', 'somebody wrote back'] : null;
|
||
return DAY2;
|
||
}
|
||
if (S.day === 3) return DAY3;
|
||
return MESSAGES;
|
||
},
|
||
|
||
async messages(ctx, term) {
|
||
const LIST = this.today();
|
||
term.clear();
|
||
term.writeln(`|11 message base 1 — ${S.day === 3 ? 'goodbyes' : 'general'}\n`);
|
||
LIST.forEach((m, i) => {
|
||
term.writeln(`|08 ${String(i + 1).padStart(2)} |07${m.from.padEnd(18)}|15${m.subj || '(no subject)'}`);
|
||
});
|
||
term.writeln('');
|
||
const n = parseInt(await term.ask('|07read # (enter to go back): |15'), 10);
|
||
if (!n || n < 1 || n > LIST.length) return;
|
||
const m = LIST[n - 1];
|
||
term.clear();
|
||
term.writeln(`|08from: |07${m.from}`);
|
||
term.writeln(`|08subj: |15${m.subj}`);
|
||
term.writeln('|08──────────────────────────────────────────────────────────────────');
|
||
await term.paint('|07' + m.body + '\n', Math.max(300, ctx.link.rate));
|
||
|
||
if (m.gives === 'nup') { S.nup = true; ctx.say('he typed it. he actually typed it.', 3400); }
|
||
if (m.phile) ctx.phile(m.phile[0], m.phile[1], m.body);
|
||
|
||
// ── the ANSI bomb. telegraphed, escapable, and the meanest thing in here.
|
||
if (m.bomb && !S.bombed) {
|
||
S.bombed = true;
|
||
await ctx.sleep(500);
|
||
for (let i = 0; i < 26; i++) {
|
||
term.goto(Math.floor(Math.random() * 78), Math.floor(Math.random() * 24));
|
||
term.fg = Math.floor(Math.random() * 16);
|
||
term.write('█▓▒░'[Math.floor(Math.random() * 4)].repeat(2));
|
||
A.blip(200 + Math.random() * 2400, 0.012, 0.05);
|
||
await ctx.sleep(45);
|
||
}
|
||
term.goto(0, 22);
|
||
term.writeln('|12 your keyboard has been redefined.');
|
||
In.setRemap({ KeyW: 'KeyS', KeyS: 'KeyW', KeyA: 'KeyD', KeyD: 'KeyA' });
|
||
ctx.say('<b>ansi bomb</b> your movement keys are inverted. type RESET at the menu.', 6000);
|
||
ctx.phile('ansi-bomb', 'the ANSI bomb', 'a message that remaps your keyboard on read.');
|
||
await ctx.sleep(1600);
|
||
}
|
||
await term.ask('|08[enter]|07');
|
||
},
|
||
|
||
reset(ctx, term) {
|
||
In.clearRemap();
|
||
term.writeln('|10 keyboard restored. you were lucky it was only the arrow keys.');
|
||
A.burst(1320, 1760, 0.2, 0.14);
|
||
},
|
||
|
||
async files(ctx, term) {
|
||
term.clear();
|
||
term.writeln('|11 file area 1 — text\n');
|
||
FILES.forEach((f, i) => term.writeln(`|08 ${String(i + 1).padStart(2)} |15${f.name.padEnd(16)}|07${f.size.padStart(8)} |08${f.note}`));
|
||
term.writeln('');
|
||
term.writeln(S.ratioOff
|
||
? '|02 the ratio is off. take what you want. it is all going anyway.'
|
||
: `|03 ratio 1:3 — you have uploaded |15${S.uploads}|03 and taken |15${S.cargo}|03.`);
|
||
term.writeln('|08 [|15#|08] download [|15U|08] upload something of your own [enter] back');
|
||
const a = (await term.ask('|07: |15')).trim().toUpperCase();
|
||
if (a === 'U') {
|
||
S.uploads++;
|
||
await term.paint('|07 sending... |08xmodem, 128 byte blocks, checksum\n', 4000);
|
||
for (let i = 0; i < 12; i++) { term.write('|10.'); A.blip(1100, 0.01, 0.03); await ctx.sleep(90); }
|
||
term.writeln('|10 ok');
|
||
ctx.say('you gave something. that is the only currency here.', 3200);
|
||
return;
|
||
}
|
||
const n = parseInt(a, 10);
|
||
if (!n || n < 1 || n > FILES.length) return;
|
||
if (!S.ratioOff && S.cargo >= S.uploads * 3 + 1) {
|
||
term.writeln('|12 ratio. upload something first.');
|
||
A.burst(480, 620, 0.3, 0.14);
|
||
await ctx.sleep(1400);
|
||
return;
|
||
}
|
||
const f = FILES[n - 1];
|
||
await term.paint(`|07 receiving |15${f.name}|07 ...\n`, 4000);
|
||
for (let i = 0; i < 18; i++) { term.write('|11▓'); A.blip(1400, 0.008, 0.03); await ctx.sleep(70); }
|
||
term.writeln('|10 complete');
|
||
S.cargo++;
|
||
ctx.game.cargo.push(f.name);
|
||
ctx.game.mtu = Math.max(576, 1500 - ctx.game.cargo.length * 180);
|
||
ctx.refreshHud();
|
||
ctx.phile('file-' + n, f.name, f.note);
|
||
ctx.say(`you are carrying ${ctx.game.cargo.length}. your MTU is down to ${ctx.game.mtu}.`, 3600);
|
||
await ctx.sleep(1000);
|
||
},
|
||
|
||
// ── the door games, in the corner, entirely optional, entirely finished
|
||
async doors(ctx, term) {
|
||
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 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') 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) {
|
||
term.clear();
|
||
await term.paint(`|12
|
||
L E G E N D O F T H E R E D D R A G O N
|
||
|08 ─────────────────────────
|
||
|07 you are |15${S.handle}|07, level 1, and you have 20 hitpoints and a
|
||
|07 short sword and no idea what you have walked into.
|
||
`, Math.max(1200, ctx.link.rate));
|
||
|
||
let hp = 20, gold = 0, foeHp = 14, day = 1;
|
||
const foe = 'a Small Thief';
|
||
term.writeln('');
|
||
await term.paint(`|14 you are in the forest. ${foe} steps out.\n`, 2400);
|
||
for (;;) {
|
||
term.writeln('');
|
||
term.writeln(`|08 you |15${hp}|08 hp ${foe} |15${Math.max(0, foeHp)}|08 hp gold |15${gold}`);
|
||
const a = (await term.ask('|07 (A)ttack (S)tats (R)un: |15')).trim().toUpperCase()[0];
|
||
if (a === 'R') { term.writeln('|03 you run away. the forest does not mind.'); break; }
|
||
if (a === 'S') { term.writeln(`|07 ${S.handle} — level 1, ${hp}/20 hp, ${gold} gold, 0 charm.`); continue; }
|
||
const dmg = 3 + Math.floor(Math.random() * 5);
|
||
foeHp -= dmg;
|
||
A.blip(300, 0.05, 0.06);
|
||
await term.paint(`|10 you hit ${foe} for ${dmg}.\n`, 3000);
|
||
if (foeHp <= 0) {
|
||
gold += 42; day++;
|
||
await term.paint(`|14 ${foe} is dead. you find 42 gold on the body.\n`, 3000);
|
||
await term.paint(`|03 you have used your forest fights for today. come back tomorrow.\n`, 2400);
|
||
ctx.phile('lord', 'LORD — you killed a Small Thief', 'there was a whole game in the corner of the fourth level.');
|
||
ctx.say('there is a whole game in the corner of this level. nobody will believe it is there.', 4600);
|
||
break;
|
||
}
|
||
const back = 1 + Math.floor(Math.random() * 4);
|
||
hp -= back;
|
||
await term.paint(`|12 ${foe} hits you for ${back}.\n`, 3000);
|
||
if (hp <= 0) { term.writeln('|12 you have died. see you tomorrow.'); break; }
|
||
}
|
||
await term.ask('|08 [enter]|07');
|
||
},
|
||
|
||
async elite(ctx, term) {
|
||
term.clear();
|
||
term.writeln('|11 elite sub-board\n');
|
||
if (S.elite) { await this.eliteBoard(ctx, term); return; }
|
||
const w = (await term.ask('|07 NUP: |15', { mask: true })).trim().toUpperCase();
|
||
if (w !== 'EMPTYSET') {
|
||
term.writeln('');
|
||
term.writeln('|12 no.');
|
||
A.burst(480, 620, 0.4, 0.16);
|
||
if (!S.nup) ctx.say('somebody on this board has typed it out loud. they always do.', 4200);
|
||
await ctx.sleep(1600);
|
||
return;
|
||
}
|
||
S.elite = true;
|
||
ctx.game.flags.elite = true;
|
||
A.burst(1320, 1760, 0.3, 0.16);
|
||
await this.eliteBoard(ctx, term);
|
||
},
|
||
|
||
async eliteBoard(ctx, term) {
|
||
term.clear();
|
||
await term.paint(`|10 you are in.
|
||
|08 ──────────────────────────────────────────────────────────────────
|
||
|07 there are four messages in here and three of them are people arguing
|
||
about whose group is better. the fourth one is this.
|
||
|
||
|15 from: TANDEM
|
||
subj: the trunk
|
||
|
||
|07 there is a way down out of the board and into the plant itself. i am
|
||
not going to draw you a map. go to the file area, take nothing, and
|
||
hang up without typing goodbye.
|
||
|
||
the switch will hold the line open for a few seconds after you drop.
|
||
that is the hole. it has been there since 1971 and nobody closed it
|
||
because nobody wrote it down.
|
||
|08 ──────────────────────────────────────────────────────────────────
|
||
`, Math.max(1200, ctx.link.rate));
|
||
ctx.phile('the-trunk', 'TANDEM — the trunk', 'hang up without typing goodbye.');
|
||
ctx.game.flags.knowsTheTrunk = true;
|
||
ctx.say('hang up without saying goodbye.', 5200);
|
||
await term.ask('|08 [enter]|07');
|
||
},
|
||
|
||
async logoff(ctx, term) {
|
||
if (S.day > 1) {
|
||
term.clear();
|
||
await term.paint(`|07\n thanks for calling the last trunk.\n`, 1600);
|
||
await ctx.sleep(700);
|
||
term.writeln('');
|
||
term.writeln('|12NO CARRIER');
|
||
A.noCarrier();
|
||
await ctx.sleep(2400);
|
||
return;
|
||
}
|
||
term.clear();
|
||
// FidoNet. store and forward. your message goes out on the 4am mail run and
|
||
// the reply gets here a level later, which is a "come back tomorrow" mechanic
|
||
// in a game, and it is very funny that it was ever normal.
|
||
await term.paint(`|03 before you go — the fidonet mail run goes out at 04:00.
|
||
anything you post now reaches the rest of the world by tuesday.
|
||
|
||
|08 (zone 1, net 129, node 22. it will go 1:129/22 → 1:1/0 → the world,
|
||
one modem call at a time, all night, while you are asleep.)
|
||
`, Math.max(1200, ctx.link.rate));
|
||
const msg = await term.ask('|07 message to nobody in particular: |15');
|
||
if (msg && msg.trim()) {
|
||
ctx.game.flags.fidoMessage = msg.trim();
|
||
S.posted = msg.trim();
|
||
await term.paint('|10 queued for the 04:00 run.\n', 2400);
|
||
ctx.phile('fido', 'you posted to fidonet', msg.trim());
|
||
ctx.say('it will get there. it will take until tuesday, and it will get there.', 4600);
|
||
await ctx.sleep(2200);
|
||
}
|
||
await term.paint('|07\n thanks for calling the last trunk.\n', 1600);
|
||
await ctx.sleep(900);
|
||
term.writeln('');
|
||
term.writeln('|12NO CARRIER');
|
||
A.noCarrier();
|
||
await ctx.sleep(2600);
|
||
},
|
||
|
||
update(dt, t, ctx) { S.t += dt; },
|
||
|
||
async unmount(ctx) {
|
||
In.releaseText();
|
||
In.clearRemap();
|
||
ctx.termCanvas.style.display = 'none';
|
||
ctx.term = null;
|
||
ctx.hint('');
|
||
},
|
||
};
|