diff --git a/src/levels/coldboot.js b/src/levels/coldboot.js index aefeff8..b60cfae 100644 --- a/src/levels/coldboot.js +++ b/src/levels/coldboot.js @@ -5,7 +5,7 @@ import * as THREE from '../../vendor/three.module.js'; import * as A from '../audio.js'; import * as In from '../input.js'; -import { Body, Look, World, rng, sprite, damp, clamp } from '../kit.js'; +import { Body, Look, World, rng, sprite, panel, glow, damp, clamp } from '../kit.js'; let body, look, ring, packets, head, tail, streaks, S = {}; @@ -15,7 +15,9 @@ export default { async mount(ctx) { const { scene, camera } = ctx; - S = { t: 0, phase: 'wake', dma: 0, spoke: 0, locked: false }; + S = { t: 0, phase: 'wake', dma: 0, spoke: 0, locked: false, + armed: false, readDesc: false, nbSaid: 0, nbCool: 0, tailWarned: false, tailNear: false }; + window.__S = S; scene.background = new THREE.Color(0xfbfdff); scene.fog = new THREE.Fog(0xfbfdff, 40, 300); @@ -102,10 +104,43 @@ export default { body.pos.set(0, 0, R - 4); look = new Look(); look.yaw = Math.PI / 2; // stand in your slot and look along the curve + S.body = body; S.look = look; // every other level keeps these on S; match it camera.near = 0.05; camera.far = 1200; camera.updateProjectionMatrix(); ctx.hint('CLICKlook around'); S.line = 0; + // ── your own descriptor, on the wall of your slot. The whole ending depends on + // the player knowing what a sequence number is, so it is written down where + // they are standing rather than narrated at them. + S.seq = 1 + Math.floor(rand() * 4294967); + const desc = + 'DESCRIPTOR nic0.tx[0341]\n\n' + + ` seq ${S.seq}\n` + + ' window 8192\n' + + ' flags SYN\n' + + ' ack —\n' + + ' ttl 64\n\n' + + ' this is all of you.'; + S.desc = panel(desc, { color: '#2a4a5e', size: 2.6, align: 'left' }); + const A0 = Math.PI / 2; // the angle you wake up at + S.desc.position.set(Math.cos(A0 + 0.055) * (R - 0.9), 1.6, Math.sin(A0 + 0.055) * (R - 0.9)); + S.desc.lookAt(0, 1.6, 0); + S.desc.visible = false; + scene.add(S.desc); + S.descAt = new THREE.Vector3(Math.cos(A0 + 0.055) * (R - 3), 0, Math.sin(A0 + 0.055) * (R - 3)); + + // ── the packet in the next slot. It has been sent before. It came back. + S.neighbour = new THREE.Group(); + const nb = new THREE.Mesh(new THREE.IcosahedronGeometry(0.46, 1), glow(0x9fd8ff, 1.2)); + S.neighbour.add(nb); S.nbCore = nb; + const nh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.7, 1), + new THREE.MeshBasicMaterial({ color: 0x6fb8e8, wireframe: true, transparent: true, opacity: 0.3 })); + S.neighbour.add(nh); S.nbHalo = nh; + S.nbAt = new THREE.Vector3(Math.cos(A0 - 0.075) * R, 0.9, Math.sin(A0 - 0.075) * R); + S.neighbour.position.copy(S.nbAt); + scene.add(S.neighbour); + S.nbSaid = 0; + // it starts by telling you what you are, because nothing else here will. S.script = [ 'you are a synchronise packet.', @@ -113,11 +148,24 @@ export default { 'are you there', 'nobody has ever answered it.', 'there are five hundred and twelve slots in this ring and you are in one of them.', - 'the tail pointer is coming round.', + 'the descriptor on the wall of your slot is all of you. E to read it.', ]; S.next = 3.0; }, + // The one in the next slot. It has been transmitted, and it came back, which is + // not supposed to happen, and it is the first hint of where this ends. + NEIGHBOUR: [ + 'you are new. you have got the flag still set.', + 'i had that. SYN, window eight thousand, off you go.', + 'i went out. eleven hops. i do not remember most of it.', + '...', + 'no. nobody answered. i came back on my own, which is not a thing that is
supposed to happen, and nobody has come to ask me about it.', + 'they put me back in the ring like a returned parcel.', + 'if it does answer — and it will not — you will not come back here.
you go into a socket and you stop being a packet at all.', + 'that is the good ending. i want to be clear that that is the good one.', + ], + update(dt, t, ctx) { S.t += dt; @@ -135,6 +183,10 @@ export default { packets.instanceMatrix.needsUpdate = true; } + if (S.neighbour) { + S.neighbour.position.y = 0.9 + Math.sin(S.t * 0.8) * 0.05; + S.nbHalo.rotation.y += dt * 0.4; + } const ha = S.t * 0.42, ta = S.t * 0.42 - 1.15; head.position.set(Math.cos(ha) * S.R, 0, Math.sin(ha) * S.R); tail.position.set(Math.cos(ta) * S.R, 0, Math.sin(ta) * S.R); @@ -153,7 +205,49 @@ export default { ctx.say(S.script[S.line], 3600); S.line++; S.next = S.t + 3.9; - if (S.line === S.script.length) S.dmaAt = S.t + 4.4; + // the DMA no longer fires on a timer — the tail pointer has to reach you + if (S.line === S.script.length) { S.armed = true; ctx.hint('WASDmove
Eread'); } + } + + // ── the descriptor + const dd = Math.hypot(body.pos.x - S.descAt.x, body.pos.z - S.descAt.z); + S.desc.visible = dd < 7; + if (dd < 3.4 && !S.readDesc && In.useHit()) { + S.readDesc = true; + A.blip(1800, 0.04, 0.05); + ctx.say(`sequence number ${S.seq}. window eight thousand one hundred and ninety two.
one flag set, and it is the one that means asking.`, 6200); + ctx.phile('descriptor'); + } + + // ── the neighbour + const nd = Math.hypot(body.pos.x - S.nbAt.x, body.pos.z - S.nbAt.z); + S.nbCore.material.color.setHex(nd < 3.2 ? 0xd8f0ff : 0x9fd8ff); + if (nd < 3.2 && !S.nbTalking) ctx.hint('Etalk'); + if (nd < 3.2 && In.useHit() && S.nbCool <= 0) { + S.nbCool = 0.5; + const L = this.NEIGHBOUR[Math.min(S.nbSaid, this.NEIGHBOUR.length - 1)]; + ctx.say(L, 4400, 'SYN'); + S.nbSaid++; + A.blip(420 + Math.random() * 90, 0.02, 0.02); + if (S.nbSaid === this.NEIGHBOUR.length) ctx.phile('came-back'); + } + S.nbCool = Math.max(0, (S.nbCool || 0) - dt); + + // ── the tail pointer. It has been coming round this whole time. Now it + // arrives, and the DMA is something that happened to you rather than + // something the level scheduled. + if (S.armed) { + const dTail = Math.hypot(body.pos.x - tail.position.x, body.pos.z - tail.position.z); + if (!S.tailWarned && dTail < 26) { + S.tailWarned = true; + ctx.say('the tail pointer is coming round.', 4000); + } + if (dTail < 22) { + // it drags the room toward it as it closes + ctx.link.glitch = Math.max(0, (22 - dTail) / 22) * 0.28; + if (!S.tailNear && dTail < 9) { S.tailNear = true; A.thud(60, 0.7, 0.3); ctx.say('it does not slow down for anybody.', 3000); } + } + if (dTail < 3.2 && !S.dmaAt) S.dmaAt = S.t; } // walk far enough out and the ring notices you are missing @@ -163,7 +257,7 @@ export default { ctx.say('you have left your slot. this has never mattered before.', 3800); } - if (S.dmaAt && S.t > S.dmaAt) { + if (S.dmaAt && S.t >= S.dmaAt) { S.phase = 'dma'; S.dma = 0; ctx.hint(''); ctx.hush?.(); diff --git a/src/levels/cuckoo.js b/src/levels/cuckoo.js index ed78474..9d80802 100644 --- a/src/levels/cuckoo.js +++ b/src/levels/cuckoo.js @@ -13,11 +13,16 @@ import * as In from '../input.js'; import { Term } from '../term.js'; const FS = { - '/': ['usr', 'tmp', 'mnt', 'etc'], - '/usr': ['games', 'lib', 'spool', 'milnet'], - '/usr/milnet': ['sdinet.doc', 'hosts.mil', 'roster.txt'], - '/etc': ['passwd', 'accounts', 'motd'], + '/': ['usr', 'tmp', 'mnt', 'etc', 'dev'], + '/usr': ['games', 'lib', 'spool', 'milnet', 'sventek'], + '/usr/milnet': ['sdinet.doc', 'hosts.mil', 'roster.txt', 'README'], + '/usr/sventek': ['.login', '.rhosts', 'notes.txt', 'thesis.tex'], + '/usr/spool': ['lpd', 'mail'], + '/usr/spool/mail': ['sventek', 'cstoll'], + '/usr/games': ['adventure', 'rogue', 'fortune'], + '/etc': ['passwd', 'accounts', 'motd', 'utmp', 'syslog'], '/tmp': ['.x', 'core'], + '/dev': ['tty01', 'ttyf3', 'lp0'], '/mnt': [], }; const DOCS = { @@ -46,6 +51,33 @@ nine seconds of connect time that nobody paid for. everybody else who saw this rounded it off. one person did not, and that is the entire reason any of this was ever found out.` }, '/usr/milnet/hosts.mil': { title: 'hosts.mil', body: 'four hundred and sixty addresses. he printed all of them.' }, + '/usr/milnet/README': { title: 'README', body: 'this directory is a mirror. do not edit. — j.s.' }, + '/usr/sventek/.rhosts': { title: '.rhosts', body: `+ + + +two characters. it means: any host, any user, no password. + +somebody typed that to save themselves twenty seconds a day in 1984 and +then left, and the account has been sitting here ever since with the door +wedged open by a doorstop made of two plus signs.` }, + '/usr/sventek/notes.txt': { title: 'notes.txt', body: `- ask dave re: tape allocation +- renew parking permit +- back sept 4 + +(he has been in england since june. these are from before.)` }, + '/usr/sventek/thesis.tex': { title: 'thesis.tex', body: '\\documentclass{article} ... four hundred pages about crystal lattices. it is not about you.' }, + '/usr/spool/mail/cstoll': { title: 'mail: cstoll', body: `From: dave@lbl +Subject: the 75 cents + +nobody cares about the 75 cents, cliff. + +(he cared.)` }, + '/etc/syslog': { title: 'syslog', body: `Nov 4 02:14:07 lbl login: ROOT LOGIN ttyf3 +Nov 4 02:14:09 lbl su: sventek to root +Nov 4 02:19:44 lbl login: ROOT LOGIN ttyf3 + +it is writing down everything you do, in a file you can read, which you +could edit — and he has a printer copy that you cannot.` }, + '/dev/tty01': { title: '/dev/tty01', body: 'his terminal. you can read it. that means he can read yours.' }, '/etc/motd': { title: 'motd', body: 'LBL VAX 11/780. be nice to each other.' }, '/usr/milnet/roster.txt': { title: 'roster.txt', body: 'names, ranks, and one phone number in Hannover.' }, }; @@ -57,7 +89,9 @@ export default { baud: 1, noRetrain: true, async mount(ctx) { - S = { t: 0, lines: 50, read: 0, cwd: '/', gone: false, caught: false, elapsed: 0 }; + S = { t: 0, lines: 50, read: 0, cwd: '/', gone: false, caught: false, elapsed: 0, + psLooks: 0, sawPrinter: false, cleaned: false }; + window.__S = S; ctx.scene.background = new THREE.Color(0x000000); ctx.camera.position.set(0, 0, 5); @@ -107,7 +141,8 @@ export default { if (!cmd) continue; if (cmd === 'help') { - term.writeln('|08 ls cd cat who ps logout'); + term.writeln('|08 ls cd cat who ps clean logout'); + term.writeln('|08 clean — edit the accounting files. costs time, buys cover.'); term.writeln('|08 every second you stay logged in he crosses off more lines.'); } else if (cmd === 'ls') { const here = FS[S.cwd] || []; @@ -137,10 +172,35 @@ export default { term.writeln('|11 sventek ttyf3 Nov 4 02:14 (you)'); term.writeln('|08 cstoll tty01 Nov 4 02:14 (he has been on since you were)'); } else if (cmd === 'ps') { - term.writeln('|08 1 /etc/init'); - term.writeln('|08 214 /usr/lib/lpd ← fifty of these'); - term.writeln('|08 907 cat /dev/tty01 > /dev/lp0'); - term.writeln('|08 908 sleep 1; beep pager ← this one is taped to a teletype'); + S.psLooks = (S.psLooks || 0) + 1; + term.writeln('|08 1 /etc/init'); + term.writeln('|08 214 /usr/lib/lpd'); + term.writeln('|08 908 sleep 1; beep pager (taped to a teletype)'); + // what he is doing changes as he narrows it, and you can watch him do it + if (S.lines > 24) { + term.writeln('|08 907 cat /dev/tty07 > /dev/lp0'); + term.writeln('|08 911 cat /dev/tty12 > /dev/lp1'); + term.writeln('|03 he is printing every line in the building. all of them at once.'); + } else if (S.lines > 6) { + term.writeln('|08 907 cat /dev/tty01 > /dev/lp0'); + term.writeln('|14 938 cat /dev/ttyf3 > /dev/lp2'); + term.writeln('|12 ttyf3 is yours. he has a printer on your line.'); + if (!S.sawPrinter) { S.sawPrinter = true; ctx.phile('printer'); } + } else { + term.writeln('|12 938 cat /dev/ttyf3 > /dev/lp2'); + term.writeln('|12 944 /usr/bin/trace ttyf3 --hold'); + term.writeln('|12 he has stopped printing anything except you.'); + } + } else if (cmd === 'clean' || cmd === 'wipe') { + if (S.cleaned) { term.writeln('|08 already done. it does not un-notice.'); continue; } + S.cleaned = true; + await term.paint('|07 editing /etc/utmp and /etc/syslog ...\n', 700); + S.elapsed += 34; // it costs you a lot of clock + await ctx.sleep(1600); + S.lines = Math.min(50, S.lines + 11); // and buys back a real amount of cover + await term.paint(`|10 your login is gone from the accounting file.\n`, 900); + await term.paint('|08 the paper copy in his basement is not, because paper does not\n have an editor. you bought time, not safety.\n', 1100); + ctx.phile('wipe'); } else if (cmd === 'logout' || cmd === 'exit') { return this.leave(ctx, term, false); } else { diff --git a/src/levels/handshake.js b/src/levels/handshake.js index 2b5af9e..091b580 100644 --- a/src/levels/handshake.js +++ b/src/levels/handshake.js @@ -72,7 +72,9 @@ const BOSS = { update(dt, t, ctx) { const b = S.b; b.t += dt; b.heads.forEach((h, i) => { - const period = 175 / h.f; // 350 and 440 do not line up often + // phase two: after the first hit it stops being patient + const urgency = 1 + (b.hits >= 1 ? 0.55 : 0) + (b.hits >= 2 ? 0.75 : 0); + const period = (175 / h.f) / urgency; // 350 and 440 do not line up often h.phase += dt; h.mesh.scale.setScalar(1 + Math.sin(b.t * (h.f / 100)) * 0.08); if (h.phase > period) { @@ -86,7 +88,7 @@ const BOSS = { }); for (let i = b.rings.length - 1; i >= 0; i--) { const rr = b.rings[i]; - rr.r += dt * 9; + rr.r += dt * 9 * (1 + b.hits * 0.45); rr.m.scale.setScalar(rr.r / 0.2); rr.m.material.opacity = Math.max(0, 0.9 - rr.r / 26); if (rr.r > 26) { S.arena.remove(rr.m); rr.m.geometry.dispose(); b.rings.splice(i, 1); } @@ -180,8 +182,11 @@ const BOSS = { update(dt, t, ctx) { const b = S.b; b.rt += dt; b.ct += dt; - if (b.rt > 0.92) { b.rt -= 0.92; b.row = (b.row + 1) % 4; A.burst(A.DTMF_ROW[b.row], null, 0.05, 0.05); } - if (b.ct > 0.61) { b.ct -= 0.61; b.col = (b.col + 1) % 4; A.burst(A.DTMF_COL[b.col], null, 0.05, 0.05); } + // phase two: past halfway the switch stops waiting for you + const rush = b.at >= 4 ? 0.62 : 1; + if (b.rt > 0.92 * rush) { b.rt -= 0.92 * rush; b.row = (b.row + 1) % 4; A.burst(A.DTMF_ROW[b.row], null, 0.05, 0.05); } + if (b.ct > 0.61 * rush) { b.ct -= 0.61 * rush; b.col = (b.col + 1) % 4; A.burst(A.DTMF_COL[b.col], null, 0.05, 0.05); } + if (b.at >= 4 && !b.rushed) { b.rushed = true; ctx.say('the switch is timing out. it will not hold the digits forever.', 3600); } b.beams.rows.forEach((m, i) => { m.material.opacity = i === b.row ? 0.5 : 0; }); b.beams.cols.forEach((m, i) => { m.material.opacity = i === b.col ? 0.5 : 0; }); @@ -237,7 +242,8 @@ const BOSS = { S.b.tone = A.ansam({ gain: 0.13 }); S.b.tone.onReverse(ph => { S.b.dir = ph; - S.b.still = 0.19; // the deaf window. this is your opening. + // the deaf window narrows every time you use it — 190 ms down to about 90 + S.b.still = Math.max(0.085, 0.19 - S.b.hits * 0.021); A.relayClick(0.18, 0.6); }); ctx.say('two thousand one hundred hertz, reversing its phase every four hundred and fifty milliseconds.', 5200); @@ -269,6 +275,7 @@ const BOSS = { A.burst(2100, 1050, 0.18, 0.2); S.flash = 0.7; ctx.say(`echo canceller down ${b.hits}/${b.need}`, 1200); + if (b.hits === 2) ctx.say('it is learning. the deaf window is getting shorter.', 3400); if (b.hits >= b.need) this.win(ctx); } }, @@ -390,7 +397,9 @@ const BOSS = { const b = S.b; b.t += dt; b.z += dt * 34; // the channel snakes. one sine is a corridor; two is a river. - const chan = z => Math.sin(z * 0.035) * 9 + Math.sin(z * 0.011 + 2) * 5; + // a line you negotiated badly is a line that wanders more + const w = 1 + (6 - (ctx.game.flags.baudEarned ?? 3)) * 0.22; + const chan = z => (Math.sin(z * 0.035) * 9 + Math.sin(z * 0.011 + 2) * 5) * w; b.x = clamp(b.x + In.axis().x * dt * 26, -22, 22); S.p.position.set(b.x, 2, 0); const off = Math.abs(b.x - chan(b.z)); diff --git a/src/levels/timewait.js b/src/levels/timewait.js index 9242c30..620db40 100644 --- a/src/levels/timewait.js +++ b/src/levels/timewait.js @@ -39,6 +39,17 @@ const SEATED = [ // because everybody in the room has watched it happen a great many times. const AGE_OUT = 'i was going to say —'; +// And somebody arrives, later, through the same door, with the flag still set — which +// is what you looked like when you walked in, and the room knows it. +const ARRIVAL = [ + '...is this it?', + 'i sent mine about forty seconds ago.', + 'they said somebody would come and tell me what happens next.', + 'nobody is coming, are they.', + '...', + 'how long have you been here.', +]; + // He will not talk to somebody who walked the length of the room without stopping. const FIN_REFUSE = [ 'there is a room full of them behind you and you have come straight to the door.', @@ -67,7 +78,8 @@ export default { async mount(ctx) { const { scene, camera } = ctx; S = { t: 0, near: null, talking: null, li: 0, finDone: false, door: null, cool: 0, - listened: 0, lapped: false, doomed: null }; + listened: 0, lapped: false, doomed: null, arrived: false, newcomer: null }; + window.__S = S; scene.background = new THREE.Color(0x1a1712); scene.fog = new THREE.Fog(0x1a1712, 8, 46); @@ -241,6 +253,36 @@ export default { 'they were halfway through a sentence. nobody in the room looked up.'); } + // ── and then somebody new comes through the door, with the flag still set, + // looking exactly like you did, and the room does not look up at that either. + if (!S.arrived && S.t > 62) { + S.arrived = true; + const g = new THREE.Group(); + const core = new THREE.Mesh(new THREE.IcosahedronGeometry(0.26, 1), + new THREE.MeshBasicMaterial({ color: 0x9ff5ff })); + g.add(core); + const halo = new THREE.Mesh(new THREE.IcosahedronGeometry(0.42, 1), + new THREE.MeshBasicMaterial({ color: 0x2ad8ff, wireframe: true, transparent: true, opacity: 0.4 })); + g.add(halo); + g.position.set(12.0, 0.92, 0); + ctx.scene.add(g); + S.newcomer = { g, core, halo, x: 2.0, z: 1.0, data: { name: 'SYN', lines: ARRIVAL }, + phase: 0, spoken: 0, walking: true }; + S.people.push(S.newcomer); + A.blip(520, 0.03, 0.03); + ctx.say('the door opened.', 2600); + } + if (S.newcomer && S.newcomer.walking) { + const n = S.newcomer; + n.g.position.x = damp(n.g.position.x, n.x, 1.1, dt); + n.g.position.z = damp(n.g.position.z, n.z, 1.1, dt); + if (Math.abs(n.g.position.x - n.x) < 0.3) { + n.walking = false; + ctx.say('it still has the flag set. it looks exactly like you did an hour ago.', 5200); + ctx.phile('arrival'); + } + } + // ── a full lap of the clock. two minutes is MSL, and the room notices you did it. if (!S.lapped && S.t > 120) { S.lapped = true; diff --git a/src/philes.js b/src/philes.js index a457f97..a93811f 100644 --- a/src/philes.js +++ b/src/philes.js @@ -23,6 +23,31 @@ export const PHILES = { actually wrote the tail pointer back. It is always the tail pointer. */`, }, + 'descriptor': { + title: 'the descriptor', + from: 'nic0.tx[0341]', + body: `seq, window, flags, ack, ttl. + +that is the whole of you. five fields in a ring buffer slot, and one of the +flags is set, and the one that is set is the one that means asking. + +everything that happens over the next sixty-four hops is an attempt to get +the fourth field filled in.`, + }, + + 'came-back': { + title: 'the one that came back', + from: 'the next slot along', + body: `it went out. eleven hops. nobody answered, and it came back on its own, +which is not a thing that is supposed to happen, and nobody has ever come +to ask it about it. they put it back in the ring like a returned parcel. + +it wanted you to know that if you do get an answer you will not come back +here — you go into a socket and you stop being a packet at all. + +and it wanted to be very clear that that is the good ending.`, + }, + 'osi': { title: 'the seven layers, as built', from: 'ISO/IEC 7498-1, and what actually happened', @@ -132,6 +157,47 @@ be worse. the chair is still there. it goes on being a chair.`, }, + 'printer': { + title: 'a printer on your line', + from: 'ps, at 02:19', + body: `he started with a printer on every line in the building, because he did not +know which one you were. + +now there is exactly one left running and the device it is attached to is +yours. + +you can watch him narrow it in ps, which is the joke: the process table is +world-readable, so the hunt is visible to the hunted, and neither of you +can do anything about the other knowing.`, + }, + + 'wipe': { + title: 'what editing the logs actually buys', + from: '/etc/utmp', + body: `you can edit the accounting file. it is a file. it has your login in it and +then it does not. + +what you cannot edit is the fifty printers in his basement, because paper +does not have an editor, and the line printer wrote it down as it happened +on a continuous fanfold that nobody can go back and change. + +that is the whole reason he used printers. not because they were good — +because they were the only medium in the building you could not reach.`, + }, + + 'arrival': { + title: 'somebody new came in', + from: 'the door, at 62 seconds', + body: `it still has the flag set. + +it sent forty seconds ago. it is asking whether this is it, and whether +somebody is coming to explain what happens next, and it has already worked +out on its own that nobody is. + +it looks exactly like you did when you walked in, and the room does not +look up at that either.`, + }, + 'msl': { title: 'a full lap', from: 'two minutes',