From 59b0d1f374b205817cb64525594e25182c7f6e40 Mon Sep 17 00:00:00 2001 From: alixthegreat <146326639+alixxhiscock@users.noreply.github.com> Date: Sat, 2 May 2026 15:06:42 +0100 Subject: [PATCH] Add Blockchain Transaction Explorer minigame --- app/views/break_escape/games/show.html.erb | 1 + .../css/blockchain-explorer-minigame.css | 552 +++++++++++++++ .../blockchain-explorer-minigame.js | 665 ++++++++++++++++++ public/break_escape/js/minigames/index.js | 3 + .../break_escape/js/systems/interactions.js | 10 + .../js/systems/minigame-starters.js | 34 + .../test-blockchain-explorer/mission.json | 14 + .../scenario.json.erb | 228 ++++++ 8 files changed, 1507 insertions(+) create mode 100644 public/break_escape/css/blockchain-explorer-minigame.css create mode 100644 public/break_escape/js/minigames/blockchain-explorer/blockchain-explorer-minigame.js create mode 100644 scenarios/test-blockchain-explorer/mission.json create mode 100644 scenarios/test-blockchain-explorer/scenario.json.erb diff --git a/app/views/break_escape/games/show.html.erb b/app/views/break_escape/games/show.html.erb index 35258188..86f4700d 100644 --- a/app/views/break_escape/games/show.html.erb +++ b/app/views/break_escape/games/show.html.erb @@ -56,6 +56,7 @@ + diff --git a/public/break_escape/css/blockchain-explorer-minigame.css b/public/break_escape/css/blockchain-explorer-minigame.css new file mode 100644 index 00000000..3c323356 --- /dev/null +++ b/public/break_escape/css/blockchain-explorer-minigame.css @@ -0,0 +1,552 @@ +/* ============================================================ + Blockchain Explorer Minigame (bce-) + Cyberpunk chain-forensics terminal aesthetic + ============================================================ */ + +.bce-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.bce-game-container { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + margin: 0 !important; +} + +.bce-container .minigame-close-button { + top: 0; + right: 0; +} + +/* ── Panel ─────────────────────────────────────────────────── */ + +.bce-panel { + display: flex; + flex-direction: column; + height: 100%; + background: #0d1117; + font-family: 'Press Start 2P', monospace; + color: #c9d1d9; +} + +/* ── Header ────────────────────────────────────────────────── */ + +.bce-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 40px 8px 16px; + border-bottom: 2px solid #21262d; + background: #080d12; + flex-shrink: 0; +} + +.bce-title-group { + display: flex; + align-items: center; + gap: 10px; +} + +.bce-title-icon { + font-family: 'VT323', monospace; + font-size: 22px; + color: #58a6ff; + line-height: 1; +} + +.bce-title { + font-size: 7px; + color: #58a6ff; + letter-spacing: 2px; + text-transform: uppercase; +} + +.bce-case-ref { + font-family: 'VT323', monospace; + font-size: 15px; + color: #8b949e; + letter-spacing: 1px; +} + +/* ── Body ──────────────────────────────────────────────────── */ + +.bce-body { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ── Graph pane (left) ─────────────────────────────────────── */ + +.bce-graph-pane { + width: 42%; + min-width: 280px; + border-right: 2px solid #21262d; + overflow: auto; + background: #080d12; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 14px 16px; + flex-shrink: 0; + gap: 8px; +} + +.bce-graph-section-label { + font-size: 6px; + color: #3d444d; + letter-spacing: 2px; + text-transform: uppercase; + flex-shrink: 0; +} + +.bce-graph-empty { + font-size: 6px; + color: #8b949e; + margin: auto; +} + +.bce-graph-svg { + display: block; + flex-shrink: 0; +} + +/* ── SVG graph: edges ──────────────────────────────────────── */ + +.bce-edge { + fill: none; + stroke: #2a3240; + stroke-width: 1.5; +} + + +.bce-arrow-marker { + fill: #2a3240; +} + +/* ── SVG graph: nodes ──────────────────────────────────────── */ + +.bce-gnode { + cursor: pointer; +} + +.bce-gnode:hover .bce-gn-rect { + filter: brightness(1.5); +} + +/* Rect base */ +.bce-gn-rect { + transition: filter 0.1s; +} + +/* Node type variants */ +.bce-gnode-tx .bce-gn-rect { + fill: #0c1e35; + stroke: #1d4a8a; + stroke-width: 1.5; +} +.bce-gnode-wallet .bce-gn-rect { + fill: #0d1117; + stroke: #2d3742; + stroke-width: 1.5; +} +.bce-gnode-mixer .bce-gn-rect { + fill: #1a1400; + stroke: #7a5e00; + stroke-width: 1.5; +} +.bce-gnode-target .bce-gn-rect { + fill: #0a1a10; + stroke: #1e5035; + stroke-width: 1.5; +} +.bce-gnode-mixer-flagged .bce-gn-rect { + fill: #1a1400; + stroke: #f0c040; + stroke-width: 2; +} +.bce-gnode-target-flagged .bce-gn-rect { + fill: #0a2018; + stroke: #3fb950; + stroke-width: 2; +} + +/* Active (currently selected) */ +.bce-gnode-active.bce-gnode-tx .bce-gn-rect { + fill: #142e55; + stroke: #58a6ff; + stroke-width: 2.5; +} +.bce-gnode-active.bce-gnode-wallet .bce-gn-rect { + fill: #141e2e; + stroke: #79c0ff; + stroke-width: 2.5; +} +.bce-gnode-active.bce-gnode-mixer .bce-gn-rect, +.bce-gnode-active.bce-gnode-mixer-flagged .bce-gn-rect { + fill: #2a2000; + stroke: #f0c040; + stroke-width: 2.5; +} +.bce-gnode-active.bce-gnode-target .bce-gn-rect, +.bce-gnode-active.bce-gnode-target-flagged .bce-gn-rect { + fill: #0a2a18; + stroke: #3fb950; + stroke-width: 2.5; +} + +/* Node text */ +.bce-gn-type { + font-family: 'Press Start 2P', monospace; + font-size: 5px; + fill: #3d444d; +} +.bce-gn-id { + font-family: 'VT323', monospace; + font-size: 17px; + fill: #8b949e; +} +.bce-gnode-tx .bce-gn-id { fill: #4a8fd6; } +.bce-gnode-wallet .bce-gn-id { fill: #8b949e; } +.bce-gnode-mixer .bce-gn-id, +.bce-gnode-mixer-flagged .bce-gn-id { fill: #d4a820; } +.bce-gnode-target .bce-gn-id, +.bce-gnode-target-flagged .bce-gn-id { fill: #3a9e4a; } + +.bce-gnode-active .bce-gn-id { fill: #ffffff !important; } +.bce-gnode-active .bce-gn-type { fill: #8b949e !important; } + +.bce-gn-sublabel { + font-family: 'VT323', monospace; + font-size: 18px; + fill: #4a5568; +} + +/* ── Detail pane (right) ───────────────────────────────────── */ + +.bce-detail { + flex: 1; + overflow-y: auto; + padding: 16px 20px; +} + +.bce-detail-empty { + font-size: 6px; + color: #8b949e; + text-align: center; + padding: 32px 0; + line-height: 2.5; +} + +/* ── View header ───────────────────────────────────────────── */ + +.bce-view-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.bce-view-icon { + font-family: 'VT323', monospace; + font-size: 26px; + color: #58a6ff; + line-height: 1; + flex-shrink: 0; +} + +.bce-view-title { + font-size: 7px; + color: #58a6ff; + letter-spacing: 2px; +} + +.bce-divider { + height: 1px; + background: #21262d; + margin: 12px 0; +} + +/* ── Meta grid (key-value pairs) ───────────────────────────── */ + +.bce-meta-grid { + display: grid; + grid-template-columns: 76px 1fr; + row-gap: 8px; + column-gap: 12px; + margin-bottom: 14px; +} + +.bce-meta-label { + font-size: 7px; + color: #8b949e; + align-self: center; +} + +.bce-meta-value { + font-family: 'VT323', monospace; + font-size: 17px; + color: #c9d1d9; + line-height: 1; +} + +.bce-meta-subtle { + font-family: 'VT323', monospace; + font-size: 14px; + color: #6a7280; + line-height: 1; +} + +/* ── IO sections ───────────────────────────────────────────── */ + +.bce-io-section { + margin-bottom: 16px; +} + +.bce-io-heading { + font-size: 6px; + color: #8b949e; + letter-spacing: 1px; + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid #21262d; + text-transform: uppercase; +} + +/* Base io row — 2 col (link | amount) */ +.bce-io-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 10px; + padding: 6px 0; + border-bottom: 1px solid #161b22; +} + +/* 3-col variant for wallet tx list (link | amount | time) */ +.bce-io-row-3col { + grid-template-columns: 1fr auto auto; +} + +.bce-io-cell { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + overflow: hidden; +} + +.bce-io-empty { + font-family: 'VT323', monospace; + font-size: 14px; + color: #8b949e; + padding: 6px 0; +} + +/* ── Clickable links ───────────────────────────────────────── */ + +.bce-link { + display: inline-block; + background: none; + border: none; + cursor: pointer; + padding: 0; + text-align: left; + color: #4a8fd6; + font-family: 'VT323', monospace; + font-size: 17px; + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.bce-link:hover { + color: #79c0ff; + text-decoration: underline; +} + +.bce-link-sublabel { + font-family: 'Press Start 2P', monospace; + font-size: 6px; + color: #6a7280; + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Amounts ───────────────────────────────────────────────── */ + +.bce-amount { + font-family: 'VT323', monospace; + font-size: 17px; + line-height: 1; + flex-shrink: 0; + white-space: nowrap; +} + +.bce-amount-in { color: #3fb950; } +.bce-amount-out { color: #f85149; } + +/* ── Direction badges ──────────────────────────────────────── */ + +.bce-badge { + font-family: 'Press Start 2P', monospace; + font-size: 5px; + padding: 2px 5px; + border-radius: 2px; + flex-shrink: 0; + white-space: nowrap; + line-height: 1.4; +} + +.bce-badge-in { background: #0d2b1a; color: #3fb950; border: 1px solid #1a4a2a; } +.bce-badge-out { background: #2b0d0d; color: #f85149; border: 1px solid #4a1a1a; } + +.bce-tx-time { + font-family: 'VT323', monospace; + font-size: 14px; + color: #6a7280; + flex-shrink: 0; + white-space: nowrap; + line-height: 1; +} + +/* ── Mixer callout ─────────────────────────────────────────── */ + +.bce-callout { + border-radius: 2px; + padding: 10px 14px; + margin: 12px 0 8px; +} + +.bce-callout-mixer { + background: #1a1400; + border: 1px solid #6e5500; + border-left: 3px solid #f0c040; +} + +.bce-callout-threat { + background: #0e1a2a; + border: 1px solid #1a3d6e; + border-left: 3px solid #e05252; +} + +.bce-no-intel { + font-family: 'VT323', monospace; + font-size: 14px; + color: #3d444d; + padding: 8px 0 4px; + letter-spacing: 0.5px; +} + +.bce-callout-title { + font-size: 6px; + margin-bottom: 6px; + letter-spacing: 1px; +} + +.bce-callout-mixer .bce-callout-title { color: #f0c040; } +.bce-callout-threat .bce-callout-title { color: #e05252; } + +.bce-callout-body { + font-family: 'VT323', monospace; + font-size: 15px; + line-height: 1.5; +} + +.bce-callout-mixer .bce-callout-body { color: #c8b060; } +.bce-callout-threat .bce-callout-body { color: #a08080; } + +/* ── Flag buttons ──────────────────────────────────────────── */ + +.bce-flag-btn { + display: block; + margin: 8px 0 4px auto; + font-family: 'Press Start 2P', monospace; + font-size: 10px; + padding: 10px 16px; + background: #0d1f3e; + color: #58a6ff; + border: 2px solid #1f6feb; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.bce-flag-btn:hover { + background: #1f6feb; + color: #ffffff; +} + +.bce-flagged-badge { + display: block; + margin: 8px 0 4px auto; + font-size: 6px; + color: #3fb950; + text-align: right; + letter-spacing: 1px; +} + +/* ── Footer ────────────────────────────────────────────────── */ + +.bce-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + border-top: 2px solid #21262d; + background: #080d12; + flex-shrink: 0; + gap: 12px; +} + +.bce-status-bar { + display: flex; + gap: 20px; + flex: 1; +} + +.bce-status-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 7px; + letter-spacing: 1px; +} + +.bce-status-icon { + font-family: 'VT323', monospace; + font-size: 16px; + line-height: 1; +} + +.bce-status-pending { color: #6a7280; } +.bce-status-ok { color: #3fb950; } +.bce-status-ok .bce-status-icon { color: #3fb950; } +.bce-status-pending .bce-status-icon { color: #4a5568; } + +/* ── Close button ──────────────────────────────────────────── */ + +.bce-close-btn { + font-family: 'Press Start 2P', monospace; + font-size: 10px; + padding: 8px 14px; + background: #0d1117; + color: #6a7280; + border: 2px solid #21262d; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.bce-close-btn:hover { + background: #161b22; + color: #c9d1d9; + border-color: #444d58; +} diff --git a/public/break_escape/js/minigames/blockchain-explorer/blockchain-explorer-minigame.js b/public/break_escape/js/minigames/blockchain-explorer/blockchain-explorer-minigame.js new file mode 100644 index 00000000..840ba224 --- /dev/null +++ b/public/break_escape/js/minigames/blockchain-explorer/blockchain-explorer-minigame.js @@ -0,0 +1,665 @@ +import { MinigameScene } from '../framework/base-minigame.js'; + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function setGlobalAndNotify(varName, value) { + if (!window.gameState) window.gameState = {}; + if (!window.gameState.globalVariables) window.gameState.globalVariables = {}; + const oldValue = window.gameState.globalVariables[varName]; + if (oldValue === value) return; + window.gameState.globalVariables[varName] = value; + if (window.gameScenario?.globalVariables) { + window.gameScenario.globalVariables[varName] = value; + } + if (window.npcConversationStateManager) { + window.npcConversationStateManager.broadcastGlobalVariableChange(varName, value, null); + } + if (window.eventDispatcher) { + window.eventDispatcher.emit(`global_variable_changed:${varName}`, { name: varName, value, oldValue }); + } +} + +function readGlobal(varName) { + return window.gameState?.globalVariables?.[varName]; +} + +function truncAddr(str) { + if (!str || str.length <= 13) return str; + return `${str.slice(0, 6)}...${str.slice(-4)}`; +} +function truncHash(str) { + if (!str || str.length <= 16) return str; + return `${str.slice(0, 10)}...`; +} + +export class BlockchainExplorerMinigame extends MinigameScene { + constructor(container, params = {}) { + super(container, { + ...params, + showCancel: false + }); + + const sd = params.lockable?.scenarioData || {}; + const md = sd.minigameData || {}; + + this._title = params.title || md.title || 'Chain Tracer'; + this._caseRef = params.caseRef || md.caseRef || ''; + this._currency = params.currency || md.currency || 'BTC'; + this._seedTx = params.seedTransaction || md.seedTransaction; + this._mixerThreshold = params.mixerFanOutThreshold ?? md.mixerFanOutThreshold ?? 4; + this._targetAddress = params.targetWalletAddress || md.targetWalletAddress; + this._stateWrites = params.stateWrites || md.stateWrites || {}; + this._requiresMixer = !!this._stateWrites.onMixerFlagged; + + this._wallets = Object.fromEntries((md.wallets || []).map(w => [w.address, w])); + this._transactions = Object.fromEntries((md.transactions || []).map(t => [t.hash, t])); + + this._seedTxOutputAddresses = new Set( + (this._transactions[this._seedTx]?.outputs || []).map(o => o.wallet) + ); + + this._navHistory = []; + this._currentView = null; + this._hasHopped = false; + + this._mixerFlagged = false; + this._destinationFlagged = false; + + // Cache graph layout so it doesn't recompute on every render + this._graphLayout = null; + } + + init() { + super.init(); + this.container.classList.add('bce-container'); + this.gameContainer.classList.add('bce-game-container'); + if (this.headerElement) this.headerElement.style.display = 'none'; + + if (this._stateWrites.onMixerFlagged && readGlobal(this._stateWrites.onMixerFlagged)) { + this._mixerFlagged = true; + } + if (this._stateWrites.onDestinationFlagged && readGlobal(this._stateWrites.onDestinationFlagged)) { + this._destinationFlagged = true; + } + if (this._destinationFlagged) this._hasHopped = true; + + if (this._targetAddress) { + const tw = this._wallets[this._targetAddress]; + if (tw && !tw.threatIntelMatch) { + console.warn(`[BlockchainExplorer] Target wallet "${this._targetAddress}" has no threatIntelMatch — players have no logical basis to identify it.`); + } + } + + if (this._seedTx && this._transactions[this._seedTx]) { + this._navigate('tx', this._seedTx); + } else { + this.render(); + } + } + + start() { + super.start(); + } + + // ── Navigation ───────────────────────────────────────────────────────────── + + _navigate(type, id) { + const alreadyInHistory = this._navHistory.some(n => n.type === type && n.id === id); + if (!alreadyInHistory) { + this._navHistory.push({ type, id }); + } + this._currentView = { type, id }; + + if (type === 'wallet' && !this._seedTxOutputAddresses.has(id)) { + this._hasHopped = true; + } + + this.render(); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + _renderAmount(n) { + const num = typeof n === 'number' ? n : parseFloat(n) || 0; + return `${num.toFixed(4)} ${escapeHtml(this._currency)}`; + } + + _walletTxs(address) { + return Object.values(this._transactions).filter(tx => + tx.inputs.some(i => i.wallet === address) || + tx.outputs.some(o => o.wallet === address) + ); + } + + _maxSenderOutputCount(address) { + let max = 0; + for (const tx of Object.values(this._transactions)) { + if (tx.inputs.some(i => i.wallet === address)) { + max = Math.max(max, tx.outputs.length); + } + } + return max; + } + + _walletLabel(address) { + const w = this._wallets[address]; + return w ? escapeHtml(w.displayName || 'Unknown Wallet') : 'Unknown Wallet'; + } + + _canFlagDestination() { + return this._hasHopped; + } + + // ── Flagging ─────────────────────────────────────────────────────────────── + + _flagMixer() { + if (this._mixerFlagged) return; + this._mixerFlagged = true; + if (this._stateWrites.onMixerFlagged) { + setGlobalAndNotify(this._stateWrites.onMixerFlagged, true); + } + this.render(); + this._checkCompletion(); + } + + _flagDestination() { + if (this._destinationFlagged) return; + this._destinationFlagged = true; + if (this._stateWrites.onDestinationFlagged) { + setGlobalAndNotify(this._stateWrites.onDestinationFlagged, true); + } + this.render(); + this._checkCompletion(); + } + + _checkCompletion() { + if (this._allFlagsSet()) { + this._submit(); + } + } + + _allFlagsSet() { + return (!this._requiresMixer || this._mixerFlagged) && this._destinationFlagged; + } + + _submit() { + if (!this._allFlagsSet()) return; + this.showSuccess('Findings submitted. Investigation complete.', true, 3000); + } + + // ── Graph layout ─────────────────────────────────────────────────────────── + + _buildGraphLayout() { + if (this._graphLayout) return this._graphLayout; + + // Map + const nodes = new Map(); + const rowByDepth = new Map(); + const nextRow = (depth) => { + const r = rowByDepth.get(depth) ?? 0; + rowByDepth.set(depth, r + 1); + return r; + }; + + const seedId = this._seedTx; + if (!seedId || !this._transactions[seedId]) return nodes; + + // Seed tx inputs go at depth -1 + const seedTx = this._transactions[seedId]; + for (const inp of seedTx.inputs) { + if (!nodes.has(inp.wallet)) { + nodes.set(inp.wallet, { id: inp.wallet, type: 'wallet', depth: -1, row: nextRow(-1) }); + } + } + + // BFS forward from seed tx (depth 0) + const visited = new Set(nodes.keys()); + const queue = [{ id: seedId, type: 'tx', depth: 0 }]; + + while (queue.length > 0) { + const { id, type, depth } = queue.shift(); + if (visited.has(id)) continue; + visited.add(id); + nodes.set(id, { id, type, depth, row: nextRow(depth) }); + + if (type === 'tx') { + const tx = this._transactions[id]; + if (!tx) continue; + for (const out of tx.outputs) { + if (!visited.has(out.wallet)) { + queue.push({ id: out.wallet, type: 'wallet', depth: depth + 1 }); + } + } + } else { + // Find txs where this wallet spends (is an input) + for (const tx of Object.values(this._transactions)) { + if (!visited.has(tx.hash) && tx.inputs.some(i => i.wallet === id)) { + queue.push({ id: tx.hash, type: 'tx', depth: depth + 1 }); + } + } + } + } + + this._graphLayout = nodes; + return nodes; + } + + // ── Graph rendering ──────────────────────────────────────────────────────── + + _renderGraph() { + const layout = this._buildGraphLayout(); + if (layout.size === 0) return 'No graph data.'; + + const COL_W = 158; + const ROW_H = 58; + const NODE_W = 130; + const NODE_H = 40; + const PAD_X = 12; + const PAD_Y = 14; + + let minDepth = Infinity, maxDepth = -Infinity; + const rowsByDepth = new Map(); + + for (const node of layout.values()) { + minDepth = Math.min(minDepth, node.depth); + maxDepth = Math.max(maxDepth, node.depth); + const arr = rowsByDepth.get(node.depth) ?? []; + arr.push(node.id); + rowsByDepth.set(node.depth, arr); + } + + const colCount = maxDepth - minDepth + 1; + const maxRows = Math.max(...[...rowsByDepth.values()].map(r => r.length)); + + const svgW = colCount * COL_W + PAD_X * 2; + const svgH = maxRows * ROW_H + PAD_Y * 2; + + // Assign pixel centre positions + const pos = new Map(); + for (const node of layout.values()) { + const col = node.depth - minDepth; + const depthIds = rowsByDepth.get(node.depth) ?? []; + const totalH = depthIds.length * ROW_H; + const startY = (svgH - totalH) / 2; + pos.set(node.id, { + x: PAD_X + col * COL_W + (COL_W - NODE_W) / 2, + y: startY + node.row * ROW_H + (ROW_H - NODE_H) / 2, + cx: PAD_X + col * COL_W + COL_W / 2, + cy: startY + node.row * ROW_H + ROW_H / 2, + }); + } + + // Build forward-only edge list from tx inputs/outputs + const ARROW_LEN = 10; // must match marker polygon tip x + const edges = []; + for (const node of layout.values()) { + if (node.type !== 'tx') continue; + const tx = this._transactions[node.id]; + if (!tx) continue; + for (const inp of tx.inputs) { + if (pos.has(inp.wallet) && pos.get(inp.wallet).cx < pos.get(node.id).cx) { + edges.push({ from: inp.wallet, to: node.id }); + } + } + for (const out of tx.outputs) { + if (pos.has(out.wallet) && pos.get(out.wallet).cx > pos.get(node.id).cx) { + edges.push({ from: node.id, to: out.wallet }); + } + } + } + + // Count outgoing/incoming per node (forward edges only) for fan-out spread. + const departCount = new Map(); + const departIndex = new Map(); + const arriveCount = new Map(); + const arriveIndex = new Map(); + for (const edge of edges) { + if (!edge.forward) continue; + const key = `${edge.from}→${edge.to}`; + const di = departCount.get(edge.from) ?? 0; + departIndex.set(key, di); + departCount.set(edge.from, di + 1); + const ai = arriveCount.get(edge.to) ?? 0; + arriveIndex.set(key, ai); + arriveCount.set(edge.to, ai + 1); + } + + // Render edges + let svgEdges = ''; + for (const edge of edges) { + const fp = pos.get(edge.from); + const tp = pos.get(edge.to); + if (!fp || !tp) continue; + + const key = `${edge.from}→${edge.to}`; + const dTotal = departCount.get(edge.from) ?? 1; + const dIdx = departIndex.get(key) ?? 0; + const aTotal = arriveCount.get(edge.to) ?? 1; + const aIdx = arriveIndex.get(key) ?? 0; + + // Spread departure along right edge; path ends ARROW_LEN before wallet left edge + // so arrowhead tip lands exactly on the wallet border. + const y1 = fp.y + NODE_H * (dIdx + 1) / (dTotal + 1); + const x1 = fp.x + NODE_W; + const y2 = tp.y + NODE_H * (aIdx + 1) / (aTotal + 1); + const x2 = tp.x - ARROW_LEN; + const mx = (x1 + x2) / 2; + const d = `M ${x1} ${y1} C ${mx} ${y1} ${mx} ${y2} ${x2} ${y2}`; + + svgEdges += ``; + } + + // Render nodes + const currentId = this._currentView?.id; + const visitedIds = new Set(this._navHistory.map(n => n.id)); + let svgNodes = ''; + + for (const node of layout.values()) { + const p = pos.get(node.id); + if (!p) continue; + + const isCurrent = node.id === currentId; + const isVisited = visitedIds.has(node.id); + const isMixer = node.type === 'wallet' && this._maxSenderOutputCount(node.id) >= this._mixerThreshold; + const isTarget = node.id === this._targetAddress; + + let variant = node.type === 'tx' ? 'tx' : 'wallet'; + if (isMixer) variant = this._mixerFlagged ? 'mixer-flagged' : 'mixer'; + if (isTarget && this._destinationFlagged) variant = 'target-flagged'; + + const activeClass = isCurrent ? ' bce-gnode-active' : ''; + const opacity = (isVisited || isCurrent) ? '1' : '0.38'; + + const typeLabel = node.type === 'tx' ? 'TX' : 'WALLET'; + const shortId = node.type === 'tx' ? truncHash(node.id) : truncAddr(node.id); + + svgNodes += ` + + + ${typeLabel} + ${escapeHtml(shortId)} +`; + + // Sub-label below node (wallet display name or tx date) + const sublabel = node.type === 'wallet' + ? this._walletLabel(node.id) + : (this._transactions[node.id]?.timestamp?.slice(0, 10) || ''); + if (sublabel) { + svgNodes += `${escapeHtml(sublabel)}`; + } + } + + return ` +CHAIN GRAPH + + + + + + + ${svgEdges} + ${svgNodes} +`; + } + + // ── Main render ──────────────────────────────────────────────────────────── + + render() { + // Preserve graph pane scroll so clicking a node doesn't reset position + const existingPane = this.gameContainer.querySelector('#bce-graph-pane'); + const scrollLeft = existingPane?.scrollLeft ?? 0; + const scrollTop = existingPane?.scrollTop ?? 0; + + const caseRef = this._caseRef + ? `${escapeHtml(this._caseRef)}` + : ''; + + this.gameContainer.innerHTML = ` + + + + ⛓ + ${escapeHtml(this._title)} + + ${caseRef} + + + + ${this._renderGraph()} + + + ${this._renderDetail()} + + + + + `; + + const newPane = this.gameContainer.querySelector('#bce-graph-pane'); + if (newPane) { + newPane.scrollLeft = scrollLeft; + newPane.scrollTop = scrollTop; + } + + this.bindEvents(); + } + + _renderDetail() { + if (!this._currentView) { + return 'Select a node in the graphto begin your investigation.'; + } + if (this._currentView.type === 'tx') { + const tx = this._transactions[this._currentView.id]; + return tx ? this._renderTxView(tx) : 'Transaction not found.'; + } + const wallet = this._wallets[this._currentView.id] || { address: this._currentView.id, displayName: 'Unknown', balance: 0 }; + return this._renderWalletView(wallet); + } + + // ── Transaction detail view ──────────────────────────────────────────────── + + _renderTxView(tx) { + const inputRows = tx.inputs.map(i => ` + + + ${escapeHtml(truncAddr(i.wallet))} + ${this._walletLabel(i.wallet)} + + +${this._renderAmount(i.amount)} + + `).join(''); + + const inputAddresses = new Set(tx.inputs.map(i => i.wallet)); + const outputRows = [...tx.outputs] + .filter(o => !inputAddresses.has(o.wallet)) + .sort((a, b) => b.amount - a.amount) + .map(o => ` + + + ${escapeHtml(truncAddr(o.wallet))} + ${this._walletLabel(o.wallet)} + + -${this._renderAmount(o.amount)} + `) + .join(''); + + const confirms = tx.confirmations != null + ? `Confirms${tx.confirmations.toLocaleString()}` + : ''; + + return ` + + ⇄ + TRANSACTION + + + + Hash + ${escapeHtml(truncHash(tx.hash))} + Block + ${tx.blockHeight?.toLocaleString() || '—'} + Time + ${escapeHtml(tx.timestamp || '—')} + Fee + ${this._renderAmount(tx.fee ?? 0)} + ${confirms} + + + INPUTS + ${inputRows || 'No inputs'} + + + OUTPUTS + ${outputRows || 'No outputs'} + + + `; + } + + // ── Wallet detail view ───────────────────────────────────────────────────── + + _renderWalletView(wallet) { + const address = wallet.address; + const txs = this._walletTxs(address); + const fanOut = this._maxSenderOutputCount(address); + const isMixer = fanOut >= this._mixerThreshold; + const isDest = address === this._targetAddress; + + const txRows = txs.map(tx => { + const isOut = tx.inputs.some(i => i.wallet === address); + const relevant = isOut + ? tx.outputs.reduce((s, o) => s + (o.wallet !== address ? o.amount : 0), 0) + : tx.outputs.filter(o => o.wallet === address).reduce((s, o) => s + o.amount, 0); + const dirClass = isOut ? 'bce-amount-out' : 'bce-amount-in'; + const sign = isOut ? '-' : '+'; + const timeStr = (tx.timestamp || '').slice(0, 16); + return ` + + + ${escapeHtml(truncHash(tx.hash))} + ${isOut ? 'OUT' : 'IN'} + + ${sign}${this._renderAmount(relevant)} + ${escapeHtml(timeStr)} + + `; + }).join(''); + + const mixerCallout = isMixer ? ` + + ⚠ High fan-out detected + This wallet sent funds to ${fanOut} recipients in a single transaction — consistent with a mixing or tumbling service. + + ` : ''; + + const threatIntel = wallet.threatIntelMatch + ? ` + ⬡ Threat Intel Match + ${escapeHtml(wallet.threatIntelMatch)} + ` + : `No threat intelligence matches found for this address.`; + + const mixerAction = isMixer && this._requiresMixer + ? (this._mixerFlagged + ? '✓ Flagged as Mixing Service' + : 'Flag as Mixing Service ▶') + : ''; + + const destAction = isDest && this._canFlagDestination() + ? (this._destinationFlagged + ? '✓ Flagged as Destination' + : 'Flag as Destination Wallet ▶') + : ''; + + return ` + + ◈ + WALLET + + + + Address + ${escapeHtml(truncAddr(address))} + Label + ${escapeHtml(wallet.displayName || 'Unknown Wallet')} + Balance + ${this._renderAmount(wallet.balance ?? 0)} + + + TRANSACTIONS (${txs.length}) + ${txRows || 'No transactions found.'} + + ${mixerCallout} + ${threatIntel} + ${mixerAction} + ${destAction} + + `; + } + + // ── Footer ───────────────────────────────────────────────────────────────── + + _renderFooter() { + const mixerStatus = this._requiresMixer ? ` + + ${this._mixerFlagged ? '✓' : '○'} + Mixing Service + ` : ''; + + const destStatus = ` + + ${this._destinationFlagged ? '✓' : '○'} + Destination Wallet + `; + + return ` + + ${mixerStatus} + ${destStatus} + + Close Terminal + `; + } + + // ── Events ───────────────────────────────────────────────────────────────── + + bindEvents() { + // SVG graph node clicks + this.gameContainer.querySelectorAll('.bce-gnode').forEach(g => { + this.addEventListener(g, 'click', () => { + this._navigate(g.dataset.type, g.dataset.id); + }); + }); + + // Inline address/tx links in detail panel + this.gameContainer.querySelectorAll('.bce-link').forEach(btn => { + this.addEventListener(btn, 'click', () => { + this._navigate(btn.dataset.type, btn.dataset.id); + }); + }); + + // Flag buttons + const mixerBtn = this.gameContainer.querySelector('#bce-flag-mixer'); + if (mixerBtn) this.addEventListener(mixerBtn, 'click', () => this._flagMixer()); + + const destBtn = this.gameContainer.querySelector('#bce-flag-dest'); + if (destBtn) this.addEventListener(destBtn, 'click', () => this._flagDestination()); + + // Close Terminal + const closeBtn = this.gameContainer.querySelector('#bce-close-btn'); + if (closeBtn) this.addEventListener(closeBtn, 'click', () => this.complete(false)); + } +} diff --git a/public/break_escape/js/minigames/index.js b/public/break_escape/js/minigames/index.js index 5dc33e06..92258560 100644 --- a/public/break_escape/js/minigames/index.js +++ b/public/break_escape/js/minigames/index.js @@ -38,6 +38,7 @@ export { VpnLogViewerMinigame } from './vpn-log-viewer/vpn-log-viewer-minigame.j export { DrugLibraryIntegrityMinigame } from './drug-library-integrity/drug-library-integrity-minigame.js'; export { CoverageDecisionFormMinigame } from './coverage-decision-form/coverage-decision-form-minigame.js'; export { WarrantyChecklistMinigame } from './warranty-checklist/warranty-checklist-minigame.js'; +export { BlockchainExplorerMinigame } from './blockchain-explorer/blockchain-explorer-minigame.js'; // Initialize the global minigame framework for backward compatibility import { MinigameFramework } from './framework/minigame-manager.js'; @@ -120,6 +121,7 @@ import { VpnLogViewerMinigame } from './vpn-log-viewer/vpn-log-viewer-minigame.j import { DrugLibraryIntegrityMinigame } from './drug-library-integrity/drug-library-integrity-minigame.js'; import { CoverageDecisionFormMinigame } from './coverage-decision-form/coverage-decision-form-minigame.js'; import { WarrantyChecklistMinigame } from './warranty-checklist/warranty-checklist-minigame.js'; +import { BlockchainExplorerMinigame } from './blockchain-explorer/blockchain-explorer-minigame.js'; // Import ransomware display minigame import { RansomwareDisplayMinigame } from './ransomware-display/ransomware-display-minigame.js'; @@ -166,6 +168,7 @@ MinigameFramework.registerScene('vpn-log-viewer', VpnLogViewerMinigame); MinigameFramework.registerScene('drug-library-integrity', DrugLibraryIntegrityMinigame); MinigameFramework.registerScene('coverage-decision-form', CoverageDecisionFormMinigame); MinigameFramework.registerScene('warranty-checklist', WarrantyChecklistMinigame); +MinigameFramework.registerScene('blockchain-explorer', BlockchainExplorerMinigame); // Make minigame functions available globally window.startNotesMinigame = startNotesMinigame; diff --git a/public/break_escape/js/systems/interactions.js b/public/break_escape/js/systems/interactions.js index af471fa8..f4522136 100644 --- a/public/break_escape/js/systems/interactions.js +++ b/public/break_escape/js/systems/interactions.js @@ -978,6 +978,16 @@ export function handleObjectInteraction(sprite) { return; } + // Handle Blockchain Explorer + if (sprite.scenarioData.interactionType === 'blockchain_explorer') { + if (window.startBlockchainExplorerMinigame) { + window.startBlockchainExplorerMinigame(sprite); + } else { + window.gameAlert('Chain analysis terminal unavailable.', 'error', 'Error', 3000); + } + return; + } + // Handle Flag Station / Launch Device interaction if (sprite.scenarioData.type === "flag-station" || sprite.scenarioData.type === "flag_station" || diff --git a/public/break_escape/js/systems/minigame-starters.js b/public/break_escape/js/systems/minigame-starters.js index 5ee27275..569950d6 100644 --- a/public/break_escape/js/systems/minigame-starters.js +++ b/public/break_escape/js/systems/minigame-starters.js @@ -844,6 +844,40 @@ export function startWarrantyChecklistMinigame(lockable, options = {}) { } window.startWarrantyChecklistMinigame = startWarrantyChecklistMinigame; +export function startBlockchainExplorerMinigame(lockable, options = {}) { + console.log('Starting Blockchain Explorer minigame', { lockable, options }); + + if (!window.MinigameFramework) { + console.error('MinigameFramework not available'); + window.gameAlert('Chain analysis terminal unavailable.', 'error', 'Error', 3000); + return; + } + + if (!window.MinigameFramework.mainGameScene) { + window.MinigameFramework.init(window.game); + } + + const scenarioData = lockable?.scenarioData || {}; + const minigameData = scenarioData.minigameData || {}; + + window.MinigameFramework.startMinigame('blockchain-explorer', null, { + title: options.title || minigameData.title || 'Chain Tracer', + caseRef: options.caseRef || minigameData.caseRef || '', + currency: options.currency || minigameData.currency || 'BTC', + seedTransaction: options.seedTransaction || minigameData.seedTransaction, + mixerFanOutThreshold: options.mixerFanOutThreshold ?? minigameData.mixerFanOutThreshold ?? 4, + targetWalletAddress: options.targetWalletAddress || minigameData.targetWalletAddress, + stateWrites: options.stateWrites || minigameData.stateWrites || {}, + lockable, + onComplete: (success, result) => { + if (typeof options.onComplete === 'function') { + options.onComplete(success, result); + } + } + }); +} +window.startBlockchainExplorerMinigame = startBlockchainExplorerMinigame; + // Export for global access window.startLockpickingMinigame = startLockpickingMinigame; window.startKeySelectionMinigame = startKeySelectionMinigame; diff --git a/scenarios/test-blockchain-explorer/mission.json b/scenarios/test-blockchain-explorer/mission.json new file mode 100644 index 00000000..9827868d --- /dev/null +++ b/scenarios/test-blockchain-explorer/mission.json @@ -0,0 +1,14 @@ +{ + "display_name": "Test Blockchain Explorer", + "description": "Quick test scenario for the Blockchain Explorer minigame. Trace a cryptocurrency payment chain, identify the mixing service, and pinpoint the ENTROPY destination wallet.", + "difficulty_level": 1, + "secgen_scenario": null, + "collection": "testing", + "cybok": [ + { + "ka": "F", + "topic": "Forensics", + "keywords": ["Cryptocurrency", "Blockchain OSINT", "Transaction tracing", "Mixing services"] + } + ] +} diff --git a/scenarios/test-blockchain-explorer/scenario.json.erb b/scenarios/test-blockchain-explorer/scenario.json.erb new file mode 100644 index 00000000..d2cd7350 --- /dev/null +++ b/scenarios/test-blockchain-explorer/scenario.json.erb @@ -0,0 +1,228 @@ +{ + "scenario_brief": "A ransom payment has been traced to a known cryptocurrency address. Use the chain analysis terminal to follow the funds across multiple hops, identify the mixing service used to obscure the transaction trail, and pinpoint the final destination wallet linked to the ENTROPY threat actor.", + "endGoal": "Identify the mixing service and the ENTROPY destination wallet.", + "startRoom": "analysis_room", + "startItemsInInventory": [], + "globalVariables": { + "entropy_mixer_identified": false, + "entropy_wallet_identified": false + }, + "objectives": [ + { + "aimId": "trace_payment", + "title": "Trace the Payment Chain", + "description": "Use the chain analysis terminal to follow the ransom payment through the blockchain.", + "status": "active", + "order": 0, + "tasks": [ + { + "taskId": "identify_mixer", + "title": "Identify the mixing service", + "type": "set_global", + "targetGlobal": "entropy_mixer_identified", + "targetValue": true, + "status": "active" + }, + { + "taskId": "identify_destination", + "title": "Identify the ENTROPY destination wallet", + "type": "set_global", + "targetGlobal": "entropy_wallet_identified", + "targetValue": true, + "status": "active" + } + ] + } + ], + "rooms": { + "analysis_room": { + "name": "Chain Analysis Suite", + "type": "room_office", + "connections": {}, + "objects": [ + { + "type": "pc", + "id": "chain_terminal", + "name": "Chain Analysis Terminal", + "takeable": false, + "interactable": true, + "active": true, + "observations": "A forensic workstation running a blockchain trace tool. The screen shows an incoming transaction flagged by the threat intel team.", + "scenarioData": { + "id": "chain_terminal", + "type": "pc", + "name": "Chain Analysis Terminal", + "interactionType": "blockchain_explorer", + "minigameData": { + "title": "ENTROPY Chain Tracer", + "caseRef": "CASE-2024-0891", + "currency": "BTC", + "seedTransaction": "tx_ransom_001", + "mixerFanOutThreshold": 4, + "targetWalletAddress": "addr_entropy_final", + "stateWrites": { + "onMixerFlagged": "entropy_mixer_identified", + "onDestinationFlagged": "entropy_wallet_identified" + }, + "wallets": [ + { + "address": "addr_victim_001", + "displayName": "Victim Organisation", + "balance": 0.0010 + }, + { + "address": "addr_ransom_001", + "displayName": "Unknown Wallet", + "balance": 0 + }, + { + "address": "addr_mixer_001", + "displayName": "Unknown Wallet", + "balance": 0 + }, + { + "address": "addr_wash_001", + "displayName": "Unknown Wallet", + "balance": 0.0706 + }, + { + "address": "addr_wash_002", + "displayName": "Unknown Wallet", + "balance": 0.0706 + }, + { + "address": "addr_wash_003", + "displayName": "Unknown Wallet", + "balance": 0.0706 + }, + { + "address": "addr_wash_004", + "displayName": "Unknown Wallet", + "balance": 0.0706 + }, + { + "address": "addr_wash_005", + "displayName": "Unknown Wallet", + "balance": 0.0706 + }, + { + "address": "addr_wash_006", + "displayName": "Unknown Wallet", + "balance": 0.0706 + }, + { + "address": "addr_relay_001", + "displayName": "Unknown Wallet", + "balance": 0 + }, + { + "address": "addr_noise_001", + "displayName": "Unknown Wallet", + "balance": 0.0230 + }, + { + "address": "addr_noise_002", + "displayName": "Unknown Wallet", + "balance": 0.0230 + }, + { + "address": "addr_collect_001", + "displayName": "Unknown Wallet", + "balance": 0 + }, + { + "address": "addr_dust_001", + "displayName": "Unknown Wallet", + "balance": 0.0030 + }, + { + "address": "addr_entropy_final", + "displayName": "Unknown Wallet", + "balance": 0.0196, + "threatIntelMatch": "Address appears in CISA Advisory AA24-089A as ENTROPY ransomware collection infrastructure. Previously observed receiving ransom payments in 3 confirmed incidents (2023–2024). Associated cluster: ENTROPY / DEV-0569." + } + ], + "transactions": [ + { + "hash": "tx_ransom_001", + "timestamp": "2024-03-15 14:22:11 UTC", + "blockHeight": 834521, + "confirmations": 2847, + "inputs": [ + { "wallet": "addr_victim_001", "amount": 0.5000 } + ], + "outputs": [ + { "wallet": "addr_ransom_001", "amount": 0.4990 }, + { "wallet": "addr_victim_001", "amount": 0.0010 } + ], + "fee": 0.0000 + }, + { + "hash": "tx_stage_001", + "timestamp": "2024-03-15 17:08:22 UTC", + "blockHeight": 834537, + "confirmations": 2831, + "inputs": [ + { "wallet": "addr_ransom_001", "amount": 0.4990 } + ], + "outputs": [ + { "wallet": "addr_mixer_001", "amount": 0.4975 } + ], + "fee": 0.0015 + }, + { + "hash": "tx_mix_001", + "timestamp": "2024-03-15 19:44:53 UTC", + "blockHeight": 834549, + "confirmations": 2819, + "inputs": [ + { "wallet": "addr_mixer_001", "amount": 0.4975 } + ], + "outputs": [ + { "wallet": "addr_wash_001", "amount": 0.0706 }, + { "wallet": "addr_wash_002", "amount": 0.0706 }, + { "wallet": "addr_wash_003", "amount": 0.0706 }, + { "wallet": "addr_wash_004", "amount": 0.0706 }, + { "wallet": "addr_wash_005", "amount": 0.0706 }, + { "wallet": "addr_wash_006", "amount": 0.0706 }, + { "wallet": "addr_relay_001", "amount": 0.0706 } + ], + "fee": 0.0033 + }, + { + "hash": "tx_relay_001", + "timestamp": "2024-03-17 11:15:09 UTC", + "blockHeight": 834892, + "confirmations": 2476, + "inputs": [ + { "wallet": "addr_relay_001", "amount": 0.0706 } + ], + "outputs": [ + { "wallet": "addr_noise_001", "amount": 0.0230 }, + { "wallet": "addr_noise_002", "amount": 0.0230 }, + { "wallet": "addr_collect_001", "amount": 0.0236 } + ], + "fee": 0.0010 + }, + { + "hash": "tx_final_001", + "timestamp": "2024-03-18 00:28:41 UTC", + "blockHeight": 834971, + "confirmations": 2397, + "inputs": [ + { "wallet": "addr_collect_001", "amount": 0.0236 } + ], + "outputs": [ + { "wallet": "addr_dust_001", "amount": 0.0030 }, + { "wallet": "addr_entropy_final", "amount": 0.0196 } + ], + "fee": 0.0010 + } + ] + } + } + } + ] + } + } +}