From d91c0bed1170db22fc82641b89c56d2c558b9e32 Mon Sep 17 00:00:00 2001 From: jae Date: Mon, 20 Apr 2026 02:44:25 +0000 Subject: [PATCH] feat(chat-cli): add SSH, adventure, figlet, fortunes, achievements, leaderboard, boss key --- api/data/changelog.json | 22 ++ css/chat-cli.css | 21 ++ js/chat-cli.js | 640 +++++++++++++++++++++++++++++++++++++++- js/chat.js | 3 +- 4 files changed, 681 insertions(+), 5 deletions(-) diff --git a/api/data/changelog.json b/api/data/changelog.json index f1c7bb3..4db9522 100644 --- a/api/data/changelog.json +++ b/api/data/changelog.json @@ -1,6 +1,28 @@ { "site": "jaeswift.xyz", "entries": [ + { + "version": "1.38.1", + "date": "20/04/2026", + "category": "FEATURE", + "title": "CLI Extensions: SSH, Adventure, Fortunes, Figlet, Achievements, Leaderboard, Boss Key", + "changes": [ + "JAE-AI terminal (/) now has 7 new commands and 3 meta-features — type /help to see the categorised menu (SYSTEM / FUN / MODES / STATS)", + "/ssh @ — fake SSH session with ED25519 handshake, MOTD, nested shell (ls/cat/cd/pwd/whoami/hostname/uname/date); exit via /exit-ssh or 'exit'", + "/adventure — short cyberpunk text adventure (DARK ALLEY → NEON ARCADE → HACKER DEN → LOW ROOFTOP → NEON ROOFTOP boss fight) with inventory, drone HP, /exit-adventure", + "/ttyper — typing-speed test with random programming quote, timer, WPM + accuracy calc, all-time best persisted in localStorage (jae-cli-ttyper-best-v1)", + "/cowsay — classic ASCII cow with auto-sized speech bubble", + "/figlet — block-font ASCII banner (5-row UNICODE █ glyphs, full A-Z/0-9/punctuation, truncated to 20 chars)", + "/fortune -o — offensive mode with 30 crude operator-themed quips; /fortune alone returns 20 tame SITREP entries", + "/cmatrix — enhanced matrix rain: dual-colour green+cyan, denser (14px), speed ramps over 30s cycles, optional square-wave AudioContext ticks for typing-sound effect", + "/achievements — progress tracker showing X/24 commands discovered with ASCII progress bar [███░░░░░░░], list of unlocked commands, persists via localStorage (jae-cli-achievements-v1); toast pops for each new unlock", + "/leaderboard — session + all-time stats for this browser: command counts, unlocks, most-used command, top-5 session & all-time, military rank (recruit → general based on 20/50/100/200/500/1000/2000 thresholds)", + "Boss key — press Escape (while anything is 'hot') or Ctrl+Shift+B to instantly hide matrix rains, dev-mode badge, toasts, and clear chat input; shows 'NORMAL MODE' tooltip for ~2s", + "Nested mode routing — when inside /ssh, /adventure, /ttyper, plain (non-slash) chat input is routed to the nested handler instead of JAE-AI; chat.js updated with isInMode() check", + "Stats tracked in localStorage (jae-cli-stats-v1) and in-memory session counter; commands object grown from 17 → 24", + "Files: js/chat-cli.js (+632 lines → 1142 total), css/chat-cli.css (+21 lines), js/chat.js (+1 line — mode-aware CLI interception)" + ] + }, { "version": "1.38.0", "date": "20/04/2026", diff --git a/css/chat-cli.css b/css/chat-cli.css index 2a8f6fc..71bb299 100644 --- a/css/chat-cli.css +++ b/css/chat-cli.css @@ -40,3 +40,24 @@ .chat-msg-cli .chat-msg-body { font-size: 11px; } #devModeBadge { font-size: 9px !important; top: 62px !important; padding: 4px 9px !important; } } + +/* ─── Extensions v1.38.1: toasts, mode accents ─── */ +.jae-cli-toast { + border-radius: 2px; + animation: jaeCliToastIn 0.28s ease-out; +} +@keyframes jaeCliToastIn { + from { transform: translateY(14px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +/* SSH & adventure nested mode user bubble tint */ +.chat-msg-user-cli.chat-msg-user-cli-mode .chat-msg-body::before { + content: '> '; + color: #33ddff; +} + +/* Figlet / cowsay bodies: ensure wide output doesn't wrap mid-block */ +.chat-msg-cli .chat-msg-body { + overflow-x: auto; +} diff --git a/js/chat-cli.js b/js/chat-cli.js index d8812e9..f7617b4 100644 --- a/js/chat-cli.js +++ b/js/chat-cli.js @@ -11,6 +11,45 @@ let cmdHistory = []; let devMode = false; let matrixActive = false; + let sessionCmdCount = 0; + let sessionPerCmd = {}; + let mode = null; // null | 'ssh' | 'adventure' | 'ttyper' + let modeState = {}; + const ACH_KEY = 'jae-cli-achievements-v1'; + const STATS_KEY = 'jae-cli-stats-v1'; + const TTYPER_KEY = 'jae-cli-ttyper-best-v1'; + let achievements = new Set(); + let allTimeStats = { total: 0, perCmd: {} }; + try { + const ar = localStorage.getItem(ACH_KEY); + if (ar) { const a = JSON.parse(ar); if (Array.isArray(a)) achievements = new Set(a); } + const sr = localStorage.getItem(STATS_KEY); + if (sr) allTimeStats = Object.assign({ total: 0, perCmd: {} }, JSON.parse(sr)); + } catch (e) {} + function saveAch() { try { localStorage.setItem(ACH_KEY, JSON.stringify([...achievements])); } catch(e){} } + function saveStats() { try { localStorage.setItem(STATS_KEY, JSON.stringify(allTimeStats)); } catch(e){} } + function unlockAchievement(cmd) { + if (achievements.has(cmd)) return false; + achievements.add(cmd); + saveAch(); + try { + const t = document.createElement('div'); + t.className = 'jae-cli-toast'; + t.textContent = '🏆 NEW: Discovered /' + cmd + ' command'; + Object.assign(t.style, { + position: 'fixed', bottom: '24px', right: '24px', zIndex: '9999', + background: 'var(--bg-panel, #0d1810)', border: '1px solid var(--warning, #c9a227)', + color: 'var(--warning, #c9a227)', padding: '10px 16px', + fontFamily: 'var(--font-mono, monospace)', fontSize: '12px', letterSpacing: '1px', + boxShadow: '0 0 18px rgba(201,162,39,0.45)', transition: 'opacity 0.4s', + borderRadius: '2px', pointerEvents: 'none', + }); + document.body.appendChild(t); + setTimeout(() => { t.style.opacity = '0'; }, 3400); + setTimeout(() => { t.remove(); }, 4000); + } catch (e) {} + return true; + } // Load history try { @@ -122,6 +161,7 @@ const base = [ 'JAE-AI TERMINAL — available commands:', '', + '— SYSTEM —', ' /help show this help', ' /ls list virtual files', ' /cat print file contents', @@ -132,13 +172,27 @@ ' /ping fake ping sweep', ' /sudo attempt privilege escalation', ' /rm -rf / don\'t do it', - ' /hack hollywood hacking simulator', - ' /matrix toggle matrix rain', ' /clear clear the chat', ' /exit try to leave', - ' /fortune random sitrep quip', ' /history show last commands', ' /neofetch system summary ASCII', + '', + '— FUN —', + ' /hack hollywood hacking simulator', + ' /matrix toggle matrix rain', + ' /cmatrix enhanced matrix (sound + ramp)', + ' /fortune [-o] random quip (-o for offensive)', + ' /cowsay a cow says something', + ' /figlet big ASCII banner (<=20 chars)', + '', + '— MODES —', + ' /ssh @ enter fake SSH session', + ' /adventure cyberpunk text adventure', + ' /ttyper typing speed test', + '', + '— STATS —', + ' /achievements your unlocked commands', + ' /leaderboard session & all-time stats', ]; if (devMode) { base.push(''); @@ -148,6 +202,7 @@ base.push(' /geo show GEO INTEL from telemetry'); } base.push(''); + base.push('Tip: Press Escape (or Ctrl+Shift+B) as a boss key to hide everything.'); base.push('Anything not prefixed with / goes to JAE-AI.'); return fmt(base); }; @@ -401,6 +456,514 @@ } }; + // ─── /cowsay ───────────────────────────────────── + commands.cowsay = function (args) { + const msg = args.join(' ') || 'moo.'; + const text = msg.slice(0, 200); + const top = ' ' + '_'.repeat(text.length + 2); + const bot = ' ' + '-'.repeat(text.length + 2); + return fmt([ + top, + '< ' + text + ' >', + bot, + ' \\ ^__^', + ' \\ (oo)\\_______', + ' (__)\\ )\\/\\', + ' ||----w |', + ' || ||' + ]); + }; + + // ─── /fortune (tame + offensive) ───────────────── + const FORTUNES_TAME = [ + 'SITREP: the compiler whispers. listen.', + 'SITREP: your branch is ahead by one regret.', + 'SITREP: staging is production if staging is where the users are.', + 'SITREP: tests pass locally is not a deployment strategy.', + 'SITREP: cache invalidation is a personality trait.', + 'SITREP: the database is fine. the database is never fine.', + 'SITREP: write it down. future-you is stupider than you think.', + 'SITREP: uptime is measured in coffee.', + 'SITREP: legacy code is just working code you hate.', + 'SITREP: every bug is someone else\'s feature.', + 'SITREP: the stack trace knows. trust the stack trace.', + 'SITREP: container orchestration is a synonym for chaos.', + 'SITREP: there are two kinds of backup: tested and imaginary.', + 'SITREP: TODO comments age like milk.', + 'SITREP: ship small, ship often, ship sober.', + 'SITREP: rm is a lifestyle. ls is religion.', + 'SITREP: root access is a personality disorder.', + 'SITREP: the terminal is the only honest relationship.', + 'SITREP: `it compiles` is not a vibe.', + 'SITREP: operator status: caffeinated. proceed.' + ]; + const FORTUNES_OFFENSIVE = [ + 'SITREP: your code smells like a fucking landfill. grep deeper.', + 'SITREP: whoever wrote this legacy shit owes the team a pint and a resignation letter.', + 'SITREP: you can\'t centralise a garbage fire. you can only photograph it for post-mortem.', + 'SITREP: touch grass, operator. the leaves are greener than your terminal.', + 'SITREP: the sysadmin is tired. the sysadmin is always fucking tired.', + 'SITREP: if it works, don\'t touch it. if it doesn\'t, blame the intern.', + 'SITREP: your git history is a crime scene. get a lawyer.', + 'SITREP: nginx config is 80% vibes and 20% stack overflow.', + 'SITREP: the only thing more fragile than your ego is this regex.', + 'SITREP: deploy on friday. embrace the chaos. live deliciously.', + 'SITREP: your tests are flaky because YOU are flaky.', + 'SITREP: merge conflicts are the universe\'s way of saying grow up.', + 'SITREP: chmod 777 is a cry for help.', + 'SITREP: whoever invented JavaScript owes humanity a bloody apology.', + 'SITREP: your Docker image is 4GB because you don\'t know what you\'re doing. fix it.', + 'SITREP: if you ssh into prod to "just check something", you are the incident.', + 'SITREP: that variable name is an act of violence against future maintainers.', + 'SITREP: you didn\'t break the build. the build broke you.', + 'SITREP: 3am deploys are for masochists and idiots. which one are you?', + 'SITREP: the documentation is a lie. it has always been a lie.', + 'SITREP: `works on my machine` is a confession, not a defence.', + 'SITREP: your monorepo is a cry for therapy.', + 'SITREP: stop copy-pasting from stack overflow you absolute muppet.', + 'SITREP: curl | bash is a religious experience and a mistake.', + 'SITREP: kubernetes is just containers with extra steps and extra crying.', + 'SITREP: the only good password is the one you forgot.', + 'SITREP: your side project is a liability, not a portfolio.', + 'SITREP: linters are for people who can\'t spell. spell better.', + 'SITREP: shit deploys fast. good shit deploys slower. great shit doesn\'t deploy on fridays.', + 'SITREP: if the server is smoking, that\'s your problem now.' + ]; + commands.fortune = function (args) { + const offensive = args && (args[0] === '-o' || args[0] === '--offensive'); + const pool = offensive ? FORTUNES_OFFENSIVE : FORTUNES_TAME; + return pool[Math.floor(Math.random() * pool.length)]; + }; + + // ─── /figlet — block-font ASCII banner ─���───────── + const FIGLET_FONT = { + 'A': [' ██ ','█ █ ','████ ','█ █ ','█ █ '], + 'B': ['███ ','█ █ ','███ ','█ █ ','███ '], + 'C': [' ███ ','█ ','█ ','█ ',' ███ '], + 'D': ['███ ','█ █ ','█ █ ','█ █ ','███ '], + 'E': ['████ ','█ ','███ ','█ ','████ '], + 'F': ['████ ','█ ','███ ','█ ','█ '], + 'G': [' ███ ','█ ','█ ██ ','█ █ ',' ███ '], + 'H': ['█ █ ','█ █ ','████ ','█ █ ','█ █ '], + 'I': ['███ ',' █ ',' █ ',' █ ','███ '], + 'J': [' ██ ',' █ ',' █ ','█ █ ',' ██ '], + 'K': ['█ █ ','█ █ ','██ ','█ █ ','█ █ '], + 'L': ['█ ','█ ','█ ','█ ','████ '], + 'M': ['█ █','██ ██','█ █ █','█ █','█ █'], + 'N': ['█ █ ','██ █ ','█ ██ ','█ █ ','█ █ '], + 'O': [' ██ ','█ █ ','█ █ ','█ █ ',' ██ '], + 'P': ['███ ','█ █ ','███ ','█ ','█ '], + 'Q': [' ██ ','█ █ ','█ █ ','█ █ ',' █ █ '], + 'R': ['███ ','█ █ ','███ ','█ █ ','█ █ '], + 'S': [' ███ ','█ ',' ██ ',' █ ','███ '], + 'T': ['█████',' █ ',' █ ',' █ ',' █ '], + 'U': ['█ █ ','█ █ ','█ █ ','█ █ ',' ██ '], + 'V': ['█ █ ','█ █ ','█ █ ',' ██ ',' █ '], + 'W': ['█ █','█ █','█ █ █','██ ██','█ █'], + 'X': ['█ █ ',' ██ ',' █ ',' ██ ','█ █ '], + 'Y': ['█ █ ',' ██ ',' █ ',' █ ',' █ '], + 'Z': ['████ ',' █ ',' █ ',' █ ','████ '], + '0': [' ██ ','█ █ ','█ █ ','█ █ ',' ██ '], + '1': [' █ ','██ ',' █ ',' █ ','███ '], + '2': [' ██ ','█ █ ',' █ ',' █ ','████ '], + '3': ['███ ',' █ ',' ██ ',' █ ','███ '], + '4': ['█ █ ','█ █ ','████ ',' █ ',' █ '], + '5': ['████ ','█ ','███ ',' █ ','███ '], + '6': [' ██ ','█ ','███ ','█ █ ',' ██ '], + '7': ['████ ',' █ ',' █ ',' █ ','█ '], + '8': [' ██ ','█ █ ',' ██ ','█ █ ',' ██ '], + '9': [' ██ ','█ █ ',' ███ ',' █ ',' ██ '], + ' ': [' ',' ',' ',' ',' '], + '!': [' █ ',' █ ',' █ ',' ',' █ '], + '?': [' ██ ','█ █ ',' █ ',' ',' █ '], + '.': [' ',' ',' ',' ',' █ '], + '-': [' ',' ','████ ',' ',' '], + '_': [' ',' ',' ',' ','████ '], + '/': [' █ ',' █ ',' █ ','█ ',' '], + ':': [' ',' █ ',' ',' █ ',' '] + }; + commands.figlet = function (args) { + const text = (args.join(' ') || 'JAE').toUpperCase().slice(0, 20); + const rows = ['', '', '', '', '']; + for (const ch of text) { + const glyph = FIGLET_FONT[ch] || FIGLET_FONT['?']; + for (let i = 0; i < 5; i++) rows[i] += glyph[i] + ' '; + } + return rows.join('\n'); + }; + + // ─── /ssh fake session ─────────────────────────── + const SSH_FS = { + '/': ['home', 'etc', 'var', 'opt', 'tmp'], + '/home': ['operator', 'guest'], + '/etc': ['hostname', 'motd', 'shadow'], + '/var': ['log'], + '/var/log': ['auth.log', 'syslog'], + }; + const SSH_FILES = { + '/etc/hostname': 'jaeswift-prod-01', + '/etc/motd': '\n WELCOME TO JAESWIFT PROD — authorised operators only.\n All activity logged. Press Ctrl+C to get off my lawn.\n', + '/etc/shadow': 'nice try. permission denied.', + '/home/operator/notes.txt': 'remember: the password is NOT hunter2.', + '/var/log/auth.log': '[' + new Date().toISOString() + '] sshd: Accepted publickey for operator from 127.0.0.1', + }; + commands.ssh = function (args) { + const target = args[0] || ''; + const m = target.match(/^([a-zA-Z0-9_-]+)@([a-zA-Z0-9.\-_]+)$/); + if (!m) return 'usage: /ssh @ e.g. /ssh operator@jaeswift.xyz'; + const user = m[1], host = m[2]; + mode = 'ssh'; + modeState = { user, host, cwd: '/home/' + user }; + return fmt([ + `Connecting to ${host} on port 22...`, + `The authenticity of host '${host}' can\'t be established.`, + `ED25519 key fingerprint is SHA256:J4Esw1FtX${Math.random().toString(36).slice(2,14)}.`, + `Warning: Permanently added '${host}' (ED25519) to the list of known hosts.`, + `${user}@${host}'s password: ****`, + `Authenticating...`, + `Last login: ${new Date().toUTCString()} from 10.0.0.1`, + ``, + `Welcome to ${host} — type 'help' for commands, '/exit-ssh' to disconnect.` + ]); + }; + function sshHandle(line) { + const raw = line.trim(); + if (!raw) return ''; + if (raw === '/exit-ssh' || raw === 'exit' || raw === 'logout') { + const h = modeState.host; + mode = null; modeState = {}; + return `Connection to ${h} closed.`; + } + const parts = raw.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const arg = parts.slice(1).join(' '); + switch (cmd) { + case 'help': + return fmt(['available: ls, cat , pwd, cd , whoami, hostname, uname, date, exit']); + case 'whoami': return modeState.user; + case 'hostname': return modeState.host; + case 'pwd': return modeState.cwd; + case 'uname': return 'Linux'; + case 'date': return new Date().toString(); + case 'ls': { + const dir = SSH_FS[modeState.cwd]; + if (dir) return dir.join(' '); + if (modeState.cwd === '/home/' + modeState.user) return 'notes.txt .bashrc .ssh'; + return ''; + } + case 'cd': { + if (!arg || arg === '~') { modeState.cwd = '/home/' + modeState.user; return ''; } + if (arg.startsWith('/')) modeState.cwd = arg; + else modeState.cwd = (modeState.cwd + '/' + arg).replace(/\/+/g, '/'); + return ''; + } + case 'cat': { + if (!arg) return 'cat: missing operand'; + const full = arg.startsWith('/') ? arg : (modeState.cwd + '/' + arg).replace(/\/+/g, '/'); + if (SSH_FILES[full] != null) return SSH_FILES[full]; + return `cat: ${arg}: No such file or directory`; + } + default: + return `${cmd}: command not found`; + } + } + + // ─── /adventure — cyberpunk text adventure ─────── + const ADV_ROOMS = { + 'alley': { name: 'DARK ALLEY', desc: 'Rain slicks the neon puddles. A fire-escape ladder climbs north to a rooftop. East is a buzzing arcade door.', exits: { n: 'rooftop_low', e: 'arcade' }, items: ['rusty knife'] }, + 'arcade': { name: 'NEON ARCADE', desc: 'Machines scream colour. A bouncer eyes you. A back door leads south to a hacker den. West back to the alley.', exits: { w: 'alley', s: 'hacker_den' }, items: ['token'] }, + 'hacker_den': { name: 'HACKER DEN', desc: 'Ten monitors, one cat. A hacker types without looking. They might talk. North back to arcade.', exits: { n: 'arcade' }, items: [], npc: 'hacker' }, + 'rooftop_low': { name: 'LOW ROOFTOP', desc: 'Gravel, pigeons, a distant drone. A ladder climbs east to the high neon rooftop.', exits: { s: 'alley', e: 'rooftop_high' }, items: [] }, + 'rooftop_high': { name: 'NEON ROOFTOP', desc: 'The city spreads below. A corporate drone blocks the datalink pylon. BOSS FIGHT.', exits: { w: 'rooftop_low' }, items: [], boss: 'drone' } + }; + commands.adventure = function () { + mode = 'adventure'; + modeState = { room: 'alley', inv: [], hp: 100, bossHp: 60, talked: false, victory: false }; + return fmt([ + '== NEON/NULL — a very short cyberpunk — ==', + '', + 'You are an operator looking to upload the encryption key to the city.', + 'Commands: look, n/s/e/w (or north/south/east/west), take ,', + ' use , inventory (i), attack, talk, /exit-adventure', + '', + advDescribe() + ]); + }; + function advDescribe() { + const r = ADV_ROOMS[modeState.room]; + const out = [`[${r.name}]`, r.desc]; + if (r.items && r.items.length) out.push('You see: ' + r.items.join(', ')); + if (r.boss && modeState.bossHp > 0) out.push('A CORPORATE DRONE is here. HP: ' + modeState.bossHp); + const exits = Object.keys(r.exits).map(k => ({n:'north',s:'south',e:'east',w:'west'})[k]).join(', '); + out.push('Exits: ' + exits); + return out.join('\n'); + } + function adventureHandle(line) { + const raw = line.trim().toLowerCase(); + if (!raw) return ''; + if (raw === '/exit-adventure' || raw === 'exit-adventure' || raw === 'quit') { + mode = null; modeState = {}; + return 'You jack out. Back to JAE-AI.'; + } + const dirMap = { n: 'n', north: 'n', s: 's', south: 's', e: 'e', east: 'e', w: 'w', west: 'w' }; + if (dirMap[raw]) { + const r = ADV_ROOMS[modeState.room]; + const d = dirMap[raw]; + if (r.boss && modeState.bossHp > 0) return 'The drone blocks your path. ATTACK it first.'; + if (!r.exits[d]) return 'You can\'t go that way.'; + modeState.room = r.exits[d]; + return advDescribe(); + } + if (raw === 'look' || raw === 'l') return advDescribe(); + if (raw === 'inventory' || raw === 'i' || raw === 'inv') { + return modeState.inv.length ? 'You carry: ' + modeState.inv.join(', ') : 'Your pockets are empty.'; + } + if (raw.startsWith('take ')) { + const item = raw.slice(5).trim(); + const r = ADV_ROOMS[modeState.room]; + const idx = r.items.indexOf(item); + if (idx === -1) return `There is no ${item} here.`; + r.items.splice(idx, 1); + modeState.inv.push(item); + return `You take the ${item}.`; + } + if (raw.startsWith('use ')) { + const item = raw.slice(4).trim(); + if (!modeState.inv.includes(item)) return `You don't have a ${item}.`; + if (item === 'token' && modeState.room === 'arcade') { + modeState.inv.push('encryption key'); + return 'The arcade machine clunks. A secret panel opens — you palm an ENCRYPTION KEY.'; + } + if (item === 'encryption key' && modeState.room === 'rooftop_high' && modeState.bossHp <= 0) { + modeState.victory = true; + mode = null; modeState = {}; + return fmt([ + 'You jack the ENCRYPTION KEY into the pylon.', + 'The city\'s surveillance grid goes dark for 11 seconds.', + 'That\'s all you needed.', + '', + '>> YOU WIN. Mission complete. Exiting adventure. <<' + ]); + } + return 'Nothing happens.'; + } + if (raw === 'talk') { + const r = ADV_ROOMS[modeState.room]; + if (r.npc === 'hacker' && !modeState.talked) { + modeState.talked = true; + if (!modeState.inv.includes('token')) return 'The hacker: "Arcade machine. Back one. Use the token. Trust me."'; + if (!modeState.inv.includes('encryption key')) return 'The hacker: "Use what you have. The arcade remembers."'; + return 'The hacker: "Rooftop. Pylon. Key. Go."'; + } + return 'No one to talk to.'; + } + if (raw === 'attack') { + const r = ADV_ROOMS[modeState.room]; + if (!r.boss || modeState.bossHp <= 0) return 'Nothing to attack.'; + const weapon = modeState.inv.includes('rusty knife') ? 'knife' : 'fist'; + const dmg = weapon === 'knife' ? (8 + Math.floor(Math.random()*8)) : (3 + Math.floor(Math.random()*4)); + modeState.bossHp -= dmg; + modeState.hp -= (3 + Math.floor(Math.random()*6)); + let out = `You attack with your ${weapon} for ${dmg} damage. Drone HP: ${Math.max(0,modeState.bossHp)}.`; + if (modeState.hp <= 0) { + mode = null; modeState = {}; + return out + '\nThe drone flatlines you. GAME OVER.'; + } + if (modeState.bossHp <= 0) out += '\nThe drone crashes to the gravel. The pylon is exposed.'; + out += ` Your HP: ${modeState.hp}.`; + return out; + } + return `Unknown command: '${raw}'. Try look, directions, take, use, attack, talk.`; + } + + // ─── /ttyper — typing speed test ───────────────── + const TTYPER_QUOTES = [ + 'the quick brown fox jumps over the lazy dog while the sysadmin sips cold coffee and watches nginx logs scroll endlessly', + 'code is read many more times than it is written so leave comments that future you will not curse at', + 'in the terminal no one can hear you scream but the bash history remembers every single thing you have ever typed', + 'deploy on friday and spend the weekend rolling back changes like a very unfortunate software archaeologist', + 'the best debugger is still a print statement followed by staring at the output for twenty minutes in silence' + ]; + commands.ttyper = function () { + const q = TTYPER_QUOTES[Math.floor(Math.random() * TTYPER_QUOTES.length)]; + mode = 'ttyper'; + modeState = { quote: q, start: 0 }; + return fmt([ + '=== TTYPER — typing speed test ===', + 'Type the quote below exactly as shown, then press Enter.', + 'Type "/exit-ttyper" to cancel.', + '', + '> ' + q, + '', + '(timer starts on your first submission)' + ]); + }; + function ttyperHandle(line) { + const raw = line.trim(); + if (raw === '/exit-ttyper' || raw === 'exit-ttyper' || raw === 'quit') { + mode = null; modeState = {}; + return 'TTYPER cancelled.'; + } + if (!modeState.start) modeState.start = Date.now(); + const elapsed = (Date.now() - modeState.start) / 1000; + const target = modeState.quote; + const a = raw, b = target; + const maxLen = Math.max(a.length, b.length); + let correct = 0; + for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] === b[i]) correct++; + const accuracy = maxLen === 0 ? 100 : Math.round((correct / maxLen) * 100); + const words = target.split(/\s+/).length; + const wpm = elapsed > 0 ? Math.round((words / elapsed) * 60) : 0; + let best = null; + try { + const raw2 = localStorage.getItem(TTYPER_KEY); + if (raw2) best = JSON.parse(raw2); + } catch (e) {} + let bestNote = ''; + if (!best || (wpm > (best.wpm || 0) && accuracy >= 90)) { + const rec = { wpm, accuracy, ts: Date.now() }; + try { localStorage.setItem(TTYPER_KEY, JSON.stringify(rec)); } catch(e){} + bestNote = '\n*** NEW PERSONAL BEST ***'; + best = rec; + } + mode = null; modeState = {}; + return fmt([ + '=== TTYPER RESULT ===', + `time: ${elapsed.toFixed(2)}s`, + `WPM: ${wpm}`, + `accuracy: ${accuracy}%`, + best ? `all-time best: ${best.wpm} WPM @ ${best.accuracy}%` : '', + bestNote + ].filter(Boolean)); + } + + // ─── /cmatrix — enhanced matrix rain ───────────── + let cmatrixCanvas = null, cmatrixRAF = null, cmatrixAudio = null, cmatrixStart = 0; + function toggleCMatrix(on) { + if (on) { + cmatrixCanvas = document.createElement('canvas'); + cmatrixCanvas.id = 'cmatrixRain'; + Object.assign(cmatrixCanvas.style, { + position: 'fixed', inset: '0', width: '100%', height: '100%', + pointerEvents: 'none', zIndex: '8', opacity: '0.32', + }); + document.body.appendChild(cmatrixCanvas); + const ctx = cmatrixCanvas.getContext('2d'); + function resize() { cmatrixCanvas.width = innerWidth; cmatrixCanvas.height = innerHeight; } + resize(); + window.addEventListener('resize', resize); + const chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノ01JAESWIFT░▒▓█'.split(''); + const size = 14; + const cols = Math.floor(cmatrixCanvas.width / size); + const drops = new Array(cols).fill(0).map(() => Math.random() * 40); + cmatrixStart = Date.now(); + try { + const AC = window.AudioContext || window.webkitAudioContext; + if (AC) cmatrixAudio = new AC(); + } catch (e) {} + let tick = 0; + function draw() { + const t = (Date.now() - cmatrixStart) / 1000; + const phase = (t % 30) / 30; + const speed = 1 + phase * 2.5; + ctx.fillStyle = 'rgba(0, 0, 0, 0.08)'; + ctx.fillRect(0, 0, cmatrixCanvas.width, cmatrixCanvas.height); + ctx.font = size + 'px JetBrains Mono, monospace'; + for (let i = 0; i < drops.length; i++) { + const ch = chars[Math.floor(Math.random() * chars.length)]; + ctx.fillStyle = (i % 2 === 0) ? '#00ff66' : '#33ddff'; + ctx.fillText(ch, i * size, drops[i] * size); + if (drops[i] * size > cmatrixCanvas.height && Math.random() > 0.97) drops[i] = 0; + drops[i] += speed; + } + tick++; + if (cmatrixAudio && tick % 4 === 0) { + try { + const osc = cmatrixAudio.createOscillator(); + const g = cmatrixAudio.createGain(); + osc.type = 'square'; + osc.frequency.value = 1600 + Math.random() * 800; + g.gain.value = 0.0035; + osc.connect(g); g.connect(cmatrixAudio.destination); + osc.start(); + osc.stop(cmatrixAudio.currentTime + 0.02); + } catch (e) {} + } + cmatrixRAF = requestAnimationFrame(draw); + } + draw(); + } else { + if (cmatrixRAF) cancelAnimationFrame(cmatrixRAF); + if (cmatrixCanvas) cmatrixCanvas.remove(); + if (cmatrixAudio) { try { cmatrixAudio.close(); } catch(e){} } + cmatrixCanvas = null; cmatrixAudio = null; + } + } + let cmatrixActive = false; + commands.cmatrix = function () { + cmatrixActive = !cmatrixActive; + toggleCMatrix(cmatrixActive); + return cmatrixActive + ? 'CMATRIX engaged. Dual-colour rain with audio. Toggle off with /cmatrix.' + : 'CMATRIX disengaged.'; + }; + + // ─── /achievements ─────────────────────────────── + const TOTAL_COMMANDS = 24; + commands.achievements = function () { + const unlocked = [...achievements].sort(); + const n = unlocked.length; + const pct = Math.round((n / TOTAL_COMMANDS) * 100); + const filled = Math.min(10, Math.round((n / TOTAL_COMMANDS) * 10)); + const bar = '[' + '█'.repeat(filled) + '░'.repeat(10 - filled) + ']'; + const out = [ + '=== ACHIEVEMENTS ===', + '', + `Progress: ${bar} ${n}/${TOTAL_COMMANDS} (${pct}%)`, + '' + ]; + if (n === 0) out.push('No commands discovered yet. Try /help to start.'); + else { + out.push('Unlocked:'); + unlocked.forEach(c => out.push(' ✓ /' + c)); + } + out.push(''); + out.push('Keep exploring, operator.'); + return fmt(out); + }; + + // ─── /leaderboard ──────────────────────────────── + function rankFor(total) { + if (total >= 2000) return 'general'; + if (total >= 1000) return 'colonel'; + if (total >= 500) return 'major'; + if (total >= 200) return 'captain'; + if (total >= 100) return 'lieutenant'; + if (total >= 50) return 'sergeant'; + if (total >= 20) return 'corporal'; + return 'recruit'; + } + commands.leaderboard = function () { + const entries = Object.entries(allTimeStats.perCmd || {}).sort((a,b) => b[1] - a[1]); + const mostUsed = entries.length ? entries[0][0] : '—'; + const sessionEntries = Object.entries(sessionPerCmd).sort((a,b) => b[1] - a[1]); + return fmt([ + '=== LEADERBOARD (this browser) ===', + '', + `Session commands: ${sessionCmdCount}`, + `All-time commands: ${allTimeStats.total || 0}`, + `Commands unlocked: ${achievements.size}/${TOTAL_COMMANDS}`, + `Most-used (all-time): /${mostUsed}`, + `Rank: ${rankFor(allTimeStats.total || 0).toUpperCase()}`, + '', + 'Session top 5:', + ...sessionEntries.slice(0, 5).map(([c, n]) => ` /${c} ×${n}`), + '', + 'All-time top 5:', + ...entries.slice(0, 5).map(([c, n]) => ` /${c} ×${n}`) + ]); + }; + // ─── Matrix rain overlay ───────────────────────── let matrixCanvas = null; let matrixRAF = null; @@ -476,7 +1039,24 @@ // ─── Entry point — handle a /-command ─────────── async function handle(raw) { - const trimmed = raw.trim(); + const trimmed = (raw || '').trim(); + if (!trimmed) return { handled: false }; + + // If we are inside a nested mode, route input there regardless of '/' prefix, + // except for an explicit /exit- escape (handled inside each). + if (mode === 'ssh') { + try { const out = sshHandle(trimmed); return { handled: true, output: out == null ? '' : String(out) }; } + catch (e) { return { handled: true, output: 'ssh error: ' + e.message }; } + } + if (mode === 'adventure') { + try { const out = adventureHandle(trimmed); return { handled: true, output: out == null ? '' : String(out) }; } + catch (e) { return { handled: true, output: 'adventure error: ' + e.message }; } + } + if (mode === 'ttyper') { + try { const out = ttyperHandle(trimmed); return { handled: true, output: out == null ? '' : String(out) }; } + catch (e) { return { handled: true, output: 'ttyper error: ' + e.message }; } + } + if (!trimmed.startsWith('/')) return { handled: false }; const parts = trimmed.slice(1).split(/\s+/); @@ -494,6 +1074,17 @@ return { handled: true, output: `command not found: /${cmd}. type /help for available commands.` }; } + // Track stats + unlock achievements for known commands + try { + sessionCmdCount++; + sessionPerCmd[cmd] = (sessionPerCmd[cmd] || 0) + 1; + allTimeStats.total = (allTimeStats.total || 0) + 1; + allTimeStats.perCmd = allTimeStats.perCmd || {}; + allTimeStats.perCmd[cmd] = (allTimeStats.perCmd[cmd] || 0) + 1; + saveStats(); + unlockAchievement(cmd); + } catch (e) {} + try { const result = await fn(args); return { handled: true, output: (result == null ? '' : String(result)) }; @@ -502,9 +1093,50 @@ } } + // ─── Boss key — Escape / Ctrl+Shift+B ──────────── + function bossKeyActivate() { + try { + if (matrixActive) { matrixActive = false; toggleMatrix(false); } + if (cmatrixActive) { cmatrixActive = false; toggleCMatrix(false); } + const badge = document.getElementById('devModeBadge'); + if (badge) badge.style.display = 'none'; + const toasts = document.querySelectorAll('.jae-cli-toast'); + toasts.forEach(t => t.remove()); + const ci = document.getElementById('chatInput'); + if (ci) { ci.value = ''; try { ci.blur(); } catch(e){} } + const tip = document.createElement('div'); + tip.textContent = 'NORMAL MODE'; + Object.assign(tip.style, { + position: 'fixed', top: '12px', left: '50%', transform: 'translateX(-50%)', + background: '#111', color: '#9aa', border: '1px solid #333', + padding: '4px 10px', fontFamily: 'monospace', fontSize: '10px', + letterSpacing: '2px', zIndex: '99999', opacity: '0.85', + borderRadius: '2px', pointerEvents: 'none', + }); + document.body.appendChild(tip); + setTimeout(() => { tip.style.transition = 'opacity 0.5s'; tip.style.opacity = '0'; }, 1500); + setTimeout(() => tip.remove(), 2200); + } catch (e) {} + } + document.addEventListener('keydown', function (e) { + const isEsc = (e.key === 'Escape'); + const isCtrlShiftB = (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')); + if (isEsc || isCtrlShiftB) { + // Don't nuke on Escape while user is typing in chat unless something is actually "hot" + const hot = matrixActive || cmatrixActive || document.getElementById('devModeBadge') || document.querySelector('.jae-cli-toast'); + if (isCtrlShiftB || (isEsc && hot)) { + bossKeyActivate(); + if (isCtrlShiftB) e.preventDefault(); + } + } + }); + window.__jaeCLI = { handle: handle, commands: commands, isDev: function () { return devMode; }, + isInMode: function () { return mode !== null; }, + currentMode: function () { return mode; }, + bossKey: bossKeyActivate, }; })(); diff --git a/js/chat.js b/js/chat.js index 7475e95..554b509 100644 --- a/js/chat.js +++ b/js/chat.js @@ -128,7 +128,8 @@ if (!text || isWaiting) return; // ─── CLI interception ─── - if (text.startsWith('/') && window.__jaeCLI && typeof window.__jaeCLI.handle === 'function') { + const inCliMode = window.__jaeCLI && typeof window.__jaeCLI.isInMode === 'function' && window.__jaeCLI.isInMode(); + if ((text.startsWith('/') || inCliMode) && window.__jaeCLI && typeof window.__jaeCLI.handle === 'function') { isWaiting = true; chatInput.value = ''; const userBubble = addMessage('user', text);