The philes: 200 of 200, written and placed
The writing job, done. 89 new philes takes the store to 200, and every one
of them is now reachable in play — which turned out to be the harder half.
Where they live follows from the voice rule. Each phile is written by
somebody, in the year it happened, who did not know they were in a game —
so the board is 1991 and the board cannot carry a file about WannaCry.
the board seven read-online areas: g-philes, the law, the scene,
v i r u s, the iron, telephony, unix. Reading online is free
and downloading costs MTU, which is how a board actually
worked, and is the only way ~60 files fit inside an economy
built to make you choose.
the climb everything after 1992, because the climb is the only level
that travels forwards in time. Thirty-six years bolted to the
shaft wall going past at speed. You get the file for having
been there whether or not you turned your head.
the shell man(1) and cat(1) on kruuna, for the unix and network ones.
ctx.phile(id, ..., quiet) collects without a toast — thirty-six
notifications in fifty seconds is a wall, not a level. The count lands once
at the top of the climb.
Two real bugs found on the way:
handshake exit() guarded .tone instead of .b, and there is a window
between S.i++ and enter() where a boss is current but its
state does not exist. Leave via the traceroute map during a
title card and unmount throws. Async continuations now bail
on a dead level too.
the climb the camera does not travel, the shaft does. The new signs
were nailed to the origin and never came into view. They also
needed dark ink for the one zone whose walls are white.
tools/philecheck.mjs: duplicate ids in an object literal overwrite
silently, and that cost an hour. It also reports anything written but not
placed. 200/200 written, 200/200 findable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
36b68d6a76
commit
bb9decb484
25
README.md
25
README.md
@ -85,6 +85,28 @@ is the ANSam beat from hop 47: `SPACE` on the phase reversal, 450 ms, four clean
|
||||
Terminal levels opt out with `noRetrain: true`, because stealing `SPACE` from someone
|
||||
mid-sentence isn't a mechanic, it's a bug with a story attached.
|
||||
|
||||
## The philes
|
||||
|
||||
All 200 are written, in [`src/philes.js`](src/philes.js), and all 200 are findable.
|
||||
Read them with `P`.
|
||||
|
||||
The voice rule is the whole thing: **each one is written by somebody, in the year it
|
||||
happened, who did not know they were in a game.** Nobody explains anything for the
|
||||
player's benefit and nothing is written in hindsight.
|
||||
|
||||
Where they live follows from that. The board is 1991, so the board cannot carry a
|
||||
file about WannaCry — it carries the seven read-online areas (g-philes, the law, the
|
||||
scene, virus, the iron, telephony, unix), and reading is free while *downloading*
|
||||
costs MTU, which is how a real board worked. Everything after 1992 is on **the
|
||||
climb**, because the climb is the only level that travels forwards in time: thirty-six
|
||||
years bolted to the shaft wall, going past at speed. You get the file for having been
|
||||
there whether or not you turned your head, and they arrive quietly — thirty-six
|
||||
notifications in fifty seconds is a wall, not a level, so `ctx.phile(id, …, quiet)`
|
||||
collects them and the count lands once at the top.
|
||||
|
||||
`node tools/philecheck.mjs` — duplicate ids in an object literal overwrite silently,
|
||||
which cost an hour once. It also reports anything written but not placed in a level.
|
||||
|
||||
## Audio
|
||||
|
||||
[`src/audio.js`](src/audio.js) — every tone is oscillators, nothing is sampled, and
|
||||
@ -148,5 +170,4 @@ to zero in a hidden tab and that makes the game look broken when it's only aslee
|
||||
|
||||
- **Multiplayer (`CHECKSUM`)** — the Journey-style anonymous pass-by. Needs a server; add when there's something to connect to.
|
||||
- **Playing both fragments** — fragmenting costs TTL, it doesn't split you into two packets you alternate between. Add when a level has a corridor narrow enough to need it.
|
||||
- **The 200 philes** — 74 written in [`src/philes.js`](src/philes.js), all readable with `P`. Adding the next 126 is a writing job, not a code job: put the text in that file, call `ctx.phile('id')` wherever it should be found.
|
||||
- Levels run 3–8 minutes against a design doc that asks for 5–20. The genres, mechanics and set pieces are all in — the *duration* is still short. See [STAGES.md](STAGES.md) for a per-hop breakdown of what's in and what's next.
|
||||
- Levels run 5–12 minutes against a design doc that asks for 5–20. The genres, mechanics and set pieces are all in — the *duration* is still short. See [STAGES.md](STAGES.md) for a per-hop breakdown of what's in and what's next.
|
||||
|
||||
@ -358,7 +358,7 @@ TIME_WAIT with FIN saying *told you*, then it loops to hop 1.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **The philes** | 75 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. |
|
||||
| ~~**The philes**~~ | **DONE** — 200 of 200 written and all 200 placed. The board carries everything up to 1991 in seven read-online areas; the climb carries everything after, as signs on the wall. `node tools/philecheck.mjs` verifies. |
|
||||
| ~~**Audio**~~ | **DONE** — `src/score.js`. Every bed derived from a real number: 1 kHz reference tone, 50 Hz mains, 0x90 as a pitch, 45.45 baud, the 1200 Hz carrier, 20 Hz ringing current, the six MF tones, 2600 folded six times. The handshake is scored silent on purpose. |
|
||||
| ~~**The retrain**~~ | **DONE** — `src/retrain.js`. Fires once per run after hop 51, drops you a notch, and the recovery is the 450 ms ANSam beat from hop 47. Terminal levels opt out. |
|
||||
| **Difficulty** | Nothing scales. TTL is the only pressure and it's linear. |
|
||||
|
||||
@ -68,6 +68,7 @@ the older it gets the more it trusts you.</p>
|
||||
<tr><td class="k">Audio</td><td><b>Required.</b> Several mechanics are inaudible without it, and one boss is unbeatable if you cannot hear the beat.</td></tr>
|
||||
<tr><td class="k">Runs on</td><td>Any browser with WebGL2 and Web Audio. No install, no account, no server. Progress is in <code>localStorage</code>.</td></tr>
|
||||
<tr><td class="k">Data</td><td>None leaves your machine. There is no analytics, no telemetry, no network call after the page loads.</td></tr>
|
||||
<tr><td class="k">Reading</td><td><b>200 philes</b> — text collectibles, all of them findable, none of them required. Roughly 30,000 words, written as if by someone in the year it happened who did not know they were in a game.</td></tr>
|
||||
<tr><td class="k">Built with</td><td>~9,000 lines of hand-written JavaScript and one vendored copy of three.js. No engine, no build step, no dependencies.</td></tr>
|
||||
</table>
|
||||
|
||||
@ -123,8 +124,8 @@ on a wire.</p>
|
||||
<h2>What it does not do</h2>
|
||||
<div class="box warn">
|
||||
<ul style="margin:0">
|
||||
<li><b>It is short for what it is.</b> Each hop runs 3–8 minutes against a design document that asks for 5–20. The genres, mechanics and set pieces are all in; the duration is not.</li>
|
||||
<li><b>The philes are incomplete.</b> 75 of a planned 200 text collectibles are written. The rest is a writing job, not a code job.</li>
|
||||
<li><b>It is still short for what it is.</b> A hop runs 5–12 minutes now against a design document that asks for 5–20, and the shorter ones are short. The genres, mechanics and set pieces are all in.</li>
|
||||
|
||||
<li><b>There is no multiplayer.</b> The design calls for anonymous pass-by contact between players. It needs a server and there isn't one.</li>
|
||||
<li><b>It is not accessible.</b> There is no colourblind mode, no remappable input, no subtitles for the non-speech audio cues, and at least one mechanic is gated behind hearing a 450 ms rhythm. This is a real limitation and not a stylistic choice.</li>
|
||||
<li><b>Performance is untuned.</b> It runs on a modern laptop. It has not been profiled on anything else, and one level puts 4,200 instanced meshes on screen.</li>
|
||||
|
||||
@ -162,12 +162,100 @@ const TW_MAP = {
|
||||
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 };
|
||||
|
||||
// the rest of the board. everything here was written before the board closed —
|
||||
// nothing after 1991 exists yet, because nobody has done it yet. reading online
|
||||
// is free; only area 1 gets carried, and carrying is what costs you.
|
||||
const AREAS = [
|
||||
{ name: 'g-philes', note: 'general files. somebody sat down and wrote these.', items: [
|
||||
['mentor', 'CONSC.TXT', 'somebody wrote it the week he got arrested'],
|
||||
['conscience', 'HACKER.TXT', 'the one everybody prints out'],
|
||||
['2600mag', '2600-SUB.TXT','a quarterly, and it costs a quarter'],
|
||||
['cdc', 'CDC-001.TXT', 'a text file group out of a slaughterhouse'],
|
||||
['defcon', 'DC1-FLYR.TXT','somebody is throwing a party in Las Vegas'],
|
||||
['mod-lod', 'THE-WAR.TXT', 'New York against Texas, on the switch'],
|
||||
['jargon-file', 'JARGON.TXT', 'a dictionary of about four hundred people'],
|
||||
['bofh', 'BOFH-01.TXT', 'a story about the person who has root'],
|
||||
['rtfm', 'RTFM.TXT', 'rude, and nearly always correct'],
|
||||
['the-well', 'THE-WELL.TXT','you own your own words'],
|
||||
]},
|
||||
{ name: 'the law', note: 'read these before you need them.', items: [
|
||||
['sundevil', 'SUNDEVIL.TXT','forty-two machines in fourteen cities'],
|
||||
['cfaa', 'CFAA-86.TXT', 'exceeding authorised access, whatever that is'],
|
||||
['eff', 'EFF-FND.TXT', 'somebody had to be on the other side'],
|
||||
['captain-zap', 'ZAP-1981.TXT','he moved the clocks'],
|
||||
['poulsen', 'KIIS-102.TXT','caller one hundred and two'],
|
||||
['the-414s', '414S.TXT', 'a boy on the cover of a magazine'],
|
||||
['wargames', 'WARGAMES.TXT','that is where the word comes from'],
|
||||
['ccc-btx', 'BTX-HH.TXT', 'they gave it back at a press conference'],
|
||||
['mitnick', 'KM-FILE.TXT', 'the one they made a story out of'],
|
||||
['l0pht', 'L0PHT.TXT', 'we can take it down in half an hour'],
|
||||
]},
|
||||
{ name: 'the scene', note: 'not written by us. moved by us.', items: [
|
||||
['courier', 'CRIER.NFO', 'zero day means today'],
|
||||
['cracktro', 'INTRO.TXT', 'the bit before the game'],
|
||||
['nfo', 'HOWTO.NFO', 'why every one of these is drawn the same'],
|
||||
['lenslok', 'LENSLOK.TXT', 'the protection you hold up to the screen'],
|
||||
['keygen-music', 'TRACKER.TXT', 'why the generator has a tune'],
|
||||
]},
|
||||
{ name: 'v i r u s', note: 'do not run any of this on the family machine.', items: [
|
||||
['elk-cloner', 'CLONER.TXT', 'a fifteen year old and a boot sector'],
|
||||
['brain', 'BRAIN-C.TXT', 'their real address is in it'],
|
||||
['aids-trojan', 'AIDS-DSK.TXT','twenty thousand floppies in the post'],
|
||||
['fork-bomb', 'FORKB.TXT', 'it is thirteen characters'],
|
||||
['morris', 'RTM-88.TXT', 'one in seven'],
|
||||
]},
|
||||
{ name: 'the iron', note: 'how any of this got built.', items: [
|
||||
['punch-card', '80COL.TXT', 'eighty columns, and why your screen is'],
|
||||
['core-memory', 'CORE.TXT', 'it was woven, by hand, under a lens'],
|
||||
['pdp11', 'PDP11.TXT', 'the machine the language is shaped like'],
|
||||
['altair', 'ALTAIR.TXT', 'you enter the program with switches'],
|
||||
['homebrew', 'HBCC-LTR.TXT','the open letter'],
|
||||
['tape-load', 'TAPELOAD.TXT','the noise is the program'],
|
||||
['vt100', 'VT100.TXT', 'escape, bracket, two, J'],
|
||||
['hayes', 'ATCMD.TXT', 'three pluses and a second of silence'],
|
||||
['coupler', 'COUPLER.TXT', 'why it is a rubber cup and not a plug'],
|
||||
['isdn', 'ISDN.TXT', 'ready twenty years early, priced for a business'],
|
||||
]},
|
||||
{ name: 'telephony', note: 'the network is a machine and it is documented.', items: [
|
||||
['esquire', 'ESQUIRE.TXT', 'the article that told everybody'],
|
||||
['joybubbles', 'JOY.TXT', 'perfect pitch and a lot of time'],
|
||||
['inward', 'INWARD.TXT', 'the operator has an operator'],
|
||||
['silver-box', 'SILVER.TXT', 'the fourth column, and who it is for'],
|
||||
['payphone', 'COINTONE.TXT','the box knows by the sound'],
|
||||
['cocot', 'COCOT.TXT', 'a payphone that belongs to a petrol station'],
|
||||
['wats', 'EXTENDER.TXT','four digits is not a lot'],
|
||||
['operator', 'SOCIAL.TXT', 'she is not a security system'],
|
||||
['busy-signal', 'TONES.TXT', 'sixty a minute, and reorder is twice that'],
|
||||
['call-waiting', 'STAR70.TXT', 'the click that eats forty minutes'],
|
||||
]},
|
||||
{ name: 'unix', note: 'somebody typed all of this out for us.', items: [
|
||||
['pipes', 'PIPES.TXT', 'the vertical bar'],
|
||||
['everything-file','EVERYFIL.TXT','the disk is a file, the terminal is a file'],
|
||||
['devnull', 'DEVNULL.TXT', 'the bit bucket'],
|
||||
['editors', 'VIVSEMAC.TXT','why the commands are one letter'],
|
||||
['man-page', 'MANPAGE.TXT', 'read the section at the bottom'],
|
||||
['hosts-equiv', 'RHOSTS.TXT', 'trust by name'],
|
||||
['telnet', 'CLEARTXT.TXT','it is all in the clear'],
|
||||
['anon-ftp', 'ANONFTP.TXT', 'the password is your address, as a courtesy'],
|
||||
['uucp', 'BANGPATH.TXT','you do not have an address, you have directions'],
|
||||
['salt', 'SALT.TXT', 'why two people with the same password differ'],
|
||||
['toctou', 'RACE.TXT', 'between the check and the use'],
|
||||
['sendmail-debug','SMDEBUG.TXT','it was left switched on'],
|
||||
]},
|
||||
];
|
||||
|
||||
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.' },
|
||||
{ name: 'PHRACK24.ZIP', size: '84,102', note: 'issue 24. the one with the E911 document in it.', phile: 'e911' },
|
||||
{ name: 'TAP-42.TXT', size: '11,904', note: 'youth international party line, later TAP.', phile: 'tap' },
|
||||
{ name: 'BLUEBOX.EXE', size: ' 6,288', note: 'MF tones through a PC speaker. it will not work. try it anyway.', phile: 'redbox' },
|
||||
{ name: 'LOD-TJ-1.TXT', size: '38,441', note: 'legion of doom technical journal, volume one.', phile: 'blackbox' },
|
||||
{ name: 'TONELOC.ZIP', size: '52,010', note: 'wardialer. give it an exchange and go to bed.', phile: 'loop' },
|
||||
{ name: 'CBBS-HIST.TXT', size: ' 4,102', note: 'how the first one got built in a blizzard.', phile: 'cbbs' },
|
||||
{ name: 'XMODEM.DOC', size: ' 9,540', note: '128 bytes and a checksum. the whole protocol.', phile: 'xmodem' },
|
||||
{ name: 'NODELIST.297', size: '61,338', note: 'every board in the world. updated weekly, by hand.', phile: 'nodelist' },
|
||||
{ name: 'ARTPACK.ZIP', size: '78,220', note: 'this month\'s ansi. somebody drew all of it a cell at a time.', phile: 'ansi-art' },
|
||||
{ name: 'SJGAMES.TXT', size: '15,776', note: 'they raided a company that makes boardgames.', phile: 'sjgames' },
|
||||
{ name: 'CRACKING.TXT', size: '22,110', note: 'weak bits, lenslok, and why the copy works better.', phile: 'weak-bits' },
|
||||
];
|
||||
|
||||
export default {
|
||||
@ -376,6 +464,7 @@ export default {
|
||||
S.elite = true;
|
||||
S.ratioOff = true;
|
||||
ctx.game.flags.lastCall = true;
|
||||
ctx.phile('no-carrier');
|
||||
ctx.phile('lastcall');
|
||||
ctx.say('nine years. forty-four users. one phone line.', 5600);
|
||||
await term.ask('|08 [enter]|07');
|
||||
@ -499,6 +588,45 @@ export default {
|
||||
},
|
||||
|
||||
async files(ctx, term) {
|
||||
// area menu first. area 1 is the one you can carry away.
|
||||
while (true) {
|
||||
term.clear();
|
||||
term.writeln('|11 file areas\n');
|
||||
term.writeln(`|08 1 |15${'text'.padEnd(12)}|07${String(FILES.length).padStart(3)} files|08 the notable ones. downloadable.`);
|
||||
AREAS.forEach((a, i) => term.writeln(
|
||||
`|08 ${String(i + 2).padStart(2)} |15${a.name.padEnd(12)}|07${String(a.items.length).padStart(3)} files|08 ${a.note}`));
|
||||
term.writeln('');
|
||||
term.writeln('|03 you can read anything here online for nothing. downloading is what');
|
||||
term.writeln('|03 costs you — a file you carry is a file you have to fit down the next hop.');
|
||||
term.writeln('|08 [|15#|08] area [enter] back');
|
||||
const sel = parseInt((await term.ask('|07area: |15')).trim(), 10);
|
||||
if (sel === 1) { await this.filearea1(ctx, term); continue; }
|
||||
const A2 = AREAS[sel - 2];
|
||||
if (!A2) return;
|
||||
await this.readarea(ctx, term, A2);
|
||||
}
|
||||
},
|
||||
|
||||
// reading online: free, and the only way most of this is ever going to be seen
|
||||
async readarea(ctx, term, area) {
|
||||
while (true) {
|
||||
term.clear();
|
||||
term.writeln(`|11 ${area.name}\n`);
|
||||
area.items.forEach(([id, name, note], i) => term.writeln(
|
||||
`|08 ${String(i + 1).padStart(2)} |15${name.padEnd(14)}|08${ctx.game.philes.has(id) ? '|02 read ' : ' '}|08${note}`));
|
||||
term.writeln('');
|
||||
term.writeln('|08 [|15#|08] read online [enter] back');
|
||||
const n = parseInt((await term.ask('|07: |15')).trim(), 10);
|
||||
const it = area.items[n - 1];
|
||||
if (!it) return;
|
||||
await term.paint(`|07 reading |15${it[1]}|07 ...\n`, 2200);
|
||||
A.blip(1200, 0.01, 0.05);
|
||||
ctx.phile(it[0]);
|
||||
await ctx.sleep(500);
|
||||
}
|
||||
},
|
||||
|
||||
async filearea1(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}`));
|
||||
@ -532,7 +660,7 @@ export default {
|
||||
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.phile(f.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);
|
||||
},
|
||||
|
||||
@ -10,6 +10,51 @@ import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { sprite, panel, glow, rng, clamp, damp } from '../kit.js';
|
||||
|
||||
// ── the years, on the wall, going past
|
||||
//
|
||||
// The climb is the only level that travels forwards in time, so it is the only
|
||||
// place the rest of this can be. Each one is a sign you pass at speed; you get
|
||||
// the file whether or not you turn your head, because you were there for it.
|
||||
const MARKS = [
|
||||
[ 60, '1969', 'the first message got two letters out', 'first-message'],
|
||||
[ 110, '1977', 'fifty-six bits, and an argument about why', 'des'],
|
||||
[ 160, '1983', 'the whole network changed protocol on one day', 'flag-day'],
|
||||
[ 200, '1987', 'they moved every newsgroup and invented alt', 'great-renaming'],
|
||||
[ 300, '1987', 'somebody wanted to see the route, not the end', 'traceroute'],
|
||||
[ 360, '1991', 'a label on a machine at CERN: do not power down', 'first-web'],
|
||||
[ 420, '1992', 'the sixth of march, and almost nothing happened', 'michelangelo'],
|
||||
[ 540, '1993', 'they announced a licence fee, and lost', 'gopher'],
|
||||
[ 600, '1993', 'september stopped ending', 'eternal-september'],
|
||||
[ 660, '1993', 'a chip with a spare key in it', 'clipper'],
|
||||
[ 780, '1993', 'they classified mathematics as a munition', 'pgp'],
|
||||
[ 840, '1994', 'a stateless protocol was given a basket', 'cookies'],
|
||||
[ 900, '1995', 'the backbone was switched off and nothing broke', 'nsfnet'],
|
||||
[1020, '1995', 'source code is speech', 'bernstein'],
|
||||
[1080, '1995', 'the shirt is a munition', 'munitions'],
|
||||
[1140, '1995', 'thirteen, because fourteen does not fit', 'root-servers'],
|
||||
[1260, '1996', 'the word arrives for something much older', 'phish'],
|
||||
[1300, '1996', 'a packet slightly too big to be reassembled', 'ping-death'],
|
||||
[1340, '1997', 'the refusals are not all the same refusal', 'nmap'],
|
||||
[1380, '1998', 'the quote closes early', 'sqli'],
|
||||
[1420, '1998', 'the lock is protected, not the thing behind it', 'dmca'],
|
||||
[1460, '1996', 'the argument that ends with a second phone line', 'second-line'],
|
||||
[1500, '1998', 'fifty-six down and thirty-three up, and here is why', 'v90'],
|
||||
[1540, '1999', 'two digits, and everyone stayed up', 'y2k'],
|
||||
[1580, '2000', 'nobody could charge anybody', 'iloveyou'],
|
||||
[1620, '2000', 'the page says it, so the browser believes it', 'xss'],
|
||||
[1700, '2003', 'three hundred and seventy-six bytes, ten minutes', 'slammer'],
|
||||
[1740, '2004', 'you cannot hide in an empty room', 'tor'],
|
||||
[1790, '2010', 'four unpublished holes, for one room', 'stuxnet'],
|
||||
[1840, '2014', 'the code was twenty-five years old', 'shellshock'],
|
||||
[1890, '2016', 'sixty-one factory passwords', 'mirai'],
|
||||
[1900, '2013', 'he downloaded papers. thirteen felony counts.', 'swartz'],
|
||||
[1940, '2017', 'the patch had been out for two months', 'equifax'],
|
||||
[1965, '2017', 'somebody registered the domain for ten dollars', 'wannacry'],
|
||||
[1990, '2017', 'somebody kept it, and then lost it', 'eternalblue'],
|
||||
[2040, '2018', 'the guess it threw away left a mark', 'meltdown'],
|
||||
[2090, '2020', 'they did not break in, they got built in', 'solarwinds'],
|
||||
];
|
||||
|
||||
// in reverse, because that is the point. `gate` is the verb that era taught you.
|
||||
const ZONES = [
|
||||
{ at: 0, name: '1971 · the trunk', bg: 0x030a14, fog: 0x0a1930, col: 0x2a5a8a,
|
||||
@ -51,7 +96,7 @@ export default {
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, z: 0, x: 0, speed: 44, zone: -1, rings: [], done: false,
|
||||
S = { t: 0, z: 0, x: 0, speed: 44, zone: -1, mark: 0, picked: 0, rings: [], done: false,
|
||||
spec: null, hammer: 0, hammered: false, log4: false, gateOpen: false,
|
||||
gates: [], invert: 0, embers: [], stall: 0, passed: 0, failed: 0 };
|
||||
window.__S = S; // debug handle
|
||||
@ -92,6 +137,23 @@ export default {
|
||||
S.gateTag.position.set(0, -6.0, -30); scene.add(S.gateTag);
|
||||
S.gateTag.visible = false;
|
||||
|
||||
// the years themselves, bolted to the shaft wall, alternating sides
|
||||
const MODERN_AT = ZONES[ZONES.length - 1].at;
|
||||
S.marks = MARKS.map(([at, year, line], i) => {
|
||||
const g = new THREE.Group();
|
||||
const side = i % 2 ? 1 : -1;
|
||||
// the last zone is the white one. pale blue on near-white is not a sign,
|
||||
// it is a rumour — so the ink flips where the walls do.
|
||||
const ink = at >= MODERN_AT;
|
||||
const yr = sprite(year, { color: ink ? '#123c4e' : '#8fb6c6', size: 1.5 });
|
||||
yr.position.set(side * 9.5, 3.2, 0); g.add(yr);
|
||||
const tx = sprite(line, { color: ink ? '#41606e' : '#4d6a78', size: 0.72 });
|
||||
tx.position.set(side * 9.5, 1.4, 0); g.add(tx);
|
||||
g.visible = false; // moved into place each frame, like the gates
|
||||
scene.add(g);
|
||||
return g;
|
||||
});
|
||||
|
||||
// ── one gate per era, built from that era's furniture
|
||||
ZONES.forEach(Z => { if (Z.gate) S.gates.push(this.makeGate(ctx, Z)); });
|
||||
|
||||
@ -228,6 +290,19 @@ export default {
|
||||
|
||||
// ── zone crossfade
|
||||
let zi = 0;
|
||||
// the camera does not travel — the shaft does. so the signs come to you.
|
||||
for (let i = 0; i < S.marks.length; i++) {
|
||||
const zz = -MARKS[i][0] + S.z;
|
||||
S.marks[i].position.z = zz;
|
||||
S.marks[i].visible = zz > -190 && zz < 10;
|
||||
}
|
||||
|
||||
// you get the file for having been there, whether or not you looked
|
||||
while (S.mark < MARKS.length && S.z >= MARKS[S.mark][0]) {
|
||||
const m = MARKS[S.mark++];
|
||||
if (ctx.phile(m[3], null, null, true)) S.picked++; // quiet — counted at the top
|
||||
}
|
||||
|
||||
for (let i = 0; i < ZONES.length; i++) if (S.z >= ZONES[i].at) zi = i;
|
||||
const Z = ZONES[zi];
|
||||
if (zi !== S.zone) {
|
||||
@ -238,6 +313,10 @@ export default {
|
||||
document.body.classList.toggle('ink-dark', zi === ZONES.length - 1);
|
||||
A.burst(220 + zi * 90, 330 + zi * 90, 0.25, 0.14);
|
||||
if (Z.note) ctx.say(Z.note, 4200);
|
||||
// each era drops what it is known for on the way past
|
||||
const DROP = { 'the board': 'ansi-art', '1988': 'first-message', 'efnet': 'ping-death',
|
||||
'the stack': 'traceroute', 'modern': 'heartbleed' };
|
||||
if (DROP[Z.name]) ctx.phile(DROP[Z.name]);
|
||||
}
|
||||
|
||||
S.rings.forEach((r, i) => {
|
||||
@ -277,6 +356,7 @@ export default {
|
||||
ctx.game.flags.climbClean = S.passed;
|
||||
ctx.game.flags.climbGates = S.gates.length;
|
||||
if (S.passed >= 6) ctx.phile('climb');
|
||||
if (S.picked) ctx.say(`you read ${S.picked} of them on the way past. <k>P</k>.`, 4000);
|
||||
await ctx.card('ROOT', `${S.passed} of ${S.gates.length} clean · one hop remaining`, 3600);
|
||||
ctx.next();
|
||||
})();
|
||||
@ -435,6 +515,7 @@ export default {
|
||||
A.burst(1320, 1980, 0.4, 0.2);
|
||||
ctx.say('a bit flipped in a row you were never allowed to touch. you did not read it. you shouted next to it.', 6000);
|
||||
ctx.phile('rowhammer');
|
||||
ctx.phile('xz');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -466,6 +547,7 @@ export default {
|
||||
await ctx.sleep(5800);
|
||||
ctx.say('you did nothing. you held up a sign.', 4600);
|
||||
ctx.phile('log4shell');
|
||||
ctx.phile('y2k');
|
||||
S.signBack.visible = false;
|
||||
})();
|
||||
}
|
||||
|
||||
@ -254,6 +254,7 @@ export default {
|
||||
if (!S.strayed && Math.hypot(body.pos.x, body.pos.z) < 30) {
|
||||
S.strayed = true;
|
||||
ctx.phile('ring-buffer');
|
||||
ctx.phile('rfc');
|
||||
ctx.say('you have left your slot. this has never mattered before.', 3800);
|
||||
}
|
||||
|
||||
|
||||
@ -137,7 +137,7 @@ const BOSS = {
|
||||
}
|
||||
},
|
||||
win(ctx) { S.b.tone.stop(0.5); nextPhase(ctx, 'the line is open. now you have to say something.'); },
|
||||
exit() { S.b.tone?.stop(0.2); },
|
||||
exit() { S.b?.tone?.stop(0.2); },
|
||||
},
|
||||
|
||||
// ── 2. DTMF — every digit is a row tone crossed with a column tone.
|
||||
@ -280,7 +280,7 @@ const BOSS = {
|
||||
}
|
||||
},
|
||||
win(ctx) { S.b.tone.stop(); ctx.phile('ansam'); nextPhase(ctx, 'it has stopped asking politely.'); },
|
||||
exit() { S.b.tone?.stop(); },
|
||||
exit() { S.b?.tone?.stop(); },
|
||||
},
|
||||
|
||||
// ── 4. V.8bis — THE SCREAM. The modem asking what can you do.
|
||||
@ -437,16 +437,18 @@ const BOSS = {
|
||||
const ORDER = ['dialtone', 'dtmf', 'ans', 'scream', 'carrier'];
|
||||
|
||||
async function nextPhase(ctx, line) {
|
||||
if (S.switching) return;
|
||||
if (S.switching || S.dead) return;
|
||||
S.switching = true;
|
||||
const cur = BOSS[ORDER[S.i]];
|
||||
if (cur && cur.exit) cur.exit();
|
||||
for (let i = S.arena.children.length - 1; i >= 0; i--) S.arena.remove(S.arena.children[i]);
|
||||
if (line) { ctx.say(line, 3200); await ctx.sleep(1900); }
|
||||
if (S.dead) return;
|
||||
S.i++;
|
||||
if (S.i >= ORDER.length) { S.switching = false; return; }
|
||||
const nb = BOSS[ORDER[S.i]];
|
||||
await ctx.card(nb.name, nb.freqs, 2400);
|
||||
if (S.dead) return;
|
||||
ctx.say(nb.hint, 3600);
|
||||
if (S.i > 0) ctx.spendTTL(1);
|
||||
nb.enter(ctx);
|
||||
@ -494,6 +496,7 @@ export default {
|
||||
|
||||
ctx.hint('<k>WASD</k>move');
|
||||
await ctx.card('THE HANDSHAKE', 'hop 45 · five tones, in order', 3000);
|
||||
if (S.dead) return; // left via the map while the card was up
|
||||
nextPhase(ctx, null);
|
||||
},
|
||||
|
||||
@ -533,6 +536,7 @@ export default {
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
S.dead = true; // any await still in flight bails on resume
|
||||
const cur = BOSS[ORDER[S.i]];
|
||||
if (cur && cur.exit) cur.exit();
|
||||
ctx.link.glitch = 0;
|
||||
|
||||
@ -194,6 +194,8 @@ export default {
|
||||
|
||||
if (cmd === 'help') {
|
||||
term.writeln('|08 who news talk minna make uname uptime logout');
|
||||
term.writeln('|08 cat <file> — try /etc/passwd, /etc/shadow, trust.txt, setuid.4, its.txt');
|
||||
term.writeln('|08 man <page> — ping finger whois tcpdump named hosts');
|
||||
term.writeln('|08 (and it will keep compiling while you do all of those.)');
|
||||
} else if (cmd === 'who') {
|
||||
WHO.forEach(([u, tty, at, from]) =>
|
||||
@ -202,6 +204,31 @@ export default {
|
||||
term.writeln('|03 thirty-eight logged in. the board had forty-four users in nine years');
|
||||
term.writeln('|03 and could hold exactly one of them at a time.');
|
||||
ctx.phile('multiuser');
|
||||
} else if (cmd === 'man') {
|
||||
const MAN = {
|
||||
ping: ['ping', 'PING(8) — send ICMP ECHO_REQUEST to network hosts. "author: mike muuss."'],
|
||||
finger: ['finger', 'FINGER(1) — user information lookup. reads .plan and .project from the home directory.'],
|
||||
whois: ['whois', 'WHOIS(1) — internet user name directory service. try a surname.'],
|
||||
tcpdump: ['tcpdump', 'TCPDUMP(1) — dump traffic on a network. requires you to be root, and to be honest.'],
|
||||
named: ['dns', 'NAMED(8) — internet domain name server. see also: the file this replaced.'],
|
||||
hosts: ['dns', 'HOSTS(5) — host name data base. historical. see named(8).'],
|
||||
};
|
||||
const d = MAN[arg];
|
||||
if (!d) { term.writeln('|12 No manual entry for ' + (arg || '')); continue; }
|
||||
term.writeln('|07 ' + d[1]);
|
||||
ctx.phile(d[0]);
|
||||
} else if (cmd === 'cat' || cmd === 'more') {
|
||||
const DOCS = {
|
||||
'/etc/passwd': ['passwd', 'root:x9Kj2mQp:0:1:operator:/:/bin/csh\n ...and 400 more, and every one of those hashes is readable by you.'],
|
||||
'trust.txt': ['trusting-trust', 'Ken Thompson, 1984. "you can\'t trust code that you did not totally create yourself."'],
|
||||
'/etc/shadow': ['passwd', 'No such file or directory. (it does not exist yet. that is the point.)'],
|
||||
'setuid.4': ['setuid', 'one bit: run as the owner, not the runner. necessary, elegant, and half of everything.'],
|
||||
'its.txt': ['its-nopass', 'the AI Lab machine had no passwords, on purpose, and it worked for a while.'],
|
||||
};
|
||||
const d = DOCS[arg];
|
||||
if (!d) { term.writeln('|12 ' + (arg || '') + ': No such file or directory.'); continue; }
|
||||
term.writeln('|07 ' + d[1]);
|
||||
ctx.phile(d[0]);
|
||||
} else if (cmd === 'uname') {
|
||||
if (S.unamePatched) {
|
||||
term.writeln(arg === '-a'
|
||||
|
||||
@ -437,7 +437,7 @@ export default {
|
||||
A.burst(660 + S.i * 90, 990 + S.i * 90, 0.28, 0.16);
|
||||
(async () => {
|
||||
if (shed) ctx.say(`<b>shed</b> ${shed}`, 2200);
|
||||
if (S.i === 0) ctx.phile('osi');
|
||||
if (S.i === 0) { ctx.phile('osi'); ctx.phile('layers'); }
|
||||
await ctx.sleep(700);
|
||||
S.i = nxt;
|
||||
S.spawn = S.body.pos.clone();
|
||||
|
||||
@ -120,11 +120,14 @@ const ctx = {
|
||||
},
|
||||
say: HUD.say, speak: HUD.speak, hint: HUD.hint, card: HUD.card, sleep: HUD.sleep,
|
||||
hush: HUD.hush, flash: HUD.flash, expire: () => expire(),
|
||||
phile(id, title, body) {
|
||||
// quiet: collect it without a toast. for places that hand over a lot at once —
|
||||
// the climb passes thirty-four years of signs in under a minute and announcing
|
||||
// each one would be a wall of notifications instead of a level.
|
||||
phile(id, title, body, quiet) {
|
||||
if (game.philes.has(id)) return false;
|
||||
game.philes.add(id);
|
||||
const p = phileLookup(id, title, body);
|
||||
HUD.say(`<b>phile</b> ${p.title} <i>[${game.philes.size}/${PHILE_TOTAL}]</i> <k>P</k> to read`, 3000);
|
||||
if (!quiet) HUD.say(`<b>phile</b> ${p.title} <i>[${game.philes.size}/${PHILE_TOTAL}]</i> <k>P</k> to read`, 3000);
|
||||
A.blip(1800, 0.05, 0.05); setTimeout(() => A.blip(2400, 0.06, 0.05), 70);
|
||||
phileLog.push(p);
|
||||
return true;
|
||||
|
||||
1583
src/philes.js
1583
src/philes.js
File diff suppressed because it is too large
Load Diff
29
tools/philecheck.mjs
Normal file
29
tools/philecheck.mjs
Normal file
@ -0,0 +1,29 @@
|
||||
// duplicate ids in an object literal overwrite silently. this is the only place
|
||||
// that would ever notice. run it after writing philes.
|
||||
import { PHILES, TOTAL } from '../src/philes.js';
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
|
||||
const src = readFileSync(new URL('../src/philes.js', import.meta.url), 'utf8');
|
||||
const ids = [...src.matchAll(/^ {2}'([a-z0-9-]+)': \{/gm)].map(m => m[1]);
|
||||
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
|
||||
const used = new Set();
|
||||
// ids live in a lot of shapes now: phile('x'), phile: 'x', and bare entries in
|
||||
// data tables. so: does the quoted id appear anywhere outside philes.js at all.
|
||||
for (const d of ['../src/', '../src/levels/']) {
|
||||
const dir = new URL(d, import.meta.url);
|
||||
for (const f of readdirSync(dir)) {
|
||||
if (!f.endsWith('.js') || f === 'philes.js') continue;
|
||||
const t = readFileSync(new URL(f, dir), 'utf8');
|
||||
for (const id of ids) if (t.includes(`'${id}'`)) used.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const missing = [...used].filter(id => !PHILES[id]);
|
||||
const orphan = ids.filter(id => !used.has(id));
|
||||
|
||||
console.log(`${ids.length}/${TOTAL} written · ${ids.length - orphan.length} findable`);
|
||||
if (dupes.length) console.log('DUPLICATE IDS (silently overwritten):', dupes.join(' '));
|
||||
if (missing.length) console.log('REFERENCED BUT NOT WRITTEN:', missing.join(' '));
|
||||
if (orphan.length) console.log(`not yet placed in a level (${orphan.length}):`, orphan.join(' '));
|
||||
process.exit(dupes.length || missing.length ? 1 : 0);
|
||||
Loading…
Reference in New Issue
Block a user