An arms race, a cure that cannot get there, and a board that closes
THE WAR — the trojan arms race. WinNuke, Sub7, Smurf, DCC send. Each one works, and then the level patches it seventy seconds after you first use it, with the countdown on screen and a line about how it died: a hotfix went out; the scanners caught up; the amplifiers stopped answering broadcast; the client asks before it accepts now. You cannot finish the level on one tactic. An exploit is not a tool you own, it is a window that is already closing while you are climbing through it. Plus a rival crew who turn up after the first rejoin, who have read the same text files you have, and who will take the ghost crown out from under you during a split. THE WORM — you can try to stop it. An antidote, three seeds, and it travels only over live links while you are killing links. It cures fast and then stops dead with its component severed, while the death count climbs past it. Measured: 4, 12, 18, 19, stall — dead 20 through 30. It works perfectly on everything it can reach. It cannot reach the rest, because you already took the road. The phones ring all night and nobody can send mail to warn anybody, because the broken thing is the thing you would have used. Pacing fix: spread rate now scales with the infection, since every infected host is running a copy. Correct, and the only way the level ever reached its own milestones — before this, nothing died inside ten minutes. THE BOARD — three days. Day one as before, and you post to FidoNet on the way out. Then the 04:00 run dials five nodes across the world while nobody is awake. Day two the board knows you: last on yesterday, calls 2, user #44 of 44 — and the first message is FIDONET quoting what you actually typed, with an answer. Four days, three boards, a long distance call somebody else paid for. Received. Hello. If you over-took on the files, your ratio has been adjusted to 1:1, because he said it would be. Day three is LAST CALL. Not raided, not busted: the second line was never coming, the long distance is forty dollars a month out of a job he does not much like, and a third of them have shell accounts now and are not calling any more, and he does not blame them, because it is better, it is genuinely better. Nine years. Forty-four users. Six real names. Never met one of them. Ratio off, elite board open, the NUP given away because it does not matter now. He asks nobody to say anything sad in the message base. They all ignore him. 65 philes. All 13 hops swept clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
8e194d677e
commit
594fd584fa
33
STAGES.md
33
STAGES.md
@ -3,7 +3,7 @@
|
||||
> One page per hop. What's built, what's thin, what I'd add, ranked.
|
||||
> Write your direction under **DIRECTION** and I'll build to it.
|
||||
|
||||
Current state: 13 playable hops, ~6,400 lines, all clean.
|
||||
Current state: 13 playable hops, ~7,400 lines, all clean.
|
||||
|
||||
**Since the first pass** (you gave me the wheel; these were my calls): the philes
|
||||
are readable, the game saves, MTU costs you distance, ROOT reads your whole save and
|
||||
@ -100,10 +100,11 @@ body-snatch on the bots.
|
||||
purpose — you take somebody over and then there's nothing to do with them.
|
||||
|
||||
**What I'd add, ranked**
|
||||
1. **Give possession a point.** A possessed client can go where you can't — through a ban, into a +i channel, onto the other side of a split you didn't reach in time.
|
||||
2. **The trojan arms race.** The design doc's best unused idea: every weapon you use gets patched *by the level* ninety seconds after you first use it. Forces you through four different tactics in one level.
|
||||
3. **DCC send.** Somebody offers you a file. It is obviously a trap. Taking it should do something genuinely bad and genuinely funny.
|
||||
4. **A second channel.** #phreak next door, different rules, and the war is actually over which one the population ends up in.
|
||||
1. ~~The trojan arms race.~~ **DONE** — WinNuke, Sub7, Smurf, DCC send, each patched by the level 70 s after first use, with the countdown on screen. You cannot finish on one tactic.
|
||||
2. ~~DCC send.~~ **DONE** — PAMELA.JPG.EXE. They take it. They always take it, and then they're yours and they body-block the rivals.
|
||||
3. ~~Rivals.~~ **DONE** — a crew turns up after the first rejoin who've read the same text files you have, and they'll take the ghost crown out from under you.
|
||||
4. **A second channel.** #phreak next door, different rules, and the war is over which one the population ends up in.
|
||||
5. **Give possession more point.** A possessed client should go where you can't — through a ban, into +i.
|
||||
|
||||
**DIRECTION:**
|
||||
> _
|
||||
@ -143,10 +144,11 @@ accumulating per host, hosts dying at load 7, links dying with them, and the
|
||||
**Thin:** you pick a vector and watch. The only input is 1/2/3.
|
||||
|
||||
**What I'd add, ranked**
|
||||
1. **Let the player try to stop it.** The historical thing is that Morris tried. Give them a kill-switch that doesn't work, or works on one host at a time while the spread outruns them. Futility you can *operate* is much worse than futility you watch.
|
||||
2. **Named hosts that fall over.** When `mit-ai` dies, say so. Sixty anonymous dots dying reads as a bar chart; six named machines dying reads as damage.
|
||||
3. **The phone calls.** The real event had people ringing each other all night. A line every twenty seconds from someone at a dying site.
|
||||
4. **A win condition that is worse.** Spread to everything before anything dies — it should be *possible*, and it should require playing so cautiously that you feel the restraint the worm didn't have.
|
||||
1. ~~Let the player try to stop it.~~ **DONE** — an antidote, on K, three seeds. It travels only over live links, one hop per carrier per tick, while you are killing links. It cures fast and then **stops dead** with its component severed while the death count climbs past it. In testing: 4 → 12 → 18 → 19, stall, dead 20 → 30. The cure works perfectly on everything it can reach. It cannot reach the rest, because you already took the road.
|
||||
2. ~~Named hosts that fall over.~~ **DONE** — the first six are named as they go.
|
||||
3. ~~The phone calls.~~ **DONE** — six, and none of them can send mail to warn anybody, because the broken thing is the thing you'd have used.
|
||||
4. **Pacing note:** attempt rate now scales with the infection (every infected host runs a copy), which is both correct and the only way it reached its own milestones inside a human lifetime. First deaths ~22 s, endgame ~60 s.
|
||||
5. **A win condition that is worse.** Spread to everything before anything dies. Should be possible and should require restraint the worm didn't have.
|
||||
|
||||
**DIRECTION:**
|
||||
> _
|
||||
@ -188,11 +190,12 @@ a playable LORD in the corner.
|
||||
is read-only.
|
||||
|
||||
**What I'd add, ranked**
|
||||
1. **Let the player post.** A message base you can only read is a museum. Posting, getting replied to on the next visit, and having the sysop react is the entire social loop of the era.
|
||||
2. **Come back tomorrow, properly.** The FidoNet run already sets this up. Make the board a place you return to across three separate hops with different content each time — that's the mechanic nobody has put in a game.
|
||||
3. **Finish Trade Wars.** A sector map, a trade route, a Ferrengi. It's the second-best joke in the game if it's real and the worst if it isn't.
|
||||
4. **More messages.** Six is a board with nobody on it. Twenty, most of them arguments.
|
||||
5. **Leeching consequences.** The `ratio_cop` message threatens to leech your account down. It should happen.
|
||||
1. ~~Come back tomorrow, properly.~~ **DONE** — **three days.** Day 1 as before. You post to FidoNet on logoff, watch the 04:00 run dial five nodes across the world while nobody is awake, and come back to *"you were last on yesterday, calls: 2, you are user #44 of 44."*
|
||||
2. ~~Let the player post and be replied to.~~ **DONE** — day 2's first message is FIDONET quoting **what you actually typed**, with an answer. Four days, three boards, a long distance call somebody else paid for.
|
||||
3. ~~Leeching consequences.~~ **DONE** — over-take on day 1 and day 2 opens with your ratio adjusted to 1:1. He said he would.
|
||||
4. ~~The board should close.~~ **DONE, and it's the level's payload now** — day 3 is **LAST CALL**. Not raided, not busted: the second line was never coming, the long distance is forty dollars a month, and a third of them have shell accounts and aren't calling any more and he doesn't blame them because it's *better*. Nine years, forty-four users, six real names, never met one of them. Ratio off, elite board open, NUP given away because it does not matter now. "do not say anything sad in the message base. i will only read it." They all ignore him.
|
||||
5. **Finish Trade Wars.** A sector map, a trade route, a Ferrengi.
|
||||
6. **More messages per day.** Five or six each is still thin for a board.
|
||||
|
||||
**DIRECTION:**
|
||||
> _
|
||||
@ -315,7 +318,7 @@ TIME_WAIT with FIN saying *told you*, then it loops to hop 1.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **The philes** | 51 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. |
|
||||
| **The philes** | 65 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. |
|
||||
| **Audio** | Every tone is real but the *music* isn't built — the design says each level's pad is generated from that era's protocol and right now they're just chords. |
|
||||
| **The retrain** | A mid-game renegotiation that drops your baud without warning. One system, touches every level after it. |
|
||||
| **Difficulty** | Nothing scales. TTL is the only pressure and it's linear. |
|
||||
|
||||
@ -84,6 +84,73 @@ 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'] },
|
||||
];
|
||||
|
||||
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.' },
|
||||
@ -97,7 +164,9 @@ export default {
|
||||
baud: null, // 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 };
|
||||
S = { t: 0, busyTries: 0, cargo: 0, uploads: 0, nup: false, elite: false, bombed: false, done: false,
|
||||
sysopSeen: 0, day: 1, ratioOff: false };
|
||||
window.__S = S; // debug handle
|
||||
|
||||
ctx.scene.background = new THREE.Color(0x000000);
|
||||
ctx.camera.position.set(0, 0, 5);
|
||||
@ -170,7 +239,24 @@ export default {
|
||||
await ctx.sleep(400);
|
||||
ctx.phile('board-login', 'THE LAST TRUNK — login screen');
|
||||
|
||||
// ── main loop
|
||||
// ── 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);
|
||||
@ -184,6 +270,123 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// ── 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──────────────────────────────────────────────────────────────────');
|
||||
@ -221,16 +424,33 @@ export default {
|
||||
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 — general\n');
|
||||
MESSAGES.forEach((m, i) => {
|
||||
term.writeln(`|08 ${String(i + 1).padStart(2)} |07${m.from.padEnd(14)}|15${m.subj}`);
|
||||
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 > MESSAGES.length) return;
|
||||
const m = MESSAGES[n - 1];
|
||||
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}`);
|
||||
@ -272,7 +492,9 @@ export default {
|
||||
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(`|03 ratio 1:3 — you have uploaded |15${S.uploads}|03 and taken |15${S.cargo}|03.`);
|
||||
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') {
|
||||
@ -285,7 +507,7 @@ export default {
|
||||
}
|
||||
const n = parseInt(a, 10);
|
||||
if (!n || n < 1 || n > FILES.length) return;
|
||||
if (S.cargo >= S.uploads * 3 + 1) {
|
||||
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);
|
||||
@ -400,7 +622,16 @@ export default {
|
||||
},
|
||||
|
||||
async logoff(ctx, term) {
|
||||
S.done = true;
|
||||
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
|
||||
@ -414,6 +645,7 @@ export default {
|
||||
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);
|
||||
@ -425,9 +657,6 @@ export default {
|
||||
term.writeln('|12NO CARRIER');
|
||||
A.noCarrier();
|
||||
await ctx.sleep(2600);
|
||||
ctx.spendTTL(1);
|
||||
await ctx.card('THE BOARD', ctx.game.flags.knowsTheTrunk ? 'hang up without saying goodbye' : 'you did not find the sub-board', 3000);
|
||||
ctx.next();
|
||||
},
|
||||
|
||||
update(dt, t, ctx) { S.t += dt; },
|
||||
|
||||
@ -19,6 +19,24 @@ const SAYS = [
|
||||
'anyone here from 604', 'nice split', 'op me', 'no', 'rehash your conf',
|
||||
];
|
||||
|
||||
// ── the arms race.
|
||||
// Everything here is real and everything here worked for about a fortnight. You use
|
||||
// it, and then the level patches it, because that is what actually happened: an
|
||||
// exploit is not a tool you own, it is a window that is already closing while you
|
||||
// are climbing through it.
|
||||
const WEAPONS = [
|
||||
{ key: 'Digit1', name: 'WinNuke', hint: 'OOB to 139. it just falls over.',
|
||||
life: 70, patch: 'a hotfix went out. everyone applied it. that took four days.' },
|
||||
{ key: 'Digit2', name: 'Sub7', hint: 'you are driving them now.',
|
||||
life: 70, patch: 'the scanners caught up. every AV on the planet knows the signature.' },
|
||||
{ key: 'Digit3', name: 'Smurf', hint: 'everybody in range, at once.',
|
||||
life: 70, patch: 'the amplifiers stopped answering broadcast. one line in one config.' },
|
||||
{ key: 'Digit4', name: 'DCC send', hint: 'they will take it. they always take it.',
|
||||
life: 70, patch: 'the client asks before it accepts now. it never used to.' },
|
||||
];
|
||||
|
||||
const RIVALS = ['gLoBaLkOs', 'MiLw0rm', 'ph33r'];
|
||||
|
||||
let S = {};
|
||||
|
||||
export default {
|
||||
@ -28,7 +46,9 @@ export default {
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, split: 0, splitDir: 1, splitTimer: 12, phase: 'idle', owned: 0, need: 2,
|
||||
inv: 0, possessing: null, crown: null, said: 0 };
|
||||
inv: 0, possessing: null, crown: null, said: 0,
|
||||
weapons: [], rivals: [], rivalsIn: false, smurfFx: 0 };
|
||||
window.__S = S; // debug handle
|
||||
|
||||
scene.background = new THREE.Color(0x0b0716);
|
||||
scene.fog = new THREE.FogExp2(0x0b0716, 0.008);
|
||||
@ -67,7 +87,8 @@ export default {
|
||||
g.position.set(Math.cos(a) * r, 0.7, Math.sin(a) * r);
|
||||
scene.add(g);
|
||||
S.people.push({ g, dot, tag, nick, vx: (rand() - 0.5) * 3, vz: (rand() - 0.5) * 3,
|
||||
op: i === 0, bot: i > 11, possessed: false, sayAt: rand() * 8 });
|
||||
op: i === 0, bot: i > 11, possessed: false, sayAt: rand() * 8,
|
||||
stun: 0, mine: false });
|
||||
});
|
||||
|
||||
// the crown. whoever is holding it runs the channel and everybody hates them.
|
||||
@ -91,8 +112,17 @@ export default {
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.far = 300; camera.updateProjectionMatrix();
|
||||
|
||||
// ── the armoury, on the floor, counting down
|
||||
S.weapons = WEAPONS.map(w => ({ ...w, used: false, t: 0, patched: false }));
|
||||
S.armTag = sprite('', { color: '#55ffff', size: 0.95 });
|
||||
S.armTag.position.set(0, 4.2, R - 3); scene.add(S.armTag);
|
||||
|
||||
// ── the rival crew. they show up after the first rejoin and they want the
|
||||
// same empty room you do.
|
||||
S.rivals = [];
|
||||
|
||||
A.pad([146.83, 220, 293.66], { gain: 0.03, type: 'sawtooth' });
|
||||
ctx.hint('<k>WASD</k>move<br><k>E</k>take ops / possess');
|
||||
ctx.hint('<k>WASD</k>move<br><k>E</k>take ops / possess<br><k>1–4</k>use it while it works');
|
||||
await ctx.card('THE WAR', 'hop 10 · efnet, 1998', 2800);
|
||||
ctx.say('nobody owns this room. that has never once stopped anybody.', 4600);
|
||||
setTimeout(() => ctx.say('when the server splits, get to the empty side. take ops in a room with nobody in it.', 6200), 6000);
|
||||
@ -115,6 +145,14 @@ export default {
|
||||
// ── the room mills about and says things
|
||||
S.people.forEach(q => {
|
||||
if (q.gone) return;
|
||||
q.stun = Math.max(0, q.stun - dt);
|
||||
if (q.stun > 0) {
|
||||
q.g.position.y = damp(q.g.position.y, 0.15, 6, dt); // fell over
|
||||
q.dot.material.color.setHex(0x1c3a1c);
|
||||
q.tag.userData.setText(q.nick, '#3a5a3a');
|
||||
return;
|
||||
}
|
||||
q.g.position.y = damp(q.g.position.y, 0.7, 6, dt);
|
||||
q.g.position.x += q.vx * dt; q.g.position.z += q.vz * dt;
|
||||
const d = Math.hypot(q.g.position.x, q.g.position.z);
|
||||
if (d > R - 2) { q.vx *= -1; q.vz *= -1; }
|
||||
@ -126,17 +164,21 @@ export default {
|
||||
ctx.say(SAYS[Math.floor(Math.random() * SAYS.length)], 2400, '<' + q.nick + '>');
|
||||
}
|
||||
}
|
||||
q.dot.material.color.setHex(q.possessed ? 0xff55ff : q.bot ? 0x5555ff : 0x55ff55);
|
||||
q.tag.userData.setText((q.op ? '@' : q.possessed ? '*' : '') + q.nick,
|
||||
q.possessed ? '#ff9fff' : q.op ? '#ffff55' : '#9fe0a0');
|
||||
if (!q.mine) q.dot.material.color.setHex(q.possessed ? 0xff55ff : q.bot ? 0x5555ff : 0x55ff55);
|
||||
q.tag.userData.setText((q.op ? '@' : q.mine ? '+' : q.possessed ? '*' : '') + q.nick,
|
||||
q.mine ? '#ffff9f' : q.possessed ? '#ff9fff' : q.op ? '#ffff55' : '#9fe0a0');
|
||||
});
|
||||
|
||||
this.armoury(dt, ctx);
|
||||
this.rivals(dt, ctx);
|
||||
|
||||
// ── the op chases you and will kick you, because that is what ops do
|
||||
if (S.opHolder && !S.opHolder.gone && !S.iAmOp) {
|
||||
if (S.opHolder && !S.opHolder.gone && !S.iAmOp && S.opHolder.stun <= 0) {
|
||||
const o = S.opHolder.g.position;
|
||||
const dx = p.x - o.x, dz = p.z - o.z, d = Math.hypot(dx, dz) || 1;
|
||||
S.opHolder.vx = damp(S.opHolder.vx, dx / d * 5.5, 2, dt);
|
||||
S.opHolder.vz = damp(S.opHolder.vz, dz / d * 5.5, 2, dt);
|
||||
const chase = 5.5 + S.owned * 1.6; // it gets more personal each time
|
||||
S.opHolder.vx = damp(S.opHolder.vx, dx / d * chase, 2, dt);
|
||||
S.opHolder.vz = damp(S.opHolder.vz, dz / d * chase, 2, dt);
|
||||
if (d < 1.6 && S.inv <= 0) {
|
||||
S.inv = 2.2;
|
||||
A.burst(220, 180, 0.3, 0.2);
|
||||
@ -173,17 +215,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// ── possession. sub7 is a body-snatch and it keeps their animations.
|
||||
if (In.useHit()) {
|
||||
const near = S.people.find(q => !q.gone && q.bot && Math.hypot(p.x - q.g.position.x, p.z - q.g.position.z) < 2.4);
|
||||
if (near && !near.possessed) {
|
||||
near.possessed = true;
|
||||
S.possessing = near;
|
||||
A.burst(660, 990, 0.2, 0.16);
|
||||
ctx.say('<b>sub7</b> you are driving somebody else now. they kept their own walk.', 4000);
|
||||
ctx.phile('sub7', 'Back Orifice / Sub7', 'cDc shipped it at DEF CON 6 and called it a remote administration tool.');
|
||||
}
|
||||
}
|
||||
if (S.possessing) {
|
||||
const q = S.possessing;
|
||||
q.g.position.x = damp(q.g.position.x, p.x + 2.2, 4, dt);
|
||||
@ -191,11 +222,144 @@ export default {
|
||||
q.vx = q.vz = 0;
|
||||
}
|
||||
|
||||
ctx.link.glitch = S.phase === 'lag' ? ctx.link.glitch : 0;
|
||||
S.smurfFx = Math.max(0, S.smurfFx - dt * 1.6);
|
||||
ctx.link.glitch = (S.phase === 'lag' ? ctx.link.glitch : 0) + S.smurfFx * 0.4;
|
||||
ctx.camera.position.x = damp(ctx.camera.position.x, p.x * 0.3, 3, dt);
|
||||
ctx.camera.lookAt(p.x * 0.25, 0, p.z * 0.2);
|
||||
},
|
||||
|
||||
// ── the armoury. Four things, each of which works right up until it does not.
|
||||
armoury(dt, ctx) {
|
||||
const p = S.p.position;
|
||||
let line = '';
|
||||
S.weapons.forEach((w, i) => {
|
||||
if (w.used && !w.patched) {
|
||||
w.t += dt;
|
||||
if (w.t >= w.life) {
|
||||
w.patched = true;
|
||||
A.burst(180, 140, 0.5, 0.16);
|
||||
ctx.say(`*** <b>${w.name}</b> has been patched. ${w.patch}`, 5200);
|
||||
}
|
||||
}
|
||||
const left = Math.max(0, Math.ceil(w.life - w.t));
|
||||
line += `${i + 1} ${w.name}${w.patched ? ' ✕' : w.used ? ' ' + left + 's' : ''} `;
|
||||
if (!In.hit(w.key)) return;
|
||||
|
||||
if (w.patched) {
|
||||
A.burst(300, 220, 0.25, 0.14);
|
||||
ctx.say(`<b>${w.name}</b> — patched. it has been patched for weeks. keep up.`, 3000);
|
||||
return;
|
||||
}
|
||||
if (!w.used) {
|
||||
w.used = true;
|
||||
ctx.say(`<b>${w.name}</b> ${w.hint} <i>it will be patched in about a minute.</i>`, 4600);
|
||||
ctx.phile('armsrace');
|
||||
}
|
||||
this.fire(ctx, w, i);
|
||||
});
|
||||
S.armTag.userData.setText(line.trim(), S.weapons.every(w => w.patched) ? '#ff4a5e' : '#55ffff');
|
||||
},
|
||||
|
||||
fire(ctx, w, i) {
|
||||
const p = S.p.position;
|
||||
const live = S.people.filter(q => !q.gone);
|
||||
const nearest = live.filter(q => !q.bot)
|
||||
.sort((a, b) => a.g.position.distanceTo(p) - b.g.position.distanceTo(p))[0];
|
||||
|
||||
if (i === 0) { // WinNuke — one machine, instantly
|
||||
if (!nearest) return;
|
||||
nearest.stun = 9;
|
||||
A.burst(220, 3000, 0.14, 0.2);
|
||||
ctx.say(`*** ${nearest.nick} has quit (Connection reset by peer)`, 3000);
|
||||
}
|
||||
if (i === 1) { // Sub7 — you are them now
|
||||
const bot = live.find(q => q.bot && !q.possessed &&
|
||||
Math.hypot(p.x - q.g.position.x, p.z - q.g.position.z) < 9);
|
||||
if (!bot) { ctx.say('nothing in range listening on 27374.', 2400); return; }
|
||||
bot.possessed = true; S.possessing = bot;
|
||||
A.burst(660, 990, 0.2, 0.16);
|
||||
ctx.say('<b>sub7</b> you are driving somebody else now. they kept their own walk.', 4000);
|
||||
ctx.phile('sub7');
|
||||
}
|
||||
if (i === 2) { // Smurf — everybody in range at once
|
||||
let n = 0;
|
||||
live.forEach(q => {
|
||||
if (Math.hypot(p.x - q.g.position.x, p.z - q.g.position.z) < 11) { q.stun = 6; n++; }
|
||||
});
|
||||
A.burst(90, 130, 0.7, 0.22);
|
||||
S.smurfFx = 0.8;
|
||||
ctx.say(`*** ${n} clients, one echo request, several hundred replies each.`, 3600);
|
||||
ctx.phile('smurf');
|
||||
}
|
||||
if (i === 3) { // DCC send — they always take it
|
||||
const mark = live.filter(q => !q.mine && q !== S.opHolder)
|
||||
.sort((a, b) => a.g.position.distanceTo(p) - b.g.position.distanceTo(p))[0];
|
||||
if (!mark) return;
|
||||
mark.mine = true;
|
||||
A.burst(1320, 1760, 0.2, 0.16);
|
||||
ctx.say(`*** ${mark.nick} accepted <b>PAMELA.JPG.EXE</b>. of course they did.`, 4200);
|
||||
ctx.phile('dcc');
|
||||
}
|
||||
},
|
||||
|
||||
// ── the rivals. They want the same empty room you do, and after the first rejoin
|
||||
// they know the trick too.
|
||||
rivals(dt, ctx) {
|
||||
if (S.owned >= 1 && !S.rivalsIn) {
|
||||
S.rivalsIn = true;
|
||||
RIVALS.forEach((nick, i) => {
|
||||
const g = new THREE.Group();
|
||||
const dot = new THREE.Mesh(new THREE.SphereGeometry(0.6, 12, 9), glow(0xff4a5e, 0.9));
|
||||
g.add(dot);
|
||||
const tag = sprite(nick, { color: '#ff8a9a', size: 0.6 });
|
||||
tag.position.y = 1.4; g.add(tag);
|
||||
const a = i * 2.1;
|
||||
g.position.set(Math.cos(a) * (R - 4), 0.75, Math.sin(a) * (R - 4));
|
||||
ctx.scene.add(g);
|
||||
S.rivals.push({ g, dot, tag, nick, stun: 0 });
|
||||
});
|
||||
ctx.say('*** three more just joined. they have read the same text files you have.', 5000);
|
||||
}
|
||||
S.rivals.forEach(r => {
|
||||
r.stun = Math.max(0, r.stun - dt);
|
||||
r.dot.material.color.setHex(r.stun > 0 ? 0x552028 : 0xff4a5e);
|
||||
if (r.stun > 0) return;
|
||||
// during a split they go for the ghost. otherwise they come for you.
|
||||
const tgt = (S.phase === 'split' && S.ghost && S.ghost.visible)
|
||||
? S.ghost.position : S.p.position;
|
||||
const dx = tgt.x - r.g.position.x, dz = tgt.z - r.g.position.z;
|
||||
const d = Math.hypot(dx, dz) || 1;
|
||||
const spd = (S.phase === 'split') ? 7.2 : 4.4;
|
||||
r.g.position.x += dx / d * spd * dt;
|
||||
r.g.position.z += dz / d * spd * dt;
|
||||
// they can take the ghost crown out from under you
|
||||
if (S.phase === 'split' && S.ghost && S.ghost.visible && !S.tookGhost && d < 1.8) {
|
||||
S.ghost.visible = false;
|
||||
S.rivalTook = r.nick;
|
||||
A.burst(220, 180, 0.4, 0.18);
|
||||
ctx.say(`*** ${r.nick} got there first. that is the game.`, 4000);
|
||||
}
|
||||
// and they can hit you
|
||||
if (Math.hypot(S.p.position.x - r.g.position.x, S.p.position.z - r.g.position.z) < 1.5 && S.inv <= 0) {
|
||||
S.inv = 2.0;
|
||||
A.burst(200, 160, 0.25, 0.18);
|
||||
ctx.say(`*** flooded off by ${r.nick}`, 2600);
|
||||
ctx.spendTTL(1, 'flooded');
|
||||
}
|
||||
});
|
||||
// your own converted clients get in the way of everybody, which is the point
|
||||
S.people.forEach(q => {
|
||||
if (!q.mine || q.gone) return;
|
||||
q.dot.material.color.setHex(0xffff55);
|
||||
const dx = S.p.position.x - q.g.position.x, dz = S.p.position.z - q.g.position.z;
|
||||
const d = Math.hypot(dx, dz) || 1;
|
||||
if (d > 3) { q.vx = dx / d * 6; q.vz = dz / d * 6; } else { q.vx *= 0.9; q.vz *= 0.9; }
|
||||
S.rivals.forEach(r => {
|
||||
if (r.g.position.distanceTo(q.g.position) < 1.6) r.stun = Math.max(r.stun, 1.6);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
beginSplit(ctx) {
|
||||
S.phase = 'lag';
|
||||
S.splitTimer = 0;
|
||||
@ -238,6 +402,10 @@ export default {
|
||||
S.people.forEach(q => { q.gone = false; q.g.visible = true; });
|
||||
A.burst(880, 1320, 0.3, 0.16);
|
||||
|
||||
if (S.rivalTook) {
|
||||
S.rivalTook = null;
|
||||
S.iAmOp = false; S.myCrown.visible = false;
|
||||
}
|
||||
if (S.iAmOp) {
|
||||
S.owned++;
|
||||
// everyone comes back and you are already wearing the crown
|
||||
|
||||
@ -21,6 +21,21 @@ const VECTORS = [
|
||||
{ key: 'Digit3', name: 'rsh trust', desc: 'it just believes .rhosts', reach: 4.0, rate: 1.6 },
|
||||
];
|
||||
|
||||
// The phones rang all night. Nobody could send mail to warn anybody, because the
|
||||
// thing that was broken was the thing you would have used to tell them.
|
||||
const CALLS = [
|
||||
'berkeley are rebooting every twenty minutes. it comes back before they finish.',
|
||||
'somebody at purdue says kill the process. there are forty of them. they respawn.',
|
||||
'we cannot post the fix. we cannot post anything. <i>that is the whole problem.</i>',
|
||||
'the milnet gateways just came down on purpose. somebody unplugged the country.',
|
||||
'i have got the load average here at ninety. ninety. it is a VAX.',
|
||||
'people are driving to each other with tapes. actual tapes. in cars.',
|
||||
];
|
||||
|
||||
// He wrote one. It could not get anywhere, because the thing it had to travel
|
||||
// through was the thing he had broken.
|
||||
const ANTIDOTE_SEEDS = 3;
|
||||
|
||||
let S = {};
|
||||
|
||||
export default {
|
||||
@ -29,7 +44,9 @@ export default {
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, vec: 0, nodes: [], links: [], spread: 0, dead: 0, infected: 0, done: false, reinfects: 0 };
|
||||
S = { t: 0, vec: 0, nodes: [], links: [], spread: 0, dead: 0, infected: 0, done: false, reinfects: 0,
|
||||
anti: false, seeds: 0, antiT: 0, callT: 0, callN: 0, cured: 0, saved: 0 };
|
||||
window.__S = S; // debug handle
|
||||
|
||||
scene.background = new THREE.Color(0x04060a);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.5));
|
||||
@ -67,6 +84,10 @@ export default {
|
||||
S.linkMesh = new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ color: 0x1d3a4a, transparent: true, opacity: 0.55 }));
|
||||
scene.add(S.linkMesh);
|
||||
|
||||
// adjacency, so the antidote has something to try to travel down
|
||||
S.nodes.forEach(n => { n.adj = []; });
|
||||
S.links.forEach(lk => { lk.a.adj.push(lk); lk.b.adj.push(lk); });
|
||||
|
||||
// patient zero
|
||||
const z0 = S.nodes[7];
|
||||
z0.state = 'infected'; z0.load = 1; S.infected = 1;
|
||||
@ -98,13 +119,16 @@ export default {
|
||||
S.vecTag.userData.setText(`${V.name} — 1 fingerd 2 sendmail 3 rsh`, '#63ffa2');
|
||||
|
||||
// ── spread
|
||||
S.spread += dt * V.rate;
|
||||
// Every infected host is running a copy, and every copy is trying. So the
|
||||
// attempt rate scales with the infection — which is both what actually happened
|
||||
// and the only way this reaches its own milestones inside a human lifetime.
|
||||
S.spread += dt * V.rate * (1 + S.infected * 0.55);
|
||||
while (S.spread > 0.55 && !S.done) {
|
||||
S.spread -= 0.55;
|
||||
const carriers = S.nodes.filter(n => n.state === 'infected');
|
||||
if (!carriers.length) break;
|
||||
const from = carriers[Math.floor(Math.random() * carriers.length)];
|
||||
const near = S.nodes.filter(n => n !== from && n.state !== 'dead' &&
|
||||
const near = S.nodes.filter(n => n !== from && n.state !== 'dead' && n.state !== 'cured' &&
|
||||
Math.hypot(n.x - from.x, n.z - from.z) < V.reach + 6);
|
||||
if (!near.length) continue;
|
||||
const to = near[Math.floor(Math.random() * near.length)];
|
||||
@ -123,10 +147,19 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
this.antidote(dt, ctx);
|
||||
|
||||
// ── load kills hosts. you are travelling through them and they are falling over.
|
||||
let dead = 0, inf = 0;
|
||||
let dead = 0, inf = 0, cured = 0;
|
||||
S.nodes.forEach(n => {
|
||||
if (n.state === 'dead') { dead++; return; }
|
||||
if (n.state === 'cured') {
|
||||
cured++;
|
||||
n.dot.material.color.setHex(0x2ad8ff);
|
||||
n.dot.scale.setScalar(0.9 + Math.sin(S.t * 2 + n.x) * 0.05);
|
||||
n.tag.userData.setText(n.nm, '#6fd8ff');
|
||||
return;
|
||||
}
|
||||
if (n.state === 'infected') {
|
||||
inf++;
|
||||
n.t += dt;
|
||||
@ -136,6 +169,7 @@ export default {
|
||||
n.tag.userData.setText(n.nm + (n.load > 1 ? ` ×${n.load}` : ''), heat > 0.6 ? '#ff8a4a' : '#63ffa2');
|
||||
if (n.load >= 7) {
|
||||
n.state = 'dead';
|
||||
if ((S.named = (S.named || 0) + 1) <= 6) ctx.say(`<b>${n.nm}</b> is down.`, 2600);
|
||||
n.dot.material.color.setHex(0x2a1014);
|
||||
n.dot.scale.setScalar(0.5);
|
||||
n.tag.userData.setText(n.nm, '#4a2028');
|
||||
@ -143,11 +177,13 @@ export default {
|
||||
}
|
||||
}
|
||||
});
|
||||
S.dead = dead; S.infected = inf;
|
||||
S.dead = dead; S.infected = inf; S.cured = cured;
|
||||
|
||||
const total = S.nodes.length;
|
||||
S.hudA.userData.setText(
|
||||
`infected ${String(inf).padStart(2)} dead ${String(dead).padStart(2)} of ${total} · reinfections ${S.reinfects}`,
|
||||
`infected ${String(inf).padStart(2)} dead ${String(dead).padStart(2)}` +
|
||||
(S.anti ? ` saved ${String(cured).padStart(2)} seeds ${S.seeds}` : '') +
|
||||
` of ${total} · reinfections ${S.reinfects}`,
|
||||
dead > total * 0.35 ? '#ff4a5e' : '#ffd9a8');
|
||||
|
||||
// links go with the hosts on either end
|
||||
@ -181,12 +217,76 @@ export default {
|
||||
A.stopPad();
|
||||
await ctx.sleep(1400);
|
||||
ctx.spendTTL(1);
|
||||
ctx.game.flags.wormSaved = S.cured;
|
||||
if (S.cured > 0) {
|
||||
ctx.say(`you saved ${S.cured}. the fix worked perfectly on every machine it could reach.`, 5600);
|
||||
await ctx.sleep(5800);
|
||||
ctx.say('it could not reach the others. you had already taken the road.', 5200);
|
||||
await ctx.sleep(5400);
|
||||
ctx.phile('cure');
|
||||
}
|
||||
await ctx.card('~6,000 HOSTS', 'about ten per cent of the internet. the internet was about 60,000 machines.', 5000);
|
||||
ctx.next();
|
||||
})();
|
||||
}
|
||||
},
|
||||
|
||||
// ── the antidote.
|
||||
// He wrote one. It went nowhere, because the only road it had to travel was the
|
||||
// one he had already broken. It spreads over live links only, and you are killing
|
||||
// links, so its reachable set shrinks faster than it can walk.
|
||||
antidote(dt, ctx) {
|
||||
if (!S.anti && S.dead >= 6) {
|
||||
S.anti = true;
|
||||
S.seeds = ANTIDOTE_SEEDS;
|
||||
ctx.say('you could write a fix. <b>press K.</b>', 5200);
|
||||
ctx.hint('<k>1</k> <k>2</k> <k>3</k>vector<br><k>K</k>release the antidote');
|
||||
}
|
||||
if (!S.anti) return;
|
||||
|
||||
if (In.hit('KeyK') && S.seeds > 0) {
|
||||
// it starts wherever there is still something alive to start it on
|
||||
const host = S.nodes.filter(n => n.state === 'infected')
|
||||
.sort((a, b) => b.adj.filter(l => !l.dead).length - a.adj.filter(l => !l.dead).length)[0];
|
||||
if (host) {
|
||||
S.seeds--;
|
||||
host.state = 'cured'; host.load = 0;
|
||||
A.burst(1760, 2640, 0.3, 0.18);
|
||||
S.antiT = 0;
|
||||
if (!S.antiSaid) {
|
||||
S.antiSaid = true;
|
||||
ctx.say('it can only travel the way the worm travelled. down the same links.', 5200);
|
||||
ctx.phile('antidote');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// it walks — slowly, over links that are still up
|
||||
S.antiT = (S.antiT || 0) + dt;
|
||||
if (S.antiT > 0.80) {
|
||||
S.antiT -= 0.80;
|
||||
const carriers = S.nodes.filter(n => n.state === 'cured');
|
||||
for (const c of carriers) {
|
||||
const live = c.adj.filter(l => !l.dead);
|
||||
for (const l of live) {
|
||||
const other = l.a === c ? l.b : l.a;
|
||||
if (other.state === 'infected' || other.state === 'clean') {
|
||||
other.state = 'cured'; other.load = 0;
|
||||
A.blip(2200 + Math.random() * 400, 0.01, 0.025);
|
||||
break; // one hop per carrier per tick. it is slow.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the phone calls, while you watch it not work
|
||||
S.callT = (S.callT || 0) + dt;
|
||||
if (S.callT > 11 && S.callN < CALLS.length) {
|
||||
S.callT = 0;
|
||||
ctx.say(CALLS[S.callN++], 5200, 'the phone');
|
||||
}
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
A.stopPad();
|
||||
ctx.link.glitch = 0;
|
||||
|
||||
164
src/philes.js
164
src/philes.js
@ -128,6 +128,47 @@ that waiting was the point.
|
||||
it was, a bit.`,
|
||||
},
|
||||
|
||||
'armsrace': {
|
||||
title: 'the half-life of an exploit',
|
||||
from: 'every single one of them',
|
||||
body: `it works. for about a fortnight it works on everything, everywhere,
|
||||
against everyone, and it feels like you have found a door in the world.
|
||||
|
||||
then somebody writes four lines of patch and posts it, and within a month
|
||||
it works on nothing, and it will never work again, and the only thing you
|
||||
actually own is having been there while it did.
|
||||
|
||||
an exploit is not a tool. it is a window that is already closing while you
|
||||
are climbing through it.`,
|
||||
},
|
||||
|
||||
'smurf': {
|
||||
title: 'smurf',
|
||||
from: 'ICMP, and a badly configured router',
|
||||
body: `you send one echo request. you forge the source address so it looks like
|
||||
it came from whoever you dislike. you send it to a network's broadcast
|
||||
address, so every machine on that network answers.
|
||||
|
||||
one packet in. two hundred packets out, all of them at somebody else.
|
||||
find a few hundred of those networks and one person on a modem can put a
|
||||
university off the air.
|
||||
|
||||
the fix was one line in one config: stop answering broadcast pings. it
|
||||
took years to get it into enough routers, and then it was simply over.`,
|
||||
},
|
||||
|
||||
'dcc': {
|
||||
title: 'PAMELA.JPG.EXE',
|
||||
from: 'they always take it',
|
||||
body: `the client offered to receive a file and the file had two extensions and
|
||||
the operating system was configured, by default, to hide the second one.
|
||||
|
||||
so it looked like a picture. it was not a picture.
|
||||
|
||||
nobody was tricked by anything clever. they were tricked by a display
|
||||
preference that shipped switched on, and it worked for about six years.`,
|
||||
},
|
||||
|
||||
'irc': {
|
||||
title: 'how a channel is owned',
|
||||
from: 'a text file with no author on it',
|
||||
@ -239,6 +280,33 @@ self-limiting program into a machine for grinding a network into a stop.
|
||||
about six thousand hosts. roughly a tenth of everything there was.`,
|
||||
},
|
||||
|
||||
'antidote': {
|
||||
title: 'the fix that could not get there',
|
||||
from: 'the night of 2 November 1988',
|
||||
body: `he wrote one. within hours. it was correct.
|
||||
|
||||
it could not travel. the only road it had was the road he had already
|
||||
broken — mail was down because the worm was in sendmail, the gateways were
|
||||
being unplugged on purpose, and every machine that could have relayed it
|
||||
was at load ninety trying to run forty copies of the thing it was supposed
|
||||
to be curing.
|
||||
|
||||
people ended up driving tapes to each other in cars.
|
||||
|
||||
that is the shape of the whole night: the cure existed, immediately, and
|
||||
the network was in no condition to carry it.`,
|
||||
},
|
||||
|
||||
'cure': {
|
||||
title: 'what you saved',
|
||||
from: 'the reachable set',
|
||||
body: `it worked perfectly on every machine it could reach.
|
||||
|
||||
that is not a consolation. that is the exact measure of the damage: the
|
||||
fix was never the hard part. the hard part was that you had already taken
|
||||
the road.`,
|
||||
},
|
||||
|
||||
'fingerd': {
|
||||
title: 'gets()',
|
||||
from: 'the actual line',
|
||||
@ -421,6 +489,102 @@ nobody wrote it down.
|
||||
hang up without typing goodbye.`,
|
||||
},
|
||||
|
||||
'lastcall': {
|
||||
title: 'LAST CALL',
|
||||
from: 'TANDEM, the ninth year',
|
||||
body: `nine years. forty-four users. one phone line.
|
||||
|
||||
it was not one thing that killed it. the second line was never coming. the
|
||||
long distance was forty dollars a month out of a job he did not much like.
|
||||
and about a third of them had shell accounts by then and were not calling
|
||||
any more, and he did not blame them, because it was better — genuinely,
|
||||
obviously better, more out there in an afternoon than had been on his board
|
||||
in nine years.
|
||||
|
||||
that is how nearly all of them went. not raided. not busted. just slowly
|
||||
out-competed by something with no ratio and no busy signal and nobody
|
||||
waiting for the line.`,
|
||||
},
|
||||
|
||||
'fido-reply': {
|
||||
title: 'somebody wrote back',
|
||||
from: 'four days, three boards, one long distance call',
|
||||
body: `it went out on the 04:00 run and it got there by tuesday and somebody you
|
||||
will never meet, in an area code you will never know, read it and typed an
|
||||
answer and gave it to their own modem.
|
||||
|
||||
nobody was paid for any leg of that. nobody was in charge of it. it worked
|
||||
for fifteen years.`,
|
||||
},
|
||||
|
||||
'oneline': {
|
||||
title: 'the phone bill',
|
||||
from: 'why it was always engaged',
|
||||
body: `a second line was forty dollars a month before a single call.
|
||||
|
||||
that is the entire technical explanation for the busy signal, for the
|
||||
ratios, for the time limits, for the fact that you could want to get in
|
||||
somewhere. the scarcity that made all of it feel like somewhere was a
|
||||
scarcity of one household phone line in one man's spare room.`,
|
||||
},
|
||||
|
||||
'read-out': {
|
||||
title: 'somebody read it out to her',
|
||||
from: 'the girl from 2111',
|
||||
body: `she could not see the ANSI. she never could.
|
||||
|
||||
somebody read it out to her once — all of it, the whole login screen, every
|
||||
block character, colour by colour — and it took eleven minutes.
|
||||
|
||||
she wanted somebody to know that happened.`,
|
||||
},
|
||||
|
||||
'ratio': {
|
||||
title: 'adjusted to 1:1',
|
||||
from: 'ratio_cop, who was never actually angry',
|
||||
body: `he said he would and you did it anyway and he did.
|
||||
|
||||
it was a job somebody had to do on a board and he liked having a job on
|
||||
a board. that is most of what the ratio was ever for.`,
|
||||
},
|
||||
|
||||
'shell': {
|
||||
title: 'where im going',
|
||||
from: 'gh0st, on his way out',
|
||||
body: `a shell account at the university. eighteen dollars a month. on all the
|
||||
time. no ratio, no time limit, nobody waiting for the line.
|
||||
|
||||
"it is better. it is so much better. i hate it."`,
|
||||
},
|
||||
|
||||
'thenumber': {
|
||||
title: 'just the number',
|
||||
from: 'anonymous, last day',
|
||||
body: `5551212
|
||||
|
||||
no subject. no body. nothing else in the message at all.
|
||||
|
||||
it is the board's number. they posted the board's number, on the board, on
|
||||
the last day, to nobody.`,
|
||||
},
|
||||
|
||||
'echo': {
|
||||
title: 'found this place',
|
||||
from: 'a new user, two days too late',
|
||||
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."`,
|
||||
},
|
||||
|
||||
'tandem-last': {
|
||||
title: 'i said do not say anything sad',
|
||||
from: 'TANDEM',
|
||||
body: `"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."`,
|
||||
},
|
||||
|
||||
// ── hop 45–50, the handshake ───────────────────────────────────────────
|
||||
|
||||
'dtmf': {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user