From c182831a0df59a335ac9e54d75a7c53ebe6aa424 Mon Sep 17 00:00:00 2001 From: ARGUS <268038739+ARGUS-Memory@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:19:36 +0100 Subject: [PATCH] BYTECRAWL: NtOS idle hacking game (#19306) * BYTECRAWL: NtOS idle hacking game Hack the world * Add jobs clear command and expand explain with cipher table commands/jobs.ts: jobs clear subcommand strips all failed jobs from the list commands/autocomplete.ts: offer clear and --filter as first-arg completions after jobs commands/meta.ts: - help jobs entry updated to document jobs clear - explain: new CIPHERS section with full abbrev/tier/name/types/uptime table; T4/T5 uptime warning highlighted; market section trimmed of inline tier list - explain tips: note added for jobs clear * bytecrawl: ascend confirmation gate, sell all command ascend now requires --confirm to execute; running ascend alone shows a preview of the GHOST bonuses and a reset warning, matching the upgrade --confirm pattern. sell all sells the entire cache at current market prices, printing per-item lines and a total. autocomplete updated for both: ascend offers --confirm alongside --preview, sell offers all as a first-arg candidate. * bytecrawl: merge connect into scan subcommand scan now probes a target directly; scan with no args or flags still refreshes the list. connect command removed. autocomplete updated to offer scan pool IDs at argPos 1 for scan (non-flag). constants and help entries updated throughout. * Unangy the belly lint Make Linters happy * fix(bytecrawl): resolve all remaining biome lint errors and warnings * Exlpicitly return FALSE * Make var h more verbose as handle * unasterisk the react he will be missed * Add more Style points get it * remove stale bread we dont need you * forgor the other asterisks, not they gone for good --- .../file_system/programs/generic/bytecrawl.dm | 14 + .../interfaces/NtosBytecrawl/Terminal.tsx | 106 +++++++ .../NtosBytecrawl/commands/autocomplete.ts | 82 ++++++ .../NtosBytecrawl/commands/context.ts | 14 + .../NtosBytecrawl/commands/hardware.ts | 207 ++++++++++++++ .../NtosBytecrawl/commands/index.ts | 53 ++++ .../interfaces/NtosBytecrawl/commands/jobs.ts | 200 ++++++++++++++ .../interfaces/NtosBytecrawl/commands/meta.ts | 261 ++++++++++++++++++ .../interfaces/NtosBytecrawl/commands/scan.ts | 99 +++++++ .../NtosBytecrawl/commands/systems.ts | 68 +++++ .../NtosBytecrawl/commands/trading.ts | 90 ++++++ .../interfaces/NtosBytecrawl/constants.ts | 63 +++++ .../tgui/interfaces/NtosBytecrawl/format.ts | 24 ++ .../interfaces/NtosBytecrawl/hooks/index.ts | 23 ++ .../NtosBytecrawl/hooks/useMarketLoop.ts | 56 ++++ .../NtosBytecrawl/hooks/useTickLoop.ts | 196 +++++++++++++ .../tgui/interfaces/NtosBytecrawl/index.tsx | 182 ++++++++++++ .../interfaces/NtosBytecrawl/logic/economy.ts | 95 +++++++ .../interfaces/NtosBytecrawl/logic/index.ts | 14 + .../interfaces/NtosBytecrawl/logic/jobs.ts | 63 +++++ .../interfaces/NtosBytecrawl/logic/market.ts | 59 ++++ .../interfaces/NtosBytecrawl/logic/trace.ts | 75 +++++ .../tgui/interfaces/NtosBytecrawl/store.ts | 13 + .../tgui/interfaces/NtosBytecrawl/types.ts | 82 ++++++ .../tgui/interfaces/NtosBytecrawl/utils.ts | 74 +++++ .../tgui/styles/interfaces/NtosBytecrawl.scss | 145 ++++++++++ tgui/packages/tgui/styles/main.scss | 1 + vorestation.dme | 1 + 28 files changed, 2360 insertions(+) create mode 100644 code/modules/modular_computers/file_system/programs/generic/bytecrawl.dm create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/Terminal.tsx create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/commands/autocomplete.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/commands/context.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/commands/hardware.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/commands/index.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/commands/jobs.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/commands/meta.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/commands/scan.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/commands/systems.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/commands/trading.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/constants.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/format.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/index.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/useMarketLoop.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/useTickLoop.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/index.tsx create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/logic/economy.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/logic/index.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/logic/jobs.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/logic/market.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/logic/trace.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/store.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/types.ts create mode 100644 tgui/packages/tgui/interfaces/NtosBytecrawl/utils.ts create mode 100644 tgui/packages/tgui/styles/interfaces/NtosBytecrawl.scss diff --git a/code/modules/modular_computers/file_system/programs/generic/bytecrawl.dm b/code/modules/modular_computers/file_system/programs/generic/bytecrawl.dm new file mode 100644 index 0000000000..cf32ee9de4 --- /dev/null +++ b/code/modules/modular_computers/file_system/programs/generic/bytecrawl.dm @@ -0,0 +1,14 @@ +/datum/computer_file/program/bytecrawl + filename = "bytecrawl" + filedesc = "BYTECRAWL" + extended_desc = "A CLI hacking idle terminal. Run crack jobs, sell stolen data, upgrade your rig." + size = 3 + requires_ntnet = FALSE + available_on_ntnet = TRUE + tgui_id = "NtosBytecrawl" + program_icon_state = "generic" + usage_flags = PROGRAM_ALL + +/datum/computer_file/program/bytecrawl/tgui_data(mob/user) + . = get_header_data() + diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/Terminal.tsx b/tgui/packages/tgui/interfaces/NtosBytecrawl/Terminal.tsx new file mode 100644 index 0000000000..b0fd2694c1 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/Terminal.tsx @@ -0,0 +1,106 @@ +import type { KeyboardEvent, RefObject } from 'react'; + +import type { Line } from './types'; + +type HeaderSlot = { + text: string; + color?: string; +}; + +type TerminalProps = { + // Output lines rendered in the scrollable area + lines: Line[]; + outputRef: RefObject; + + // Current input value + input: string; + onInput: (value: string) => void; + onKeyDown: (e: KeyboardEvent) => void; + inputRef: RefObject; + + // Prompt shown before the input field (e.g. "ghost@bytecrawl:~$") + prompt: string; + + // Header bar slots — rendered left to right + headerSlots: HeaderSlot[]; + + // Setup screen: render a plain output block instead of lines + header + setupContent?: string; +}; + +export function Terminal(props: TerminalProps) { + const { + lines, + outputRef, + input, + onInput, + onKeyDown, + inputRef, + prompt, + headerSlots, + setupContent, + } = props; + + const inputRow = ( +
+ {`${prompt} `} + onInput(e.target.value)} + onKeyDown={onKeyDown} + autoFocus + spellCheck={false} + /> +
+ ); + + return ( +
+ {/* CRT overlay effects */} +
+
+ +
+ {setupContent !== undefined ? ( + // ── Setup screen ──────────────────────────────────────────────────── + <> +
+
+ {setupContent} +
+
+ {inputRow} + + ) : ( + // ── Main game screen ───────────────────────────────────────────────── + <> +
+ {headerSlots.map((slot, i) => ( + + {slot.text} + + ))} +
+
+ {lines.map((l, i) => ( +
+ {l.text} +
+ ))} +
+ {inputRow} + + )} +
+
+ ); +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/autocomplete.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/autocomplete.ts new file mode 100644 index 0000000000..1820670c1c --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/autocomplete.ts @@ -0,0 +1,82 @@ +// ── doAutocomplete ──────────────────────────────────────────────────────────── +// Tab/right-arrow completion for commands and dynamic IDs. + +import type { MutableRefObject } from 'react'; + +import { ALL_COMMANDS } from '../constants'; +import type { GState, ScanEntry } from '../types'; + +export function doAutocomplete( + raw: string, + gRef: MutableRefObject, + scanPool: MutableRefObject, +): string { + const endsWithSpace = raw.endsWith(' '); + const parts = raw.trimEnd().split(/\s+/); + const currentToken = endsWithSpace ? '' : (parts[parts.length - 1] ?? ''); + const tokensBefore = endsWithSpace ? parts : parts.slice(0, -1); + const prefix = endsWithSpace ? raw : raw.slice(0, raw.length - currentToken.length); + + let candidates: string[] = []; + const state = gRef.current; + + if (tokensBefore.length === 0) { + candidates = ALL_COMMANDS as unknown as string[]; + } else { + const cmd = tokensBefore[0].toLowerCase(); + const argPos = tokensBefore.length; + + if (argPos === 1 && cmd === 'crack') { + candidates = scanPool.current.map((s) => s.id); + } else if (argPos === 1 && cmd === 'scan' && !currentToken.startsWith('-')) { + candidates = scanPool.current.map((s) => s.id); + } else if (argPos === 1 && cmd === 'cancel') { + candidates = state.jobs.map((j) => j.id); + } else if (argPos === 1 && cmd === 'collect') { + candidates = ['all', ...state.jobs.filter((j) => j.state === 'ready').map((j) => j.id)]; + } else if (argPos === 1 && cmd === 'sell') { + candidates = ['all', ...state.cache.map((c) => c.id)]; + } else if (argPos === 2 && cmd === 'sell') { + candidates = ['now']; + } else if (argPos === 1 && ['buy', 'use'].includes(cmd)) { + candidates = ['VPN', 'FRG', 'CVR', 'XPL', 'FHVST']; + } else if (argPos === 2 && cmd === 'use') { + candidates = state.jobs.filter((j) => j.state === 'cracking').map((j) => j.id); + } else if (argPos === 1 && cmd === 'upgrade') { + candidates = ['cpu', 'ram', 'stealth']; + } else if (argPos === 2 && cmd === 'upgrade') { + candidates = ['--confirm']; + } else if (cmd === 'scan' && currentToken.startsWith('-')) { + candidates = ['--tier', '--type']; + } else if (argPos === 1 && cmd === 'jobs') { + candidates = ['clear', '--filter']; + } else if (cmd === 'jobs' && currentToken.startsWith('-')) { + candidates = ['--filter']; + } else if (cmd === 'ascend' && currentToken.startsWith('-')) { + candidates = ['--preview', '--confirm']; + } else if (cmd === 'crack' && currentToken.startsWith('-')) { + candidates = ['--method']; + } else if (argPos >= 1 && tokensBefore[argPos - 1] === '--method') { + candidates = ['fragment', 'brute']; + } else if (argPos >= 1 && tokensBefore[argPos - 1] === '--filter') { + candidates = ['complete', 'cracking']; + } else if (cmd === 'help') { + candidates = ALL_COMMANDS as unknown as string[]; + } + } + + const lower = currentToken.toLowerCase(); + const matches = candidates.filter((c) => c.toLowerCase().startsWith(lower)); + if (!matches.length) return raw; + + // Longest common prefix + let lcp = matches[0]; + for (const m of matches.slice(1)) { + let i = 0; + while (i < lcp.length && i < m.length && lcp[i].toLowerCase() === m[i].toLowerCase()) i++; + lcp = lcp.slice(0, i); + } + + if (lcp.length <= currentToken.length) return raw; + return prefix + lcp; +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/context.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/context.ts new file mode 100644 index 0000000000..6a45632119 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/context.ts @@ -0,0 +1,14 @@ +// ── CommandContext ───────────────────────────────────────────────────────────── +// Everything a command function needs, passed from index.tsx. + +import type { MutableRefObject } from 'react'; +import type { GState, ScanEntry } from '../types'; + +export type CommandContext = { + readonly gRef: MutableRefObject; + readonly setG: (updater: (prev: GState) => GState) => void; + readonly print: (text: string, color?: string) => void; + readonly clearLines: () => void; + readonly act: (action: string, params?: Record) => void; + readonly scanPool: MutableRefObject; +}; diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/hardware.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/hardware.ts new file mode 100644 index 0000000000..701fc09927 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/hardware.ts @@ -0,0 +1,207 @@ +// ── rig / upgrade / inv / shop / buy / use commands ─────────────────────────── + +import { CPU_UPGRADES, RAM_UPGRADES, SHOP_ITEMS, STL_UPGRADES } from '../constants'; +import { fmtMoney } from '../format'; +import { getMaxSlots } from '../utils'; +import type { CommandContext } from './context'; + +// ── rig ─────────────────────────────────────────────────────────────────────── + +export function cmdRig(ctx: CommandContext): void { + const { gRef, print } = ctx; + const state = gRef.current; + print(`--- RIG STATS ---`, '#33ff33'); + print(` Handle : ${state.handle}`); + print(` CPU : ${CPU_UPGRADES[state.cpu].name} (×${CPU_UPGRADES[state.cpu].mult})`); + print(` RAM : ${RAM_UPGRADES[state.ram].name} (${getMaxSlots(state)} job slots)`); + print(` Stealth : ${STL_UPGRADES[state.stl].name} (×${STL_UPGRADES[state.stl].mult})`); + print(` Wallet : ${fmtMoney(state.wallet)}`); + print(` Trace : ${state.trace.toFixed(1)}% Heat: ${state.heat}`); + if (state.ascCount > 0) print(` Ascension: ${state.ascCount}x`); +} + +// ── upgrade ─────────────────────────────────────────────────────────────────── + +type UpgradeEntry = { readonly name: string; readonly cost: number; readonly mult?: number; readonly slots?: number }; + +export function cmdUpgrade(args: readonly string[], ctx: CommandContext): void { + const { gRef, setG, print, act } = ctx; + const state = gRef.current; + if (!args[0]) { + print('Usage: upgrade cpu|ram|stealth [--confirm]', '#ff8800'); + return; + } + const part = args[0].toLowerCase(); + const confirm = args.includes('--confirm'); + let table: readonly UpgradeEntry[]; + let current: number; + let label: string; + if (part === 'cpu') { + table = CPU_UPGRADES as unknown as UpgradeEntry[]; + current = state.cpu; + label = 'CPU'; + } else if (part === 'ram') { + table = RAM_UPGRADES as unknown as UpgradeEntry[]; + current = state.ram; + label = 'RAM'; + } else if (part === 'stealth' || part === 'stl') { + table = STL_UPGRADES as unknown as UpgradeEntry[]; + current = state.stl; + label = 'Stealth'; + } else { + print(`Unknown part: ${part}`, '#ff8800'); + return; + } + const next = current + 1; + if (next >= table.length) { + print(`${label} already maxed.`); + return; + } + const upgrade = table[next]; + if (!confirm) { + print(`${label} upgrade: ${table[current].name} -> ${upgrade.name}`); + const bonus = upgrade.mult ? `Multiplier: ×${upgrade.mult}` : `Slots: ${upgrade.slots}`; + print(` ${bonus} | Cost: ${fmtMoney(upgrade.cost)}`); + print(` Wallet: ${fmtMoney(state.wallet)}`); + print(` Run 'upgrade ${part} --confirm' to purchase.`, '#aaaaaa'); + return; + } + if (state.wallet < upgrade.cost) { + print(`Insufficient funds. Need ${fmtMoney(upgrade.cost)}.`, '#ff8800'); + return; + } + const dmPart = part === 'stealth' ? 'stl' : part; + setG((prev) => { + const updated = { ...prev, wallet: prev.wallet - upgrade.cost }; + if (part === 'cpu') return { ...updated, cpu: next }; + if (part === 'ram') return { ...updated, ram: next }; + return { ...updated, stl: next }; + }); + act('upgrade', { part: dmPart, cost: upgrade.cost }); + print(`${label} upgraded to ${upgrade.name}. ${fmtMoney(upgrade.cost)} charged.`, '#33ff33'); +} + +// ── inv ─────────────────────────────────────────────────────────────────────── + +export function cmdInv(ctx: CommandContext): void { + const { gRef, print } = ctx; + const state = gRef.current; + const inv = state.inv as Record; + const items = SHOP_ITEMS.filter((i) => inv[i.id] > 0); + if (!items.length && !state.hasFragHarvester) { + print('Inventory empty.'); + return; + } + print('ITEM ID NAME QTY DESCRIPTION'); + print('-------- ------------------ ----- -----------'); + for (const i of items) { + print(`${i.id.padEnd(8)} ${i.name.padEnd(18)} ${inv[i.id].toString().padEnd(5)} ${i.desc}`); + } + if (state.hasFragHarvester) { + print('FHVST Fragment Harvester 1 Passive fragment generation'); + } +} + +// ── shop ────────────────────────────────────────────────────────────────────── + +export function cmdShop(args: readonly string[], ctx: CommandContext): void { + const { gRef, print } = ctx; + const state = gRef.current; + const catIdx = args.indexOf('--category'); + const cat = catIdx !== -1 ? args[catIdx + 1] : null; + const shopMult = 1 + state.heat * 0.2; + print('ITEM ID NAME COST DESCRIPTION'); + print('-------- ------------------ --------- -----------'); + const show = (id: string, name: string, base: number, desc: string): void => { + const cost = Math.floor(base * shopMult); + print(`${id.padEnd(8)} ${name.padEnd(18)} ${fmtMoney(cost).padEnd(9)} ${desc}`); + }; + if (!cat || cat === 'items') { + for (const i of SHOP_ITEMS) show(i.id, i.name, i.cost, i.desc); + } + if ((!cat || cat === 'items') && !state.hasFragHarvester) { + show('FHVST', 'Frag Harvester', 4500, 'Passive key fragment generation'); + } + if (shopMult > 1) { + print( + `Note: shop prices +${Math.floor((shopMult - 1) * 100)}% due to agency heat.`, + '#ff8800', + ); + } +} + +// ── buy ─────────────────────────────────────────────────────────────────────── + +export function cmdBuy(args: readonly string[], ctx: CommandContext): void { + const { gRef, setG, print, act } = ctx; + const state = gRef.current; + if (!args[0]) { + print('Usage: buy ', '#ff8800'); + return; + } + const iid = args[0].toUpperCase(); + const shopMult = 1 + state.heat * 0.2; + if (iid === 'FHVST') { + if (state.hasFragHarvester) { + print('Fragment Harvester already installed.'); + return; + } + const cost = Math.floor(4500 * shopMult); + if (state.wallet < cost) { + print(`Need ${fmtMoney(cost)}.`, '#ff8800'); + return; + } + setG((prev) => ({ ...prev, wallet: prev.wallet - cost, hasFragHarvester: true })); + act('buy_item', { id: 'FHVST', cost }); + print(`Fragment Harvester installed. Cost: ${fmtMoney(cost)}`, '#33ff33'); + return; + } + const item = SHOP_ITEMS.find((i) => i.id === iid); + if (!item) { + print(`Unknown item: ${iid}`, '#ff8800'); + return; + } + const cost = Math.floor(item.cost * shopMult); + if (state.wallet < cost) { + print(`Need ${fmtMoney(cost)}.`, '#ff8800'); + return; + } + const inv = { ...state.inv } as Record; + inv[iid] = (inv[iid] ?? 0) + 1; + setG((prev) => ({ ...prev, wallet: prev.wallet - cost, inv: inv as typeof state.inv })); + act('buy_item', { id: iid, cost }); + print(`Purchased ${item.name}. Cost: ${fmtMoney(cost)}`, '#33ff33'); +} + +// ── use ─────────────────────────────────────────────────────────────────────── + +export function cmdUse(args: readonly string[], ctx: CommandContext): void { + const { gRef, setG, print, act } = ctx; + const state = gRef.current; + if (args.length < 2) { + print('Usage: use ', '#ff8800'); + return; + } + const iid = args[0].toUpperCase(); + const jid = args[1].toLowerCase(); + if (iid !== 'XPL') { + print(`Cannot use ${iid} this way. Try 'vpn use' or check inv.`, '#ff8800'); + return; + } + if (state.inv.XPL < 1) { + print('No Exploit Kits in inventory.', '#ff8800'); + return; + } + const job = state.jobs.find((j) => j.id === jid && j.state === 'cracking'); + if (!job) { + print(`Active job '${jid}' not found.`, '#ff8800'); + return; + } + setG((prev) => ({ + ...prev, + jobs: prev.jobs.map((j) => (j.id === jid ? { ...j, duration: j.duration * 0.7 } : j)), + inv: { ...prev.inv, XPL: prev.inv.XPL - 1 }, + })); + act('use_xpl', { job_id: jid }); + print(`Exploit Kit applied to job ${jid}. Crack time reduced 30%.`, '#33ff33'); +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/index.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/index.ts new file mode 100644 index 0000000000..c8ed7b55c6 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/index.ts @@ -0,0 +1,53 @@ +// ── Commands barrel + dispatcher ────────────────────────────────────────────── +// Re-exports all command handlers and the main execCommand dispatcher. + +export type { CommandContext } from './context'; +export { doAutocomplete } from './autocomplete'; + +import type { CommandContext } from './context'; +import { cmdScan } from './scan'; +import { cmdCrack, cmdJobs, cmdCancel, cmdCollect, cmdCache } from './jobs'; +import { cmdSell, cmdMarket } from './trading'; +import { cmdTrace, cmdCloak, cmdVpn, cmdLaunder } from './systems'; +import { cmdRig, cmdUpgrade, cmdInv, cmdShop, cmdBuy, cmdUse } from './hardware'; +import { cmdWallet, cmdLog, cmdAscend, cmdGhost, cmdBounty, cmdHelp, cmdExplain } from './meta'; + +export function execCommand(raw: string, ctx: CommandContext): void { + const trimmed = raw.trim(); + if (!trimmed) return; + ctx.print(`> ${trimmed}`, '#33ff33'); + const parts = trimmed.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + + switch (cmd) { + case 'help': cmdHelp(args, ctx); break; + case 'clear': ctx.clearLines(); break; + case 'scan': cmdScan(args, ctx); break; + case 'crack': cmdCrack(args, ctx); break; + case 'jobs': cmdJobs(args, ctx); break; + case 'cancel': cmdCancel(args, ctx); break; + case 'collect': cmdCollect(args, ctx); break; + case 'cache': cmdCache(ctx); break; + case 'sell': cmdSell(args, ctx); break; + case 'market': cmdMarket(args, ctx); break; + case 'trace': cmdTrace(ctx); break; + case 'cloak': cmdCloak(args, ctx); break; + case 'vpn': cmdVpn(args, ctx); break; + case 'launder': cmdLaunder(ctx); break; + case 'rig': cmdRig(ctx); break; + case 'upgrade': cmdUpgrade(args, ctx); break; + case 'inv': cmdInv(ctx); break; + case 'shop': cmdShop(args, ctx); break; + case 'buy': cmdBuy(args, ctx); break; + case 'use': cmdUse(args, ctx); break; + case 'wallet': cmdWallet(args, ctx); break; + case 'log': cmdLog(args, ctx); break; + case 'ascend': cmdAscend(args, ctx); break; + case 'ghost': cmdGhost(ctx); break; + case 'bounty': cmdBounty(ctx); break; + case 'explain': cmdExplain(ctx); break; + default: + ctx.print(`Unknown command: ${cmd}. Type 'help' for commands.`, '#ff8800'); + } +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/jobs.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/jobs.ts new file mode 100644 index 0000000000..30c088dd83 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/jobs.ts @@ -0,0 +1,200 @@ +// ── crack / jobs / cancel / collect / cache commands ────────────────────────── + +import { CPU_UPGRADES } from '../constants'; +import { fmtMoney, fmtTime, progressBar } from '../format'; +import { getCrackDuration, getMaxSlots, makeJobId, rand } from '../utils'; +import type { CommandContext } from './context'; + +// ── crack ───────────────────────────────────────────────────────────────────── + +export function cmdCrack(args: readonly string[], ctx: CommandContext): void { + const { gRef, setG, print, act, scanPool } = ctx; + const state = gRef.current; + if (!args[0]) { + print('Usage: crack [--method fragment]', '#ff8800'); + return; + } + const maxSlots = getMaxSlots(state); + const active = state.jobs.filter((j) => j.state === 'cracking').length; + if (active >= maxSlots) { + print(`RAM full. ${active}/${maxSlots} slots used. Collect jobs or upgrade RAM.`, '#ff8800'); + return; + } + const methodIdx = args.indexOf('--method'); + const fragMethod = methodIdx !== -1 && args[methodIdx + 1] === 'fragment'; + if (fragMethod && state.inv.FRG < 1) { + print('No key fragments in inventory.', '#ff8800'); + return; + } + const target = scanPool.current.find((s) => s.id.toUpperCase() === args[0].toUpperCase()); + if (!target) { + print(`Target '${args[0]}' not found. Run scan first.`, '#ff8800'); + return; + } + if (fragMethod && target.tier.tier < 4) { + print('Fragments only work on T4/T5 ciphers.', '#ff8800'); + return; + } + const cpuMult = CPU_UPGRADES[state.cpu].mult; + const dur = getCrackDuration(target.tier, cpuMult, state.ghost.crack, fragMethod); + const stlMult = 1 + state.stl * 0.6; + const uptimeCap = target.tier.uptime + ? rand(target.tier.uptime[0], target.tier.uptime[1]) * stlMult + : null; + const jid = makeJobId(state.nextId); + + setG((prev) => ({ + ...prev, + jobs: [ + ...prev.jobs, + { + id: jid, + cipher: target.tier.abbrev, + tier: target.tier.tier, + gb: target.gb, + type: target.type, + progress: 0, + duration: dur, + uptimeCap, + state: 'cracking', + }, + ], + inv: fragMethod ? { ...prev.inv, FRG: prev.inv.FRG - 1 } : prev.inv, + nextId: prev.nextId + 1, + })); + + act('crack', { + id: jid, + cipher: target.tier.abbrev, + gb: target.gb, + type: target.type, + duration: dur, + uptime: uptimeCap ?? null, + }); + + print( + `[CRACK] Job ${jid} queued: ${target.tier.abbrev} ${target.gb.toFixed(2)}GB ${target.type} | ETA ${fmtTime(dur)}${fragMethod ? ' [FRAGMENT]' : ''}`, + '#33ff33', + ); +} + +// ── jobs ────────────────────────────────────────────────────────────────────── + +export function cmdJobs(args: readonly string[], ctx: CommandContext): void { + const { gRef, setG, print } = ctx; + const state = gRef.current; + + // ── jobs clear ─────────────────────────────────────────────────────────── + if (args[0] === 'clear') { + const failed = state.jobs.filter((j) => j.state === 'failed'); + if (!failed.length) { + print('No failed jobs to clear.'); + return; + } + setG((prev) => ({ ...prev, jobs: prev.jobs.filter((j) => j.state !== 'failed') })); + print(`Cleared ${failed.length} failed job${failed.length === 1 ? '' : 's'}.`, '#ff8800'); + return; + } + + const filterIdx = args.indexOf('--filter'); + const filterVal = filterIdx !== -1 ? args[filterIdx + 1] : undefined; + const list = state.jobs.filter((j) => + filterVal === 'complete' ? j.state === 'ready' + : filterVal === 'cracking' ? j.state === 'cracking' + : true, + ); + if (!list.length) { + print('No jobs.'); + return; + } + const maxSlots = getMaxSlots(state); + const active = state.jobs.filter((j) => j.state === 'cracking').length; + print(`Jobs: ${active}/${maxSlots} slots active`); + print('ID STATUS CIPHER SIZE TYPE PROGRESS'); + print('----- --------- ------- -------- ----- --------'); + for (const j of list) { + const pct = Math.min(100, (j.progress / j.duration) * 100).toFixed(1); + const bar = progressBar(parseFloat(pct), 15); + const status = + j.state === 'cracking' ? 'RUNNING' : j.state === 'ready' ? 'READY ' : 'FAILED '; + const col = j.state === 'ready' ? '#33ff33' : j.state === 'failed' ? '#ff3333' : undefined; + print( + `${j.id.padEnd(5)} ${status} ${j.cipher.padEnd(7)} ${j.gb.toFixed(2).padEnd(8)} ${j.type} ${bar} ${pct}%`, + col, + ); + if (j.state === 'cracking') { + const remaining = j.duration - j.progress; + const uptimeStr = j.uptimeCap !== null ? ` uptime: ${fmtTime(j.uptimeCap)}` : ''; + print(` ETA: ${fmtTime(remaining)}${uptimeStr}`, '#aaaaaa'); + } + } +} + +// ── cancel ──────────────────────────────────────────────────────────────────── + +export function cmdCancel(args: readonly string[], ctx: CommandContext): void { + const { gRef, setG, print, act } = ctx; + if (!args[0]) { + print('Usage: cancel ', '#ff8800'); + return; + } + const jid = args[0].toLowerCase(); + const job = gRef.current.jobs.find((j) => j.id === jid); + if (!job) { + print(`Job '${jid}' not found.`, '#ff8800'); + return; + } + setG((prev) => ({ ...prev, jobs: prev.jobs.filter((j) => j.id !== jid) })); + act('cancel_job', { id: jid }); + print(`Job ${jid} cancelled.`, '#ff8800'); +} + +// ── collect ─────────────────────────────────────────────────────────────────── + +export function cmdCollect(args: readonly string[], ctx: CommandContext): void { + const { gRef, setG, print, act } = ctx; + const state = gRef.current; + if (!args[0]) { + print('Usage: collect |all', '#ff8800'); + return; + } + const all = args[0].toLowerCase() === 'all'; + const ready = state.jobs.filter( + (j) => j.state === 'ready' && (all || j.id === args[0].toLowerCase()), + ); + if (!ready.length) { + print('No ready jobs to collect.', '#ff8800'); + return; + } + const newCache = [...state.cache]; + const newJobs = state.jobs.filter((j) => !ready.includes(j)); + for (const j of ready) { + const cid = `d${j.id}`; + newCache.push({ id: cid, gb: j.gb, type: j.type }); + print(`Collected ${cid}: ${j.gb.toFixed(2)}GB ${j.type}`, '#33ff33'); + } + setG((prev) => ({ ...prev, jobs: newJobs, cache: newCache })); + if (all) { + act('collect_all'); + } else { + act('collect', { id: ready[0].id }); + } +} + +// ── cache ───────────────────────────────────────────────────────────────────── + +export function cmdCache(ctx: CommandContext): void { + const { gRef, print } = ctx; + const state = gRef.current; + if (!state.cache.length) { + print('Cache empty.'); + return; + } + print('ID SIZE TYPE EST VALUE'); + print('----- -------- ----- ---------------'); + for (const c of state.cache) { + const price = state.market[c.type] * state.ghost.market; + const val = c.gb * price; + print(`${c.id.padEnd(5)} ${c.gb.toFixed(2).padEnd(8)} ${c.type} ${fmtMoney(val)}`); + } +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/meta.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/meta.ts new file mode 100644 index 0000000000..377b0013c3 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/meta.ts @@ -0,0 +1,261 @@ +// ── wallet / log / ascend / ghost / bounty / help / explain commands ────────── + +import { ASC_THRESHOLDS } from '../constants'; +import { fmtMoney } from '../format'; +import type { Ghost } from '../types'; +import { rand } from '../utils'; +import type { CommandContext } from './context'; + +// ── wallet ──────────────────────────────────────────────────────────────────── + +export function cmdWallet(args: readonly string[], ctx: CommandContext): void { + const { gRef, print } = ctx; + const state = gRef.current; + print(`Balance: ${fmtMoney(state.wallet)}`, '#33ff33'); + print(`Total earned this run: ${fmtMoney(state.totalEarned)}`); + const logIdx = args.indexOf('--log'); + const logN = logIdx !== -1 ? parseInt(args[logIdx + 1], 10) || 5 : 0; + if (logN > 0) { + print('Recent transactions:'); + for (const l of state.txLog.slice(0, logN)) print(` ${l}`, '#aaaaaa'); + } +} + +// ── log ─────────────────────────────────────────────────────────────────────── + +export function cmdLog(_args: readonly string[], ctx: CommandContext): void { + const { gRef, print } = ctx; + const entries = gRef.current.txLog.slice(0, 20); + if (!entries.length) { + print('Log empty.'); + return; + } + for (const e of entries) print(` ${e}`, '#aaaaaa'); +} + +// ── bounty ──────────────────────────────────────────────────────────────────── + +export function cmdBounty(ctx: CommandContext): void { + const { gRef, print } = ctx; + const state = gRef.current; + print( + `Agency heat: ${state.heat}`, + state.heat > 2 ? '#ff3333' : state.heat > 0 ? '#ff8800' : '#33ff33', + ); + if (state.heat === 0) { + print(' No heat. Clean record.'); + return; + } + const effects = [ + '', + '1 honeypot in scan pool.', + '3 honeypots. T4/T5 uptime -10%.', + '6 honeypots. Launder cost ×1.5.', + '10 honeypots. Shop prices +20%.', + '30%+ honeypots. Trace builds 2× faster.', + ]; + print(` Effect: ${effects[Math.min(state.heat, 5)] ?? effects[5]}`, '#ff8800'); + print(` Use 'buy CVR' then 'use CVR' to reduce heat.`); +} + +// ── ascend ──────────────────────────────────────────────────────────────────── + +export function cmdAscend(args: readonly string[], ctx: CommandContext): void { + const { gRef, setG, print, act } = ctx; + const state = gRef.current; + const threshold = ASC_THRESHOLDS[state.ascCount] ?? Infinity; + const newCrack = 1 + (state.ascCount + 1) * 0.08; + const newMarket = 1 + (state.ascCount + 1) * 0.05; + const newTrace = Math.max(0.1, 1 - (state.ascCount + 1) * 0.1); + const bonusSlots = state.ascCount >= 3 ? state.ghost.slots + 1 : state.ghost.slots; + + if (args.includes('--preview')) { + print(`Ascension preview (ascension ${state.ascCount + 1}):`); + print(` Required: ${fmtMoney(threshold)} | Earned: ${fmtMoney(state.totalEarned)}`); + print(` GHOST crack speed: ×${newCrack.toFixed(2)}`); + print(` GHOST market bonus: ×${newMarket.toFixed(2)}`); + print(` GHOST trace reduction: ×${newTrace.toFixed(2)}`); + if (bonusSlots > state.ghost.slots) print(` Bonus RAM slot: +1`); + print(` Everything else resets. Handle and GHOST bonuses persist.`); + return; + } + if (state.totalEarned < threshold) { + print( + `Cannot ascend. Need ${fmtMoney(threshold)} earned. Current: ${fmtMoney(state.totalEarned)}.`, + '#ff8800', + ); + return; + } + if (!args.includes('--confirm')) { + print(`Ascension ${state.ascCount + 1} available.`, '#ffff00'); + print(` Earned: ${fmtMoney(state.totalEarned)} Required: ${fmtMoney(threshold)}`); + print(` GHOST crack ×${newCrack.toFixed(2)} market ×${newMarket.toFixed(2)} trace ×${newTrace.toFixed(2)}${bonusSlots > state.ghost.slots ? ' +1 slot' : ''}`); + print(` WARNING: wallet, rig, jobs, cache, and heat all reset.`, '#ff8800'); + print(` Run 'ascend --confirm' to proceed.`, '#aaaaaa'); + return; + } + const newAsc = state.ascCount + 1; + const newGhost: Ghost = { + crack: 1 + newAsc * 0.08, + market: 1 + newAsc * 0.05, + trace: Math.max(0.1, 1 - newAsc * 0.1), + slots: newAsc >= 4 ? newAsc - 3 : 0, + }; + setG(() => ({ + wallet: 0, + cpu: 0, ram: 0, stl: 0, + trace: 0, heat: 0, playtime: 0, + ascCount: newAsc, totalEarned: 0, + ghost: newGhost, + jobs: [], cache: [], + inv: { VPN: 0, FRG: 0, CVR: 0, XPL: 0 }, + market: { CRD: 820, FIN: 1240, CRP: 960, CLS: 3100 }, + cloakOn: false, + lastContract: 0, lastBounty: 0, lastMarketEvent: 0, + fragTimer: rand(45, 90), + hasFragHarvester: false, bountyUnlocked: false, + txLog: [], nextId: 1, + phase: 'playing', + handle: state.handle, + })); + act('ascend'); + print(`[ASCENSION ${newAsc}] Run reset. GHOST bonuses banked.`, '#33ff33'); + print( + ` Crack ×${newGhost.crack.toFixed(2)} Market ×${newGhost.market.toFixed(2)} Trace ×${newGhost.trace.toFixed(2)}${newGhost.slots > 0 ? ` +${newGhost.slots} slot(s)` : ''}`, + '#33ff33', + ); +} + +// ── ghost ───────────────────────────────────────────────────────────────────── + +export function cmdGhost(ctx: CommandContext): void { + const { gRef, print } = ctx; + const state = gRef.current; + if (state.ascCount === 0) { + print('No GHOST bonuses yet. Ascend to earn bonuses.'); + return; + } + print(`GHOST bonuses (${state.ascCount} ascensions):`, '#33ff33'); + print(` Crack speed : ×${state.ghost.crack.toFixed(2)}`); + print(` Market prices: ×${state.ghost.market.toFixed(2)}`); + print(` Trace rate : ×${state.ghost.trace.toFixed(2)}`); + if (state.ghost.slots > 0) print(` Bonus slots : +${state.ghost.slots}`); + const next = ASC_THRESHOLDS[state.ascCount]; + if (next !== undefined) print(` Next ascension at: ${fmtMoney(next)}`); +} + +// ── help ───────────────────────────────────────────────────────────────────── + +export function cmdHelp(args: readonly string[], ctx: CommandContext): void { + const { print } = ctx; + if (args.length > 0) { + const c = args[0].toLowerCase(); + const helps: Record = { + scan: 'scan [--tier N] [--type TYPE] List targets. scan Probe a target for full stats.', + crack: 'crack [--method fragment] Queue a crack job.', + jobs: 'jobs [--filter complete|cracking] List jobs. jobs clear Remove failed jobs.', + cancel: 'cancel Abort a job.', + collect: 'collect |all Move ready data to cache.', + cache: 'cache View cached data.', + sell: 'sell now Sell one item. sell all Sell entire cache at market rate.', + market: 'market [--history] Current market prices.', + trace: 'trace Show trace % and status.', + cloak: 'cloak [off] Toggle stealth mode.', + vpn: 'vpn use Consume a VPN Burst.', + launder: 'launder Pay to reset trace.', + rig: 'rig Show hardware stats.', + upgrade: 'upgrade cpu|ram|stealth [--confirm] Upgrade hardware.', + inv: 'inv [--type keys] List inventory.', + shop: 'shop [--category stealth|cpu|ram|items] Browse shop.', + buy: 'buy Purchase from shop.', + use: 'use Apply item to job.', + wallet: 'wallet [--log N] Show balance.', + log: 'log [--filter sales|cracks|trace] Activity log.', + ascend: 'ascend [--preview] Preview ascension. ascend --confirm Execute ascension.', + ghost: 'ghost Show GHOST bonuses.', + bounty: 'bounty Check agency heat.', + explain: 'explain Full walkthrough of how to play.', + clear: 'clear Wipe terminal.', + }; + print(helps[c] ?? `No help for '${c}'.`, '#aaffaa'); + } else { + print('BYTECRAWL v1.0 | Available commands:', '#33ff33'); + print(' scan crack jobs cancel collect cache'); + print(' sell market trace cloak vpn launder'); + print(' rig upgrade inv shop buy use'); + print(' wallet log ascend ghost bounty explain help clear'); + print("Type 'help ' for details.", '#aaaaaa'); + } +} + +// ── explain ─────────────────────────────────────────────────────────────────── + +export function cmdExplain(ctx: CommandContext): void { + const { print } = ctx; + print(''); + print('BYTECRAWL: HOW TO PLAY', '#33ff33'); + print('======================', '#33ff33'); + print('An idle hacking terminal. Queue crack jobs, sell stolen data,'); + print('upgrade your rig, manage trace before you get burned.'); + print(''); + print('THE LOOP:', '#33ff33'); + print(' 1. scan Find servers to crack'); + print(' 2. scan SRV-XX Inspect a target (cipher, ETA, trace risk)'); + print(' 3. crack SRV-XX Queue the job (uses 1 RAM slot)'); + print(' 4. jobs Watch progress — jobs finish on their own'); + print(' 5. collect all Pull finished data into cache'); + print(' 6. sell now Sell at current market rate'); + print(' 7. upgrade Reinvest into better hardware'); + print(' Repeat from step 1. Multiple jobs run in parallel (RAM limited).'); + print(''); + print('TRACE:', '#33ff33'); + print(' Every active job bleeds trace%. Hit 100% and you are BURNED:'); + print(' all jobs and cache wiped, wallet docked 50%, agency heat +1.'); + print(' Reset methods: launder (costs money), vpn use (costs a VPN item),'); + print(' or stop all jobs and wait for passive decay (0.12%/min, free).'); + print(' Cloak halves trace gain at -20% crack speed: cloak / cloak off'); + print(''); + print('CIPHERS:', '#33ff33'); + print(' Abbrev Tier Name Data types Uptime window'); + print(' ------ ---- ------------- ------------- -------------'); + print(' C7 T1 CAESAR-7 CRD none'); + print(' VX T2 VIGENERE-X CRD, FIN none'); + print(' E4 T3 ENIGMA-IV FIN, CRP none'); + print(' ON T4 ONYX-256 CRP, CLS yes — server can go offline'); + print(' WR T5 WRAITH-512 CLS yes — server can go offline', '#ff8800'); + print(' T4/T5 jobs fail if the uptime window expires before you collect.', '#ff8800'); + print(' Use a Key Fragment (FRG) to halve crack time on T4/T5.'); + print(''); + print('MARKET:', '#33ff33'); + print(' Data prices drift constantly. CLASSIFIED (CLS) spikes upward often.'); + print(' Run market to see live prices. Sell when prices are high.'); + print(''); + print('UPGRADES (priority order):', '#33ff33'); + print(' CPU first — multiplies crack speed directly'); + print(' Stealth early — reduces trace/min, lets you run more jobs safely'); + print(' RAM after — more slots only help if CPU can keep up'); + print(' Run: upgrade cpu / upgrade ram / upgrade stealth'); + print(' Preview cost then add --confirm to buy.'); + print(''); + print('SHOP ITEMS:', '#33ff33'); + print(' VPN $200 Instant trace reset (buy several, use when critical)'); + print(' XPL $900 Exploit Kit: -30% crack time on one job'); + print(' FRG $450 Key Fragment: halves T4/T5 crack time'); + print(' CVR $1200 Cover Story: removes 1 agency heat level'); + print(' Run: shop / buy VPN / inv'); + print(''); + print('ASCENSION:', '#33ff33'); + print(' Once total earned this run hits $350K, run ascend.'); + print(' Everything resets. You bank permanent GHOST bonuses:'); + print(' faster cracks, better market prices, lower trace rate.'); + print(' Each cycle earns faster than the last. Run ghost to inspect.'); + print(''); + print('TIPS:', '#33ff33'); + print(' Right arrow autocompletes commands and IDs.'); + print(' Up/Down arrows scroll command history.'); + print(' scan refreshes targets each use — IDs change.'); + print(' collect all is faster than collecting one at a time.'); + print(' jobs clear removes failed jobs from the list.'); + print(' Watch the header: trace and slot count update in real time.'); + print(''); +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/scan.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/scan.ts new file mode 100644 index 0000000000..8871941832 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/scan.ts @@ -0,0 +1,99 @@ +// ── scan command (includes inline connect/probe subcommand) ─────────────────── + +import type { MutableRefObject } from 'react'; + +import { + CIPHER_TIERS, + CPU_UPGRADES, + DATA_CEILINGS, + DATA_FLOORS, + DATA_FULLNAMES, + STL_UPGRADES, +} from '../constants'; +import type { GState, ScanEntry } from '../types'; +import { fmtMoney, fmtTime } from '../format'; +import { rand } from '../utils'; +import type { CommandContext } from './context'; + +// ── refreshScan ─────────────────────────────────────────────────────────────── + +export function refreshScan( + filterTier: number | undefined, + filterType: string | undefined, + g: GState, + scanPool: MutableRefObject, +): ScanEntry[] { + const pool: ScanEntry[] = []; + for (let i = 0; i < 8; i++) { + const tier = CIPHER_TIERS[Math.floor(Math.random() * CIPHER_TIERS.length)]; + const type = tier.types[Math.floor(Math.random() * tier.types.length)]; + if (filterTier !== undefined && tier.tier !== filterTier) continue; + if ( + filterType !== undefined && + !type.toLowerCase().includes(filterType.toLowerCase()) && + DATA_FULLNAMES[type].toLowerCase() !== filterType.toLowerCase() + ) continue; + const gb = rand(tier.minGb, tier.maxGb); + pool.push({ id: `SRV-${(i + 1).toString().padStart(2, '0')}`, tier, type, gb }); + } + scanPool.current = pool; + return pool; +} + +// ── scan ────────────────────────────────────────────────────────────────────── + +export function cmdScan(args: readonly string[], ctx: CommandContext): void { + const { gRef, print, scanPool } = ctx; + const state = gRef.current; + + // scan : probe a specific target from the current scan pool + if (args[0] && !args[0].startsWith('-')) { + const target = scanPool.current.find((s) => s.id.toUpperCase() === args[0].toUpperCase()); + if (!target) { + print(`Target '${args[0]}' not found. Run scan first.`, '#ff8800'); + return; + } + const cpuMult = CPU_UPGRADES[state.cpu].mult; + const activeCount = Math.max(1, state.jobs.filter((j) => j.state === 'cracking').length + 1); + const eta = + ((target.tier.minMin + target.tier.maxMin) / 2 / (cpuMult * state.ghost.crack)) * activeCount; + const tm = (target.tier.tppm / STL_UPGRADES[state.stl].mult / state.ghost.trace).toFixed(4); + print(`--- ${target.id} ---`, '#33ff33'); + print(` Cipher : ${target.tier.name} [${target.tier.abbrev}] T${target.tier.tier}`); + print(` Payload : ${target.gb.toFixed(2)} GB ${DATA_FULLNAMES[target.type]}`); + print(` ETA : ~${fmtTime(eta)} at current CPU share`); + print(` Trace : ${tm}%/min`); + if (target.tier.uptime) { + const stlMult = 1 + state.stl * 0.6; + print( + ` Uptime : ${Math.floor(target.tier.uptime[0] * stlMult)}-${Math.floor(target.tier.uptime[1] * stlMult)} min window`, + '#ff8800', + ); + } + print( + ` Value : ${fmtMoney(target.gb * DATA_FLOORS[target.type])}-${fmtMoney(target.gb * DATA_CEILINGS[target.type])}`, + ); + return; + } + + // scan [--tier N] [--type TYPE]: refresh and list targets + let filterTier: number | undefined; + let filterType: string | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--tier' && args[i + 1]) filterTier = parseInt(args[i + 1], 10); + if (args[i] === '--type' && args[i + 1]) filterType = args[i + 1]; + } + const pool = refreshScan(filterTier, filterType, gRef.current, scanPool); + if (!pool.length) { + print('No targets found matching filter.', '#ff8800'); + return; + } + print('TARGET CIPHER DATA SIZE TRACE/MIN'); + print('---------- ----------- -------- -------- ---------'); + for (const s of pool) { + const tm = (s.tier.tppm / STL_UPGRADES[gRef.current.stl].mult).toFixed(4); + print( + `${s.id.padEnd(10)} ${s.tier.abbrev.padEnd(11)} ${s.type.padEnd(8)} ${s.gb.toFixed(2).padEnd(8)}GB ${tm}%/m`, + ); + } +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/systems.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/systems.ts new file mode 100644 index 0000000000..9ed43580aa --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/systems.ts @@ -0,0 +1,68 @@ +// ── trace / cloak / vpn / launder commands ──────────────────────────────────── + +import { fmtMoney, progressBar } from '../format'; +import { getTraceStatus } from '../utils'; +import type { CommandContext } from './context'; + +// ── trace ───────────────────────────────────────────────────────────────────── + +export function cmdTrace(ctx: CommandContext): void { + const { gRef, print } = ctx; + const state = gRef.current; + const { label, color } = getTraceStatus(state.trace); + const bar = progressBar(state.trace, 30); + print(`Trace: ${bar} ${state.trace.toFixed(1)}% [${label}]`, color); + if (state.trace >= 61) { + const effect = state.trace >= 81 ? 'Jobs slowed 15%' : 'Jobs slowed 5%'; + print(` Effect: ${effect}`, '#ff8800'); + } + print(` Cloak: ${state.cloakOn ? 'ON (-20% crack speed)' : 'off'}`); + print(` Heat: ${state.heat} ${state.heat > 0 ? '(use bounty to inspect)' : ''}`); +} + +// ── cloak ───────────────────────────────────────────────────────────────────── + +export function cmdCloak(args: readonly string[], ctx: CommandContext): void { + const { setG, print, act } = ctx; + const off = args[0] === 'off'; + setG((prev) => ({ ...prev, cloakOn: !off })); + act('cloak', { on: off ? 0 : 1 }); + print( + off + ? 'Cloak deactivated.' + : 'Cloak active. Trace generation halved. Crack speed -20%.', + '#33ff33', + ); +} + +// ── vpn ─────────────────────────────────────────────────────────────────────── + +export function cmdVpn(args: readonly string[], ctx: CommandContext): void { + const { gRef, setG, print, act } = ctx; + if (args[0] !== 'use') { + print('Usage: vpn use ', '#ff8800'); + return; + } + if (gRef.current.inv.VPN < 1) { + print('No VPN Bursts in inventory.', '#ff8800'); + return; + } + setG((prev) => ({ ...prev, trace: 0, inv: { ...prev.inv, VPN: prev.inv.VPN - 1 } })); + act('use_vpn'); + print('VPN Burst consumed. Trace reset to 0%.', '#33ff33'); +} + +// ── launder ─────────────────────────────────────────────────────────────────── + +export function cmdLaunder(ctx: CommandContext): void { + const { gRef, setG, print, act } = ctx; + const state = gRef.current; + const cost = Math.floor((400 + (state.trace / 100) * 400) * (1 + state.heat * 0.5)); + if (state.wallet < cost) { + print(`Insufficient funds. Launder costs ${fmtMoney(cost)}.`, '#ff8800'); + return; + } + setG((prev) => ({ ...prev, wallet: prev.wallet - cost, trace: 0 })); + act('launder', { cost }); + print(`Laundered. Trace reset. Cost: ${fmtMoney(cost)}`, '#33ff33'); +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/trading.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/trading.ts new file mode 100644 index 0000000000..5f4186de5e --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/commands/trading.ts @@ -0,0 +1,90 @@ +// ── sell + market commands ───────────────────────────────────────────────────── + +import { DATA_CEILINGS, DATA_FLOORS, DATA_FULLNAMES } from '../constants'; +import { fmtMoney } from '../format'; +import type { CommandContext } from './context'; + +// ── sell ────────────────────────────────────────────────────────────────────── + +export function cmdSell(args: readonly string[], ctx: CommandContext): void { + const { gRef, setG, print, act } = ctx; + const state = gRef.current; + + // ── sell all ────────────────────────────────────────────────────────────── + if (args[0]?.toLowerCase() === 'all') { + if (!state.cache.length) { + print('Cache empty. Nothing to sell.', '#ff8800'); + return; + } + let total = 0; + const newLog: string[] = []; + for (const item of state.cache) { + const price = state.market[item.type] * state.ghost.market; + const revenue = Math.floor(item.gb * price); + total += revenue; + print(` Sold ${item.id}: ${item.gb.toFixed(2)}GB ${DATA_FULLNAMES[item.type]} — ${fmtMoney(revenue)}`, '#33ff33'); + newLog.push(`SELL ${item.id} ${item.gb.toFixed(2)}GB ${item.type} +${fmtMoney(revenue)}`); + } + setG((prev) => ({ + ...prev, + wallet: prev.wallet + total, + totalEarned: prev.totalEarned + total, + cache: [], + txLog: [...newLog, ...prev.txLog].slice(0, 100), + })); + act('sell_all', { total }); + print(`Total: ${fmtMoney(total)}`, '#33ff33'); + return; + } + + if (args.length < 2) { + print('Usage: sell now | sell all', '#ff8800'); + return; + } + const cid = args[0].toLowerCase(); + const item = state.cache.find((c) => c.id === cid); + if (!item) { + print(`Cache item '${cid}' not found.`, '#ff8800'); + return; + } + const price = state.market[item.type] * state.ghost.market; + const revenue = Math.floor(item.gb * price); + setG((prev) => ({ + ...prev, + wallet: prev.wallet + revenue, + totalEarned: prev.totalEarned + revenue, + cache: prev.cache.filter((c) => c.id !== cid), + txLog: [ + `SELL ${cid} ${item.gb.toFixed(2)}GB ${item.type} +${fmtMoney(revenue)}`, + ...prev.txLog.slice(0, 99), + ], + })); + act('sell', { id: cid, revenue }); + print( + `Sold ${cid}: ${item.gb.toFixed(2)}GB ${DATA_FULLNAMES[item.type]} for ${fmtMoney(revenue)}`, + '#33ff33', + ); +} + +// ── market ──────────────────────────────────────────────────────────────────── + +export function cmdMarket(_args: readonly string[], ctx: CommandContext): void { + const { gRef, print } = ctx; + const state = gRef.current; + const BASELINES: Record = { CRD: 820, FIN: 1240, CRP: 960, CLS: 3100 }; + const trend = (k: string): string => { + const cur = state.market[k]; + const base = BASELINES[k] ?? cur; + if (cur > base * 1.1) return '↑'; + if (cur < base * 0.9) return '↓'; + return '='; + }; + print('TYPE PRICE/GB TREND FLOOR CEILING'); + print('------ ---------- ------- -------- --------'); + for (const k of ['CRD', 'FIN', 'CRP', 'CLS']) { + const p = Math.floor(state.market[k] * state.ghost.market); + print( + `${k} $${p.toString().padEnd(10)} ${trend(k)} $${DATA_FLOORS[k]} $${DATA_CEILINGS[k]}`, + ); + } +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/constants.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/constants.ts new file mode 100644 index 0000000000..c1cbb4125f --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/constants.ts @@ -0,0 +1,63 @@ +export const TICK_MS = 3000; +export const MARKET_TICK_MS = 9000; +export const CONTRACT_INTERVAL = 45; +export const BOUNTY_INTERVAL = 60; +export const IDLE_DECAY_PER_MIN = 0.12; + +export const CPU_UPGRADES = [ + { name: 'Stock Rig', mult: 1.0, cost: 0 }, + { name: 'Overlock Kit', mult: 1.4, cost: 2000 }, + { name: 'Liquid Cool', mult: 2.0, cost: 5500 }, + { name: 'FPGA Cluster', mult: 3.5, cost: 14000 }, + { name: 'ASIC Array', mult: 6.0, cost: 38000 }, + { name: 'Quantum Coprocessor', mult: 12.0, cost: 110000 }, + { name: 'Neural Fabric', mult: 22.0, cost: 280000 }, +] as const; + +export const RAM_UPGRADES = [ + { name: 'Stock', slots: 2, cost: 0 }, + { name: 'DDR5 Expansion', slots: 4, cost: 1500 }, + { name: 'Server Grade', slots: 6, cost: 6000 }, + { name: 'Rack Array', slots: 10, cost: 20000 }, + { name: 'Distributed Node', slots: 16, cost: 45000 }, + { name: 'Mesh Cluster', slots: 24, cost: 130000 }, +] as const; + +export const STL_UPGRADES = [ + { name: 'No Stealth', mult: 1.0, cost: 0 }, + { name: 'Tor Relay', mult: 1.5, cost: 800 }, + { name: 'VPN Chain', mult: 2.5, cost: 3000 }, + { name: 'Onion Router', mult: 4.0, cost: 9000 }, + { name: 'Ghost Protocol', mult: 8.0, cost: 28000 }, + { name: 'Zero Footprint', mult: 16.0, cost: 85000 }, + { name: 'Phantom Layer', mult: 32.0, cost: 220000 }, +] as const; + +export const CIPHER_TIERS = [ + { tier: 1, abbrev: 'C7', name: 'CAESAR-7', minMin: 2, maxMin: 8, minGb: 0.2, maxGb: 0.8, tppm: 0.003, types: ['CRD'] as string[], uptime: null as [number, number] | null }, + { tier: 2, abbrev: 'VX', name: 'VIGENERE-X', minMin: 15, maxMin: 35, minGb: 0.5, maxGb: 2.0, tppm: 0.010, types: ['CRD', 'FIN'] as string[], uptime: null as [number, number] | null }, + { tier: 3, abbrev: 'E4', name: 'ENIGMA-IV', minMin: 40, maxMin: 100, minGb: 1.5, maxGb: 5.0, tppm: 0.028, types: ['FIN', 'CRP'] as string[], uptime: null as [number, number] | null }, + { tier: 4, abbrev: 'ON', name: 'ONYX-256', minMin: 180, maxMin: 420, minGb: 4.0, maxGb: 12.0, tppm: 0.075, types: ['CRP', 'CLS'] as string[], uptime: [60, 150] as [number, number] }, + { tier: 5, abbrev: 'WR', name: 'WRAITH-512', minMin: 540, maxMin: 1440, minGb: 10.0, maxGb: 30.0, tppm: 0.190, types: ['CLS'] as string[], uptime: [30, 80] as [number, number] }, +]; + +export const DATA_BASELINES: Record = { CRD: 820, FIN: 1240, CRP: 960, CLS: 3100 }; +export const DATA_FLOORS: Record = { CRD: 480, FIN: 700, CRP: 550, CLS: 1400 }; +export const DATA_CEILINGS: Record = { CRD: 1200, FIN: 2500, CRP: 2000, CLS: 8000 }; +export const DATA_FULLNAMES: Record = { CRD: 'CREDENTIALS', FIN: 'FINANCIAL', CRP: 'CORPORATE', CLS: 'CLASSIFIED' }; + +export const ASC_THRESHOLDS = [350000, 1200000, 2200000, 8000000, 25000000, 80000000]; + +export const SHOP_ITEMS = [ + { id: 'VPN', name: 'VPN Burst', cost: 200, desc: 'Instantly resets trace to 0%' }, + { id: 'FRG', name: 'Key Fragment', cost: 450, desc: 'Halves crack time for T4/T5 jobs' }, + { id: 'CVR', name: 'Cover Story', cost: 1200, desc: 'Removes 1 agency heat level' }, + { id: 'XPL', name: 'Exploit Kit', cost: 900, desc: 'Reduces one job crack time by 30%' }, +] as const; + +export const ALL_COMMANDS = [ + 'scan', 'crack', 'jobs', 'cancel', 'collect', 'cache', + 'sell', 'market', 'trace', 'cloak', 'vpn', 'launder', 'rig', + 'upgrade', 'inv', 'shop', 'buy', 'use', 'wallet', 'log', + 'ascend', 'ghost', 'bounty', 'explain', 'help', 'clear', +]; diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/format.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/format.ts new file mode 100644 index 0000000000..56e0596e34 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/format.ts @@ -0,0 +1,24 @@ +// ── Display-only formatting helpers ────────────────────────────────────────── +// Pure functions — no game-state knowledge, no side-effects. + +/** Format a dollar amount with K/M suffixes. */ +export function fmtMoney(n: number): string { + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}K`; + return `$${Math.floor(n)}`; +} + +/** Format a duration in minutes as a human-readable string. */ +export function fmtTime(mins: number): string { + if (mins < 1) return '<1m'; + if (mins < 60) return `${Math.ceil(mins)}m`; + const h = Math.floor(mins / 60); + const m = Math.ceil(mins % 60); + return m > 0 ? `${h}h${m}m` : `${h}h`; +} + +/** Render an ASCII progress bar, e.g. [######..............] */ +export function progressBar(pct: number, width = 20): string { + const filled = Math.round((pct / 100) * width); + return `[${'#'.repeat(filled)}${'.'.repeat(width - filled)}]`; +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/index.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/index.ts new file mode 100644 index 0000000000..251bccf1ed --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/index.ts @@ -0,0 +1,23 @@ +// ── Hooks barrel ────────────────────────────────────────────────────────────── +// Composes the tick loop and market loop into a single hook for index.tsx. + +import { + type Dispatch as ReactDispatch, + type MutableRefObject, + type SetStateAction, +} from 'react'; + +import type { GState, Line } from '../types'; +import { useMarketLoop } from './useMarketLoop'; +import { useTickLoop } from './useTickLoop'; + +export function useGameLoop( + phase: 'setup' | 'playing', + gRef: MutableRefObject, + setG: ReactDispatch>, + setLines: ReactDispatch>, + act: (action: string, params?: Record) => void, +): void { + useTickLoop(phase, gRef, setG, setLines, act); + useMarketLoop(phase, setG, setLines, act); +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/useMarketLoop.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/useMarketLoop.ts new file mode 100644 index 0000000000..e14ff00ae9 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/useMarketLoop.ts @@ -0,0 +1,56 @@ +// ── useMarketLoop ───────────────────────────────────────────────────────────── +// Slower market-drift interval (separate from the main tick rate). + +import { + type Dispatch as ReactDispatch, + type SetStateAction, + useEffect, + useRef, +} from 'react'; + +import { MARKET_TICK_MS } from '../constants'; +import { computeMarketTick } from '../logic'; +import type { GState, Line } from '../types'; + +export function useMarketLoop( + phase: 'setup' | 'playing', + setG: ReactDispatch>, + setLines: ReactDispatch>, + act: (action: string, params?: Record) => void, +): void { + const actRef = useRef(act); + useEffect(() => { + actRef.current = act; + }, [act]); + + useEffect(() => { + if (phase !== 'playing') return; + + const id = setInterval(() => { + setG((prev) => { + const { newMarket, newLastEvent, event } = computeMarketTick( + prev.market, + prev.lastMarketEvent, + ); + + if (event) { + setLines((l) => [ + ...l, + { text: `[MARKET] ${event.key} ${event.dir}: $${event.price}/GB`, color: '#ffff00' }, + ]); + } + + actRef.current('market_tick', { + crd: newMarket.CRD, + fin: newMarket.FIN, + crp: newMarket.CRP, + cls: newMarket.CLS, + }); + + return { ...prev, market: newMarket, lastMarketEvent: newLastEvent }; + }); + }, MARKET_TICK_MS); + + return () => clearInterval(id); + }, [phase]); // eslint-disable-line react-hooks/exhaustive-deps +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/useTickLoop.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/useTickLoop.ts new file mode 100644 index 0000000000..a92727a63a --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/hooks/useTickLoop.ts @@ -0,0 +1,196 @@ +// ── useTickLoop ─────────────────────────────────────────────────────────────── +// Per-tick game loop: job progress, trace, burn, contracts, bounties, fragments. + +import { + type Dispatch as ReactDispatch, + type MutableRefObject, + type SetStateAction, + useEffect, + useRef, +} from 'react'; + +import { CPU_UPGRADES, STL_UPGRADES, TICK_MS } from '../constants'; +import { + computeBountyTick, + computeBurnTick, + computeContractTick, + computeFragTick, + computeJobTick, + computeTraceTick, + traceSlowFactor, +} from '../logic'; +import type { GState, Line } from '../types'; +import { fmtMoney } from '../format'; + +export function useTickLoop( + phase: 'setup' | 'playing', + gRef: MutableRefObject, + setG: ReactDispatch>, + setLines: ReactDispatch>, + act: (action: string, params?: Record) => void, +): void { + const actRef = useRef(act); + useEffect(() => { + actRef.current = act; + }, [act]); + + useEffect(() => { + if (phase !== 'playing') return; + + const id = setInterval(() => { + setG((prev) => { + const cpuMult = CPU_UPGRADES[prev.cpu].mult; + const stlMult = STL_UPGRADES[prev.stl].mult; + const activeJobs = prev.jobs.filter((j) => j.state === 'cracking'); + const jobCount = Math.max(1, activeJobs.length); + const slowFactor = traceSlowFactor(prev.trace); + const cloakPenalty = prev.cloakOn ? 0.8 : 1.0; + + // ── Job progress ──────────────────────────────────────────────────── + const { newJobs, stateChanges, traceSpike } = computeJobTick( + prev.jobs, + cpuMult, + prev.ghost.crack, + slowFactor, + cloakPenalty, + jobCount, + ); + + // Emit messages for completed/failed jobs + for (const sc of stateChanges) { + const job = prev.jobs.find((j) => j.id === sc.id); + if (!job) continue; + if (sc.state === 'ready') { + setLines((l) => [ + ...l, + { + text: `[COMPLETE] Job ${job.id} ready for collection (${job.gb.toFixed(2)}GB ${job.type}).`, + color: '#33ff33', + }, + ]); + } else { + setLines((l) => [ + ...l, + { + text: `[ALERT] Job ${job.id} failed: server went offline. Trace spike.`, + color: '#ff8800', + }, + ]); + } + } + + // ── Trace ─────────────────────────────────────────────────────────── + const newTrace = computeTraceTick( + prev.trace, + activeJobs, + stlMult, + prev.ghost.trace, + traceSpike, + ); + + // ── Burn ──────────────────────────────────────────────────────────── + const burnResult = computeBurnTick(newTrace, prev.heat, prev.wallet, newJobs, prev.cache); + if (burnResult.burned) { + setLines((l) => [ + ...l, + { + text: '[!!! BURNED !!!] Trace hit 100%. All jobs lost. Agency heat +1.', + color: '#ff0000', + }, + ]); + } + + // ── Contract ──────────────────────────────────────────────────────── + const { newLastContract, payout: contractPayout } = computeContractTick( + prev.lastContract, + prev.cpu, + prev.stl, + prev.ghost.market, + ); + if (contractPayout > 0) { + setLines((l) => [ + ...l, + { text: `[CONTRACT] Guaranteed payout: ${fmtMoney(contractPayout)}`, color: '#33ff33' }, + ]); + } + + // ── Bounty ────────────────────────────────────────────────────────── + const { newLastBounty, bountyPayout, justUnlocked, newBountyUnlocked } = computeBountyTick( + prev.lastBounty, + prev.bountyUnlocked, + prev.playtime, + prev.cpu, + prev.stl, + prev.ghost.market, + ); + if (justUnlocked) { + setLines((l) => [ + ...l, + { + text: '[SYSTEM] Bounty network unlocked. Anonymous payouts will begin.', + color: '#33ff33', + }, + ]); + } + if (bountyPayout > 0) { + setLines((l) => [ + ...l, + { text: `[BOUNTY] Anonymous payout: ${fmtMoney(bountyPayout)}`, color: '#33cc33' }, + ]); + } + + // ── Fragment harvester ─────────────────────────────────────────────── + const { newFragTimer, generated: fragGenerated, newInv } = computeFragTick( + prev.fragTimer, + prev.hasFragHarvester, + prev.inv, + ); + if (fragGenerated) { + setLines((l) => [ + ...l, + { text: '[HARVESTER] Key fragment generated.', color: '#33cc33' }, + ]); + } + + // ── Persist to DM ─────────────────────────────────────────────────── + const totalWalletDelta = contractPayout + bountyPayout; + actRef.current('tick', { + trace: burnResult.newTrace, + playtime: prev.playtime + 1, + contract_amount: contractPayout > 0 ? contractPayout : null, + bounty_amount: bountyPayout > 0 ? bountyPayout : null, + burned: burnResult.burned ? 1 : null, + frag_generated: fragGenerated ? 1 : null, + bounty_unlock: justUnlocked ? 1 : null, + }); + for (const sc of stateChanges) { + actRef.current('job_update', { + id: sc.id, + state: sc.state, + progress: sc.progress, + uptime: sc.uptime ?? null, + }); + } + + // ── Build next state ───────────────────────────────────────────────── + return { + ...prev, + playtime: prev.playtime + 1, + jobs: burnResult.newJobs as GState['jobs'], + cache: burnResult.newCache as GState['cache'], + trace: burnResult.newTrace, + heat: burnResult.newHeat, + wallet: burnResult.newWallet + totalWalletDelta, + totalEarned: prev.totalEarned + totalWalletDelta, + lastContract: newLastContract, + lastBounty: newLastBounty, + bountyUnlocked: newBountyUnlocked || prev.bountyUnlocked, + fragTimer: newFragTimer, + inv: newInv, + }; + }); + }, TICK_MS); + + return () => clearInterval(id); + }, [phase]); // eslint-disable-line react-hooks/exhaustive-deps +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/index.tsx b/tgui/packages/tgui/interfaces/NtosBytecrawl/index.tsx new file mode 100644 index 0000000000..f64141d3a6 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/index.tsx @@ -0,0 +1,182 @@ +import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; + +import { storage } from 'common/storage'; +import { useBackend } from 'tgui/backend'; +import { NtosWindow } from 'tgui/layouts'; +import { Box } from 'tgui-core/components'; + +import { doAutocomplete, execCommand } from './commands'; +import type { CommandContext } from './commands'; +import { STORAGE_KEY } from './store'; +import type { GState, Line, ScanEntry } from './types'; +import { useGameLoop } from './hooks'; +import { defaultState, getMaxSlots, getTraceStatus } from './utils'; +import { Terminal } from './Terminal'; + +const W = 720; +const H = 540; + +export const NtosBytecrawl = (_props: unknown) => { + const { act } = useBackend(); + + // ── Terminal output lines ────────────────────────────────────────────────── + const [lines, setLines] = useState([]); + const outputRef = useRef(null); + + // Auto-scroll output to bottom on new lines + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [lines]); + + // ── Game state ───────────────────────────────────────────────────────────── + // Persisted via common/storage — load on mount, save on every change. + const [g, setG] = useState(defaultState()); + const gRef = useRef(g); + useEffect(() => { + gRef.current = g; + }, [g]); + + // Load persisted save on mount + useEffect(() => { + const load = async () => { + const saved = await storage.get(STORAGE_KEY); + if (saved) setG(saved as GState); + }; + load(); + }, []); + + // Persist on every state change + useEffect(() => { + storage.set(STORAGE_KEY, g); + }, [g]); + + // ── Scan pool (client-side, regenerated per scan command) ────────────────── + const scanPool = useRef([]); + + // ── Command input state ──────────────────────────────────────────────────── + const [input, setInput] = useState(''); + const [cmdHistory, setCmdHistory] = useState([]); + const [histIdx, setHistIdx] = useState(-1); + const inputRef = useRef(null); + + // ── Setup screen input ───────────────────────────────────────────────────── + const [setupInput, setSetupInput] = useState(''); + + // ── Game loop (tick + market tick intervals) ─────────────────────────────── + useGameLoop(g.phase, gRef, setG, setLines, act); + + // ── Helpers ──────────────────────────────────────────────────────────────── + const print = (text: string, color?: string) => + setLines((prev) => [...prev, { text, color }]); + + const clearLines = () => setLines([]); + + // ── Command context ──────────────────────────────────────────────────────── + const ctx: CommandContext = { + gRef, + setG, + print, + clearLines, + act, + scanPool, + }; + + // ── Keyboard handler (main terminal) ────────────────────────────────────── + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + const raw = input; + setInput(''); + setHistIdx(-1); + if (raw.trim()) { + setCmdHistory((h) => [raw.trim(), ...h.slice(0, 49)]); + } + execCommand(raw, ctx); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHistIdx((i) => { + const ni = Math.min(i + 1, cmdHistory.length - 1); + if (cmdHistory[ni]) setInput(cmdHistory[ni]); + return ni; + }); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setHistIdx((i) => { + const ni = Math.max(i - 1, -1); + if (ni === -1) setInput(''); + else if (cmdHistory[ni]) setInput(cmdHistory[ni]); + return ni; + }); + } else if (e.key === 'ArrowRight') { + const el = inputRef.current; + if (el && el.selectionStart === input.length) { + const completed = doAutocomplete(input, gRef, scanPool); + if (completed !== input) { + e.preventDefault(); + setInput(completed); + } + } + } + }; + + // ── Keyboard handler (setup screen) ─────────────────────────────────────── + const handleSetupKey = (e: KeyboardEvent) => { + if (e.key !== 'Enter') return; + const name = setupInput.trim() || 'ghost'; + setG((prev) => ({ ...prev, handle: name, phase: 'playing' })); + act('init', { handle: name }); + print(`Identity locked: ${name}`, '#33ff33'); + print(''); + print('BYTECRAWL v1.0 ready. Type help for commands.'); + print('Start with: scan'); + print(''); + }; + + // ── Header data ──────────────────────────────────────────────────────────── + const { label: traceLabel, color: traceColor } = getTraceStatus(g.trace); + const activeSlots = g.jobs.filter((j) => j.state === 'cracking').length; + const maxSlots = getMaxSlots(g); + const headerSlots = [ + { text: `BYTECRAWL v1.0 // ${g.handle}${g.ascCount > 0 ? ` [ASC-${g.ascCount}]` : ''}` }, + { text: `TRACE: ${g.trace.toFixed(1)}% [${traceLabel}]`, color: traceColor }, + { text: `WALLET: $${Math.floor(g.wallet)}` }, + { text: `SLOTS: ${activeSlots}/${maxSlots}` }, + ]; + + // ── Render ───────────────────────────────────────────────────────────────── + return ( + + + inputRef.current?.focus()}> + {g.phase === 'setup' ? ( + + ) : ( + + )} + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/economy.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/economy.ts new file mode 100644 index 0000000000..0480b07e2b --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/economy.ts @@ -0,0 +1,95 @@ +// ── Economy logic: contracts, bounties, fragments ───────────────────────────── +// Pure functions — no React, no side-effects. + +import { BOUNTY_INTERVAL, CONTRACT_INTERVAL } from '../constants'; +import type { Inv } from '../types'; +import { rand } from '../utils'; + +// ── Contract payout ─────────────────────────────────────────────────────────── + +export type ContractResult = { + readonly newLastContract: number; + readonly payout: number; +}; + +/** + * Advance the contract timer; returns a payout amount if the interval was hit. + */ +export function computeContractTick( + lastContract: number, + cpu: number, + stl: number, + ghostMarket: number, +): ContractResult { + const next = lastContract + 1; + if (next < CONTRACT_INTERVAL) { + return { newLastContract: next, payout: 0 }; + } + const payout = Math.floor(400 * (cpu + stl) * ghostMarket * rand(0.85, 1.15)); + return { newLastContract: 0, payout: Math.max(0, payout) }; +} + +// ── Bounty payout + unlock ──────────────────────────────────────────────────── + +export type BountyResult = { + readonly newLastBounty: number; + readonly bountyPayout: number; + readonly justUnlocked: boolean; + readonly newBountyUnlocked: boolean; +}; + +/** + * Advance bounty timer, handle periodic payout, and detect the initial unlock + * (after 35 real-time minutes of playtime). + */ +export function computeBountyTick( + lastBounty: number, + bountyUnlocked: boolean, + playtime: number, + cpu: number, + stl: number, + ghostMarket: number, +): BountyResult { + const justUnlocked = !bountyUnlocked && playtime >= 35 * 60; + const nowUnlocked = bountyUnlocked || justUnlocked; + + if (!nowUnlocked) { + return { newLastBounty: lastBounty, bountyPayout: 0, justUnlocked, newBountyUnlocked: false }; + } + + const next = lastBounty + 1; + if (next < BOUNTY_INTERVAL) { + return { newLastBounty: next, bountyPayout: 0, justUnlocked, newBountyUnlocked: nowUnlocked }; + } + + const bp = Math.floor(rand(800, 2400) * (1 + (cpu + stl) * 0.08) * ghostMarket); + return { newLastBounty: 0, bountyPayout: bp, justUnlocked, newBountyUnlocked: nowUnlocked }; +} + +// ── Fragment harvester ──────────────────────────────────────────────────────── + +export type FragResult = { + readonly newFragTimer: number; + readonly generated: boolean; + readonly newInv: Inv; +}; + +/** + * Advance the fragment-harvester timer; produce a fragment when it fires. + */ +export function computeFragTick( + fragTimer: number, + hasFragHarvester: boolean, + inv: Inv, +): FragResult { + if (!hasFragHarvester) { + return { newFragTimer: fragTimer, generated: false, newInv: inv }; + } + const next = fragTimer - 1; + if (next > 0) { + return { newFragTimer: next, generated: false, newInv: inv }; + } + // Timer fired — generate fragment (cap at 20) + const newInv: Inv = inv.FRG < 20 ? { ...inv, FRG: inv.FRG + 1 } : inv; + return { newFragTimer: rand(45, 90), generated: true, newInv }; +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/index.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/index.ts new file mode 100644 index 0000000000..d2d23dd574 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/index.ts @@ -0,0 +1,14 @@ +// ── Logic barrel ────────────────────────────────────────────────────────────── +// Re-exports every pure game-logic function and its associated types. + +export type { JobStateChange, JobTickResult } from './jobs'; +export { computeJobTick } from './jobs'; + +export type { BurnResult } from './trace'; +export { computeBurnTick, computeTraceTick, traceSlowFactor } from './trace'; + +export type { BountyResult, ContractResult, FragResult } from './economy'; +export { computeBountyTick, computeContractTick, computeFragTick } from './economy'; + +export type { MarketEvent, MarketTickResult } from './market'; +export { computeMarketTick } from './market'; diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/jobs.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/jobs.ts new file mode 100644 index 0000000000..04da42c19d --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/jobs.ts @@ -0,0 +1,63 @@ +// ── Job-tick logic ──────────────────────────────────────────────────────────── +// Pure functions — no React, no side-effects. + +import type { Job } from '../types'; + +export type JobStateChange = { + readonly id: string; + readonly state: 'ready' | 'failed'; + readonly progress: number; + readonly uptime: number | null; +}; + +export type JobTickResult = { + readonly newJobs: readonly Job[]; + readonly stateChanges: readonly JobStateChange[]; + /** Extra trace added by server uptime failures this tick */ + readonly traceSpike: number; +}; + +/** + * Advance every cracking job by one tick. + * Returns the updated job list, any state-change events, and an accumulated + * trace spike from any uptime failures. + */ +export function computeJobTick( + jobs: readonly Job[], + cpuMult: number, + ghostCrack: number, + slowFactor: number, + cloakPenalty: number, + jobCount: number, +): JobTickResult { + const stateChanges: JobStateChange[] = []; + let traceSpike = 0; + + const newJobs: Job[] = jobs.map((j) => { + if (j.state !== 'cracking') return j; + + const progressPerMin = (cpuMult * ghostCrack * slowFactor * cloakPenalty) / jobCount; + const np: Job = { ...j, progress: j.progress + progressPerMin }; + + // Uptime window expiry (T4/T5 servers go offline) + if (np.uptimeCap !== null) { + np.uptimeCap = np.uptimeCap - 1; + if (np.uptimeCap <= 0) { + np.state = 'failed'; + traceSpike += 15; + stateChanges.push({ id: np.id, state: 'failed', progress: np.progress, uptime: np.uptimeCap }); + return np; + } + } + + // Crack complete + if (np.progress >= np.duration) { + np.state = 'ready'; + stateChanges.push({ id: np.id, state: 'ready', progress: np.progress, uptime: np.uptimeCap }); + } + + return np; + }); + + return { newJobs, stateChanges, traceSpike }; +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/market.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/market.ts new file mode 100644 index 0000000000..4c4b82ded4 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/market.ts @@ -0,0 +1,59 @@ +// ── Market-drift logic ──────────────────────────────────────────────────────── +// Pure functions — no React, no side-effects. + +import { DATA_BASELINES, DATA_CEILINGS, DATA_FLOORS } from '../constants'; +import { rand } from '../utils'; + +export type MarketEvent = { + readonly key: string; + readonly dir: 'SPIKE' | 'DROP'; + readonly price: number; +}; + +export type MarketTickResult = { + readonly newMarket: Record; + readonly newLastEvent: number; + /** Non-null when a visible spike/drop event fired this tick */ + readonly event: MarketEvent | null; +}; + +const MARKET_KEYS = ['CRD', 'FIN', 'CRP', 'CLS'] as const; +type MarketKey = (typeof MARKET_KEYS)[number]; + +/** + * Apply one tick of market drift (mean-reversion + noise) and optionally + * fire a random spike/drop event. + */ +export function computeMarketTick( + market: Record, + lastMarketEvent: number, +): MarketTickResult { + const nm: Record = { ...market }; + + // Drift + mean-reversion for every key + for (const k of MARKET_KEYS) { + const base = DATA_BASELINES[k]; + const drift = rand(-0.025, 0.025) * base; + const pull = (base - nm[k]) * 0.035; + nm[k] = Math.max(DATA_FLOORS[k], Math.min(DATA_CEILINGS[k], nm[k] + drift + pull)); + } + + // Random event (spike or drop) + const sinceEvent = lastMarketEvent + 1; + if (sinceEvent < rand(8, 15)) { + return { newMarket: nm, newLastEvent: sinceEvent, event: null }; + } + + const k: MarketKey = MARKET_KEYS[Math.floor(Math.random() * MARKET_KEYS.length)]; + const isCLS = k === 'CLS'; + const spike = isCLS ? rand(0.05, 0.55) : rand(-0.55, 0.55); + const old = nm[k]; + nm[k] = Math.max(DATA_FLOORS[k], Math.min(DATA_CEILINGS[k], nm[k] * (1 + spike))); + + let event: MarketEvent | null = null; + if (Math.abs(nm[k] - old) > old * 0.1) { + event = { key: k, dir: nm[k] > old ? 'SPIKE' : 'DROP', price: Math.floor(nm[k]) }; + } + + return { newMarket: nm, newLastEvent: 0, event }; +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/trace.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/trace.ts new file mode 100644 index 0000000000..549ca7c423 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/logic/trace.ts @@ -0,0 +1,75 @@ +// ── Trace + burn logic ──────────────────────────────────────────────────────── +// Pure functions — no React, no side-effects. + +import { CIPHER_TIERS, IDLE_DECAY_PER_MIN } from '../constants'; +import type { CacheItem, Job } from '../types'; + +// ── Trace tick ──────────────────────────────────────────────────────────────── + +/** + * Compute the new trace value for one tick. + * Accumulates from all cracking jobs or decays passively when idle. + */ +export function computeTraceTick( + trace: number, + activeJobs: readonly Job[], + stlMult: number, + ghostTrace: number, + additionalSpike: number, +): number { + let next: number; + if (activeJobs.length > 0) { + let gain = 0; + for (const j of activeJobs) { + const tier = CIPHER_TIERS.find((c) => c.abbrev === j.cipher); + if (tier) gain += tier.tppm / stlMult / ghostTrace; + } + next = trace + gain + additionalSpike; + } else { + next = trace - IDLE_DECAY_PER_MIN + additionalSpike; + } + return Math.min(100, Math.max(0, next)); +} + +// ── Crack-speed slow factor ─────────────────────────────────────────────────── + +/** Returns the crack-speed multiplier based on current trace level. */ +export function traceSlowFactor(trace: number): number { + if (trace >= 81) return 0.85; + if (trace >= 61) return 0.95; + return 1.0; +} + +// ── Burn ────────────────────────────────────────────────────────────────────── + +export type BurnResult = { + readonly burned: boolean; + readonly newTrace: number; + readonly newHeat: number; + readonly newWallet: number; + readonly newJobs: readonly Job[]; + readonly newCache: readonly CacheItem[]; +}; + +/** + * If trace has hit 100, apply burn: wipe jobs/cache, dock wallet, add heat. + */ +export function computeBurnTick( + trace: number, + heat: number, + wallet: number, + jobs: readonly Job[], + cache: readonly CacheItem[], +): BurnResult { + if (trace < 100) { + return { burned: false, newTrace: trace, newHeat: heat, newWallet: wallet, newJobs: jobs, newCache: cache }; + } + return { + burned: true, + newTrace: 0, + newHeat: Math.min(heat + 1, 10), + newWallet: Math.floor(wallet * 0.5), + newJobs: [], + newCache: [], + }; +} diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/store.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/store.ts new file mode 100644 index 0000000000..8d2b581a83 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/store.ts @@ -0,0 +1,13 @@ +// ── Jotai atom ──────────────────────────────────────────────────────────────── +// Plain atom — persistence is handled in index.tsx via common/storage. +// Key versioned to 'bytecrawl_v1' — bump to invalidate old saves on +// breaking state shape changes. + +import { atom } from 'jotai'; + +import type { GState } from './types'; +import { defaultState } from './utils'; + +export const STORAGE_KEY = 'bytecrawl_v1'; + +export const gameStateAtom = atom(defaultState()); diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/types.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/types.ts new file mode 100644 index 0000000000..db2f7ea946 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/types.ts @@ -0,0 +1,82 @@ +// ── Cipher tier shape (mirrors CIPHER_TIERS array elements) ────────────────── +export type CipherTier = { + tier: number; + abbrev: string; + name: string; + minMin: number; + maxMin: number; + minGb: number; + maxGb: number; + tppm: number; + types: readonly string[]; + uptime: [number, number] | null; +}; + +// ── In-memory job (TSX-side) ────────────────────────────────────────────────── +export type Job = { + id: string; + cipher: string; + tier: number; + gb: number; + type: string; + progress: number; + duration: number; + uptimeCap: number | null; + state: 'cracking' | 'ready' | 'failed'; +}; + +// ── Cached data item ────────────────────────────────────────────────────────── +export type CacheItem = { + id: string; + gb: number; + type: string; +}; + +// ── Inventory ───────────────────────────────────────────────────────────────── +export type Inv = { VPN: number; FRG: number; CVR: number; XPL: number }; + +// ── Ascension bonuses ───────────────────────────────────────────────────────── +export type Ghost = { crack: number; market: number; trace: number; slots: number }; + +// ── Full client-side game state ─────────────────────────────────────────────── +export type GState = { + wallet: number; + cpu: number; + ram: number; + stl: number; + trace: number; + heat: number; + playtime: number; + ascCount: number; + totalEarned: number; + ghost: Ghost; + jobs: Job[]; + cache: CacheItem[]; + inv: Inv; + market: Record; + cloakOn: boolean; + lastContract: number; + lastBounty: number; + lastMarketEvent: number; + fragTimer: number; + hasFragHarvester: boolean; + bountyUnlocked: boolean; + txLog: string[]; + nextId: number; + phase: 'setup' | 'playing'; + handle: string; +}; + +// ── Scan pool entry (generated client-side by cmdScan) ──────────────────────── +export type ScanEntry = { + id: string; + tier: CipherTier; + type: string; + gb: number; +}; + +// ── Terminal output line ────────────────────────────────────────────────────── +export type Line = { + text: string; + color?: string; +}; diff --git a/tgui/packages/tgui/interfaces/NtosBytecrawl/utils.ts b/tgui/packages/tgui/interfaces/NtosBytecrawl/utils.ts new file mode 100644 index 0000000000..a2ec8dd3a1 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosBytecrawl/utils.ts @@ -0,0 +1,74 @@ +// ── Game-state utility helpers ──────────────────────────────────────────────── +// Pure functions — no React, no side-effects. +// Display-only formatting (fmtMoney, fmtTime, progressBar) lives in format.ts. + +import { RAM_UPGRADES } from './constants'; +import type { CipherTier, GState } from './types'; + +// ── RNG ─────────────────────────────────────────────────────────────────────── +export function rand(lo: number, hi: number): number { + return lo + Math.random() * (hi - lo); +} + +// ── Trace status label + colour ─────────────────────────────────────────────── +export function getTraceStatus(t: number): { readonly label: string; readonly color: string } { + if (t < 30) return { label: 'SAFE', color: '#33ff33' }; + if (t < 61) return { label: 'ELEVATED', color: '#ffff00' }; + if (t < 81) return { label: 'DANGER', color: '#ff8800' }; + if (t < 100) return { label: 'CRITICAL', color: '#ff3333' }; + return { label: 'BURNED', color: '#ff0000' }; +} + +// ── RAM slot count (base + GHOST bonus) ─────────────────────────────────────── +export function getMaxSlots(g: GState): number { + return RAM_UPGRADES[g.ram].slots + g.ghost.slots; +} + +// ── Job ID from counter ─────────────────────────────────────────────────────── +export function makeJobId(nextId: number): string { + return nextId.toString(16).padStart(3, '0'); +} + +// ── Crack duration in minutes ───────────────────────────────────────────────── +export function getCrackDuration( + tier: CipherTier, + cpuMult: number, + ghostCrack: number, + fragUsed: boolean, +): number { + let base = rand(tier.minMin, tier.maxMin); + if (fragUsed && tier.tier >= 4) base *= 0.5; + return base / (cpuMult * ghostCrack); +} + +// ── Fresh game state (setup phase) ──────────────────────────────────────────── +// Also used as the Jotai atom's initial/fallback value when no localStorage save exists. +export function defaultState(): GState { + return { + wallet: 0, + cpu: 0, + ram: 0, + stl: 0, + trace: 0, + heat: 0, + playtime: 0, + ascCount: 0, + totalEarned: 0, + ghost: { crack: 1.0, market: 1.0, trace: 1.0, slots: 0 }, + jobs: [], + cache: [], + inv: { VPN: 0, FRG: 0, CVR: 0, XPL: 0 }, + market: { CRD: 820, FIN: 1240, CRP: 960, CLS: 3100 }, + cloakOn: false, + lastContract: 0, + lastBounty: 0, + lastMarketEvent: 0, + fragTimer: rand(45, 90), + hasFragHarvester: false, + bountyUnlocked: false, + txLog: [], + nextId: 1, + phase: 'setup', + handle: '', + }; +} diff --git a/tgui/packages/tgui/styles/interfaces/NtosBytecrawl.scss b/tgui/packages/tgui/styles/interfaces/NtosBytecrawl.scss new file mode 100644 index 0000000000..0406bded11 --- /dev/null +++ b/tgui/packages/tgui/styles/interfaces/NtosBytecrawl.scss @@ -0,0 +1,145 @@ +// ── BYTECRAWL CRT Terminal Styles ──────────────────────────────────────────── +// All fixed layout and CRT effect styles live here. +// Dynamic per-line colours are applied as inline style in Terminal.tsx. + +$green: #33ff33; +$green-dim: #1a3a1a; +$bg: #0a140a; +$font: 'Courier New', Courier, monospace; +$font-size: 12px; +$line-height: 1.5; + +// ── Outer container ─────────────────────────────────────────────────────────── +.NtosByteCrawl__container { + width: 720px; + height: 540px; + background: $bg; + border-radius: 8px; + position: relative; + overflow: hidden; + font-family: $font; + font-size: $font-size; + line-height: $line-height; + cursor: text; + + // Screen curvature via perspective + perspective: 800px; +} + +// ── Inner wrapper (holds the perspective warp) ──────────────────────────────── +.NtosByteCrawl__inner { + width: 100%; + height: 100%; + transform: perspective(800px) rotateX(1deg) scaleY(0.99); + transform-origin: center center; + display: flex; + flex-direction: column; + position: relative; +} + +// ── CRT overlay layers ──────────────────────────────────────────────────────── +.NtosByteCrawl__scanlines { + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + rgba(0, 0, 0, 0.15) 0px, + rgba(0, 0, 0, 0.15) 1px, + transparent 1px, + transparent 3px + ); + pointer-events: none; + z-index: 10; +} + +.NtosByteCrawl__vignette { + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.7) 100%); + pointer-events: none; + z-index: 11; +} + +// ── Header bar ──────────────────────────────────────────────────────────────── +.NtosByteCrawl__header { + padding: 6px 12px; + border-bottom: 1px solid $green-dim; + display: flex; + justify-content: space-between; + align-items: center; + color: $green; + text-shadow: 0 0 6px rgba(51, 255, 51, 0.7); + font-size: 11px; + flex-shrink: 0; + z-index: 2; +} + +// ── Scrollable output area ──────────────────────────────────────────────────── +.NtosByteCrawl__output { + flex: 1; + overflow-y: auto; + padding: 12px; + color: $green; + text-shadow: 0 0 6px rgba(51, 255, 51, 0.7); + white-space: pre-wrap; + word-break: break-word; + z-index: 2; + + // Hide scrollbar on most browsers while keeping scroll functionality + scrollbar-width: thin; + scrollbar-color: $green-dim $bg; + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-track { + background: $bg; + } + &::-webkit-scrollbar-thumb { + background: $green-dim; + } +} + +// ── Each output line (colour is set inline per-line) ───────────────────────── +.NtosByteCrawl__line { + // Base glow; exact color + shadow overridden by inline style per-line + text-shadow: 0 0 5px rgba(51, 255, 51, 0.6); +} + +// ── Input row ───────────────────────────────────────────────────────────────── +.NtosByteCrawl__input_row { + display: flex; + align-items: center; + padding: 4px 12px 8px; + border-top: 1px solid $green-dim; + gap: 6px; + flex-shrink: 0; + z-index: 2; +} + +// ── Prompt label ───────────────────────────────────────────────────────────── +.NtosByteCrawl__prompt { + color: $green; + text-shadow: 0 0 6px rgba(51, 255, 51, 0.7); + white-space: nowrap; + user-select: none; +} + +// ── Text input field ────────────────────────────────────────────────────────── +.NtosByteCrawl__input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: $green; + text-shadow: 0 0 6px rgba(51, 255, 51, 0.7); + font-family: $font; + font-size: $font-size; + caret-color: $green; + + // Prevent browser autocomplete UI from clashing with the CRT look + &:-webkit-autofill { + -webkit-text-fill-color: $green; + -webkit-box-shadow: 0 0 0 1000px $bg inset; + } +} diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss index f60a3df5f2..96d932eecc 100644 --- a/tgui/packages/tgui/styles/main.scss +++ b/tgui/packages/tgui/styles/main.scss @@ -62,6 +62,7 @@ @include meta.load-css('./interfaces/FourInARow.scss'); @include meta.load-css('./interfaces/RpgDice.scss'); @include meta.load-css('./interfaces/NineMen.scss'); +@include meta.load-css('./interfaces/NtosBytecrawl.scss'); // Layouts @include meta.load-css('./layouts/Layout.scss'); diff --git a/vorestation.dme b/vorestation.dme index 2b43c0857b..b3ee6bc298 100644 --- a/vorestation.dme +++ b/vorestation.dme @@ -3992,6 +3992,7 @@ #include "code\modules\modular_computers\file_system\programs\engineering\rcon_console.dm" #include "code\modules\modular_computers\file_system\programs\engineering\shutoff_monitor.dm" #include "code\modules\modular_computers\file_system\programs\engineering\supermatter_monitor.dm" +#include "code\modules\modular_computers\file_system\programs\generic\bytecrawl.dm" #include "code\modules\modular_computers\file_system\programs\generic\camera.dm" #include "code\modules\modular_computers\file_system\programs\generic\configurator.dm" #include "code\modules\modular_computers\file_system\programs\generic\email_client.dm"