| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Signal Cascade 3D: Wireless Core Fault Simulator</title> |
| <style> |
| :root { |
| --bg: #070b18; |
| --panel: rgba(16, 24, 48, 0.92); |
| --panel2: rgba(30, 42, 78, 0.92); |
| --text: #eef3ff; |
| --muted: #a9b7d9; |
| --line: #344266; |
| --accent: #7dd3fc; |
| --healthy: #27c46b; |
| --degraded: #ffd166; |
| --faulted: #ef476f; |
| --mitigating: #4cc9f0; |
| --restored: #b388ff; |
| --unknown: #80889f; |
| } |
| |
| * { box-sizing: border-box; } |
| |
| body { |
| margin: 0; |
| min-height: 100vh; |
| font-family: Arial, Helvetica, sans-serif; |
| color: var(--text); |
| background: radial-gradient(circle at top, #172b5d 0%, var(--bg) 58%); |
| overflow-x: hidden; |
| } |
| |
| header { |
| padding: 20px 24px 8px; |
| text-align: center; |
| } |
| |
| h1 { |
| margin: 0 0 8px; |
| font-size: clamp(28px, 4vw, 46px); |
| letter-spacing: 0.4px; |
| } |
| |
| .subtitle { |
| margin: 0 auto; |
| max-width: 1050px; |
| color: var(--muted); |
| line-height: 1.45; |
| font-size: 16px; |
| } |
| |
| .app { |
| display: grid; |
| grid-template-columns: minmax(620px, 1fr) 430px; |
| gap: 16px; |
| padding: 16px; |
| max-width: 1500px; |
| min-width: 1060px; |
| margin: 0 auto; |
| align-items: start; |
| } |
| |
| .stage-card, .side-card { |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: 20px; |
| box-shadow: 0 18px 42px rgba(0,0,0,0.35); |
| overflow: hidden; |
| } |
| |
| .stage-card { position: relative; min-height: 720px; } |
| |
| #scene { |
| display: block; |
| width: 100%; |
| height: 720px; |
| cursor: grab; |
| } |
| |
| #scene:active { cursor: grabbing; } |
| |
| .hud { |
| position: absolute; |
| top: 14px; |
| left: 14px; |
| right: 14px; |
| display: grid; |
| grid-template-columns: repeat(6, minmax(90px, 1fr)); |
| gap: 8px; |
| pointer-events: none; |
| } |
| |
| .stat { |
| background: rgba(7, 12, 28, 0.76); |
| border: 1px solid rgba(125, 211, 252, 0.18); |
| border-radius: 14px; |
| padding: 9px 10px; |
| text-align: center; |
| backdrop-filter: blur(8px); |
| } |
| |
| .stat strong { display: block; font-size: 20px; } |
| .stat span { color: var(--muted); font-size: 12px; } |
| |
| .hint { |
| position: absolute; |
| bottom: 14px; |
| left: 14px; |
| right: 14px; |
| color: #cfe9ff; |
| background: rgba(7, 12, 28, 0.72); |
| border: 1px solid rgba(125, 211, 252, 0.18); |
| border-radius: 14px; |
| padding: 10px 12px; |
| font-size: 13px; |
| line-height: 1.35; |
| backdrop-filter: blur(8px); |
| } |
| |
| .side-card { |
| padding: 16px; |
| position: sticky; |
| top: 12px; |
| max-height: calc(100vh - 24px); |
| overflow-y: auto; |
| } |
| |
| .controls { |
| display: grid; |
| grid-template-columns: repeat(2, 1fr); |
| gap: 10px; |
| margin: 12px 0; |
| } |
| |
| button, select { |
| border: 1px solid var(--line); |
| background: #263456; |
| color: var(--text); |
| border-radius: 12px; |
| padding: 11px 12px; |
| font-size: 14px; |
| } |
| |
| button { cursor: pointer; font-weight: 700; } |
| button:hover { background: #314374; } |
| .primary { background: #0f79a8; border-color: #23a7dc; } |
| .danger { background: #8c1d3b; border-color: #ef476f; } |
| .good { background: #137346; border-color: #27c46b; } |
| |
| h2, h3 { margin: 10px 0 8px; } |
| |
| .explain, .small { |
| color: var(--muted); |
| line-height: 1.45; |
| font-size: 13px; |
| } |
| |
| .scenario-box, .selected-box { |
| background: rgba(125,211,252,0.08); |
| border: 1px solid rgba(125,211,252,0.25); |
| border-radius: 14px; |
| padding: 12px; |
| color: #dff7ff; |
| font-size: 13px; |
| line-height: 1.45; |
| margin: 10px 0; |
| } |
| |
| .selected-box { |
| background: rgba(179, 136, 255, 0.08); |
| border-color: rgba(179, 136, 255, 0.28); |
| min-height: 118px; |
| } |
| |
| .legend { |
| display: grid; |
| grid-template-columns: repeat(2, 1fr); |
| gap: 8px; |
| margin: 10px 0 14px; |
| } |
| |
| .legend-item { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| color: var(--muted); |
| font-size: 13px; |
| } |
| |
| .dot { |
| width: 14px; |
| height: 14px; |
| border-radius: 50%; |
| display: inline-block; |
| } |
| |
| .domain-list { |
| display: grid; |
| gap: 8px; |
| margin-top: 8px; |
| max-height: 240px; |
| overflow: auto; |
| padding-right: 4px; |
| } |
| |
| .domain { |
| display: grid; |
| grid-template-columns: 28px 1fr auto; |
| gap: 8px; |
| align-items: center; |
| background: rgba(255,255,255,0.045); |
| border: 1px solid rgba(255,255,255,0.08); |
| border-radius: 12px; |
| padding: 8px; |
| font-size: 13px; |
| } |
| |
| .domain b { display: block; } |
| .domain span { color: var(--muted); font-size: 11px; } |
| |
| .pill { |
| border-radius: 999px; |
| padding: 4px 8px; |
| font-size: 11px; |
| font-weight: 700; |
| color: #08111f; |
| background: var(--muted); |
| } |
| |
| .log { |
| background: #070c1c; |
| border: 1px solid var(--line); |
| border-radius: 14px; |
| padding: 12px; |
| height: 180px; |
| overflow: auto; |
| font-size: 12px; |
| line-height: 1.45; |
| color: #d9e6ff; |
| white-space: pre-wrap; |
| } |
| |
| @media (max-width: 1120px) { |
| body { overflow-x: auto; } |
| .app { |
| grid-template-columns: minmax(620px, 1fr) 430px; |
| min-width: 1060px; |
| } |
| .stage-card { min-height: 620px; } |
| #scene { height: 620px; } |
| .side-card { |
| position: sticky; |
| top: 12px; |
| max-height: calc(100vh - 24px); |
| overflow-y: auto; |
| } |
| } |
| |
| @media (max-width: 680px) { |
| body { overflow-x: auto; } |
| .app { |
| grid-template-columns: minmax(620px, 1fr) 430px; |
| min-width: 1060px; |
| padding: 10px; |
| } |
| .hud { grid-template-columns: repeat(3, 1fr); } |
| .controls { grid-template-columns: repeat(2, 1fr); } |
| .stage-card { min-height: 560px; } |
| #scene { height: 560px; } |
| } |
| .hud { grid-template-columns: repeat(3, 1fr); } |
| .controls { grid-template-columns: 1fr; } |
| .stage-card { min-height: 560px; } |
| #scene { height: 560px; } |
| } |
| </style> |
| </head> |
| <body> |
| <header> |
| <h1>Signal Cascade 3D: Wireless Core Fault Simulator</h1> |
| <p class="subtitle"> |
| A 3D telecom mission-control simulator where ten Wireless Core domains are connected as a live dependency graph. Faults pulse, spread, mitigate, and restore across Subscriber, RAN, Packet Core, Voice, Messaging, E911, Policy, Identity, Operations, and External Partner systems. |
| </p> |
| </header> |
|
|
| <main class="app"> |
| <section class="stage-card"> |
| <canvas id="scene"></canvas> |
| <div class="hud"> |
| <div class="stat"><strong id="turnCount">0</strong><span>Turns</span></div> |
| <div class="stat"><strong id="healthyCount">0</strong><span>Healthy</span></div> |
| <div class="stat"><strong id="degradedCount">0</strong><span>Degraded</span></div> |
| <div class="stat"><strong id="faultedCount">0</strong><span>Faulted</span></div> |
| <div class="stat"><strong id="riskScore">0</strong><span>Risk</span></div> |
| <div class="stat"><strong id="statusText">Ready</strong><span>Status</span></div> |
| </div> |
| <div class="hint"> |
| Drag to rotate the 3D Wireless Core. Scroll to zoom. Click any node to select a persona and cycle its state. Animated links show dependency paths where faults can cascade. |
| </div> |
| </section> |
|
|
| <aside class="side-card"> |
| <h2>Command Controls</h2> |
| <p class="explain">Load a known Wireless Core incident, introduce a specific fault, inspect evidence, choose a playbook, or let the cascade run. Faults now spread more slowly and playbooks reduce the active incident when the evidence points to the right domain.</p> |
|
|
| <div class="controls"> |
| <button id="playBtn" class="primary">▶ Play</button> |
| <button id="stepBtn">Step Once</button> |
| <button id="resetBtn" class="danger">Reset</button> |
| <button id="randomBtn">Random Set</button> |
| <button id="inspectBtn" class="good">Inspect Signals</button> |
| <button id="playbookBtn" class="good">Apply Playbook</button> |
| </div> |
|
|
| <select id="patternSelect" style="width:100%; margin-bottom:10px;"></select> |
| <button id="loadPatternBtn" style="width:100%;">Load Known Pattern</button> |
|
|
| <h3>Introduce a Specific Fault</h3> |
| <select id="faultSelect" style="width:100%; margin-bottom:10px;"></select> |
| <button id="introduceFaultBtn" class="danger" style="width:100%;">Introduce Selected Fault</button> |
|
|
| <h3>Choose a Playbook</h3> |
| <select id="playbookSelect" style="width:100%; margin-bottom:10px;"></select> |
| <div class="controls" style="margin-top:0;"> |
| <button id="applySelectedPlaybookBtn" class="good">Apply Selected Playbook</button> |
| <button id="hintBtn">Need a Hint?</button> |
| </div> |
|
|
| <div id="scenarioBox" class="scenario-box">Choose a known pattern or create a random incident.</div> |
| <div id="selectedBox" class="selected-box">Selected node: Wireless Core center. Click an outer domain to view its persona, function, state, and playbook.</div> |
|
|
| <h3>Legend</h3> |
| <div class="legend"> |
| <div class="legend-item"><span class="dot" style="background:var(--healthy)"></span> Healthy</div> |
| <div class="legend-item"><span class="dot" style="background:var(--degraded)"></span> Degraded</div> |
| <div class="legend-item"><span class="dot" style="background:var(--faulted)"></span> Faulted</div> |
| <div class="legend-item"><span class="dot" style="background:var(--mitigating)"></span> Mitigating</div> |
| <div class="legend-item"><span class="dot" style="background:var(--restored)"></span> Restored</div> |
| <div class="legend-item"><span class="dot" style="background:var(--unknown)"></span> Unknown</div> |
| </div> |
|
|
| <h3>10 Wireless Core Personas</h3> |
| <div id="domainList" class="domain-list"></div> |
|
|
| <h3>Event Log</h3> |
| <div id="log" class="log"></div> |
| </aside> |
| </main> |
|
|
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://unpkg.com/three@0.160.0/build/three.module.js", |
| "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" |
| } |
| } |
| </script> |
|
|
| <script type="module"> |
| import * as THREE from 'three'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'; |
| |
| const STATES = ["healthy", "degraded", "faulted", "mitigating", "restored", "unknown"]; |
| const stateColors = { |
| healthy: 0x27c46b, |
| degraded: 0xffd166, |
| faulted: 0xef476f, |
| mitigating: 0x4cc9f0, |
| restored: 0xb388ff, |
| unknown: 0x80889f |
| }; |
| |
| const domains = [ |
| { id: 0, abbr: "SUB", emoji: "📱", name: "Ava Connect", fn: "Subscriber / Device Requests", playbook: "Subscriber validation and provisioning check" }, |
| { id: 1, abbr: "RAN", emoji: "📡", name: "Rafi RAN", fn: "Radio Access Network", playbook: "Coverage, handoff, congestion, and RAN transport check" }, |
| { id: 2, abbr: "PKT", emoji: "🌐", name: "Pax Packet", fn: "Packet Core Sessions", playbook: "Attach, session, AMF, SMF, UPF validation" }, |
| { id: 3, abbr: "VOC", emoji: "☎️", name: "Vera Voice", fn: "Voice Core / IMS", playbook: "IMS, SIP, SBC, VoLTE/VoNR troubleshooting" }, |
| { id: 4, abbr: "MSG", emoji: "💬", name: "Milo Message", fn: "Messaging", playbook: "SMS/MMS queue, retry, gateway, delivery check" }, |
| { id: 5, abbr: "911", emoji: "🚨", name: "Elena Emergency", fn: "E911 Emergency Services", playbook: "Immediate emergency routing and location escalation" }, |
| { id: 6, abbr: "POL", emoji: "⚖️", name: "Priya Policy", fn: "Policy and Charging", playbook: "PCRF/PCF/OCS/CHF policy and charging validation" }, |
| { id: 7, abbr: "ID", emoji: "🪪", name: "Uri Identity", fn: "Subscriber Identity", playbook: "HSS/UDM/AUSF authentication and profile check" }, |
| { id: 8, abbr: "OPS", emoji: "📊", name: "Olivia Ops", fn: "Operations Telemetry", playbook: "KPI, alarm, log, dashboard, and change-record validation" }, |
| { id: 9, abbr: "EXT", emoji: "🔗", name: "Evan Edge", fn: "External Partners / OSS/BSS", playbook: "Roaming, interconnect, OSS/BSS, billing, DNS check" } |
| ]; |
| |
| const dependencyEdges = { |
| 0: [1,2,7,6], |
| 1: [0,2,3], |
| 2: [0,1,3,4,6,7,8], |
| 3: [1,2,5,7,8], |
| 4: [2,6,8,9], |
| 5: [3,8,9], |
| 6: [0,2,4,8], |
| 7: [0,2,3,6], |
| 8: [2,3,4,5,6,9], |
| 9: [4,5,8] |
| }; |
| |
| |
| |
| const dependencyWeights = { |
| "2-0": 0.55, "2-6": 0.52, "2-7": 0.48, "2-3": 0.30, "2-4": 0.25, "2-8": 0.28, |
| "6-2": 0.58, "6-0": 0.48, "6-4": 0.28, "6-8": 0.22, |
| "7-0": 0.62, "7-2": 0.55, "7-3": 0.36, "7-6": 0.22, |
| "1-0": 0.55, "1-2": 0.42, "1-3": 0.36, |
| "3-5": 0.30, "3-8": 0.22, "3-2": 0.20, |
| "4-9": 0.28, "4-8": 0.22, "4-2": 0.18, |
| "5-3": 0.18, "5-8": 0.24, "5-9": 0.20, |
| "8-2": 0.20, "8-3": 0.20, "8-4": 0.20, "8-5": 0.28, "8-6": 0.20, "8-9": 0.18, |
| "9-4": 0.42, "9-5": 0.30, "9-8": 0.22, "9-0": 0.18 |
| }; |
| |
| const evidenceChecklist = { |
| 0: "attach/service requests, SIM/profile status, provisioning, APN/DNN settings, roaming eligibility", |
| 1: "cell congestion, coverage, handoff attempts, access rejects, RAN transport alarms", |
| 2: "attach success, AMF/MME alarms, SMF/SGW/PGW errors, UPF health, PDU/session setup, IP allocation", |
| 3: "IMS registration, SIP response codes, SBC alarms, VoLTE/VoNR call setup, dropped calls, one-way audio", |
| 4: "SMSC/MMSC queues, retry rates, gateway errors, delivery receipts, routing failures, backlog depth", |
| 5: "911 call completion, emergency routing, location lookup, PSAP handoff, location accuracy, compliance alarms", |
| 6: "PCRF/PCF latency, policy rule changes, quota checks, charging records, OCS/CHF status, QoS rules", |
| 7: "HSS/UDM/AUSF lookups, auth timeouts, subscriber profile mismatch, SIM validation, replication lag", |
| 8: "KPI feed health, alarm pipeline, log ingestion, dashboard freshness, topology data, tickets and change records", |
| 9: "roaming partner status, interconnect health, OSS/BSS integrations, billing feeds, partner APIs, DNS/routing" |
| }; |
| |
| const patterns = [ |
| { name: "1. Packet Core Attach Failure", desc: "Attach success drops. Packet Core is faulted; Subscriber and Identity begin to degrade.", states: {2:"faulted",0:"degraded",7:"degraded",8:"degraded"} }, |
| { name: "2. Voice / IMS Registration Failure", desc: "Voice registration failures appear. Voice Core is faulted; RAN and Identity show pressure.", states: {3:"faulted",1:"degraded",7:"degraded",8:"degraded"} }, |
| { name: "3. Messaging Queue Backlog", desc: "SMS/MMS delivery is delayed. Messaging is faulted; External and Packet domains degrade.", states: {4:"faulted",9:"degraded",2:"degraded",8:"degraded"} }, |
| { name: "4. E911 Location Lookup Failure", desc: "Emergency location lookup is failing. E911 is faulted and requires immediate escalation.", states: {5:"faulted",3:"degraded",8:"degraded",9:"degraded"} }, |
| { name: "5. PCF / PCRF Policy Timeout", desc: "Policy response latency increases. Policy and Charging is faulted; Packet and Subscriber degrade.", states: {6:"faulted",2:"degraded",0:"degraded",8:"degraded"} }, |
| { name: "6. UDM / HSS Authentication Failure", desc: "Authentication requests time out. Identity is faulted; Subscriber and Packet Core degrade.", states: {7:"faulted",0:"degraded",2:"degraded",3:"degraded"} }, |
| { name: "7. RAN Congestion Cascade", desc: "Cell congestion and access rejects create downstream service symptoms.", states: {1:"faulted",0:"degraded",2:"degraded",3:"degraded"} }, |
| { name: "8. Monitoring Blind Spot", desc: "Telemetry is missing or stale. Fault confidence drops and domains become unknown.", states: {8:"faulted",2:"unknown",3:"unknown",4:"unknown",6:"degraded"} }, |
| { name: "9. Roaming Partner Outage", desc: "External partner problems affect roaming and message/voice handoff.", states: {9:"faulted",0:"degraded",3:"degraded",4:"degraded",5:"degraded"} }, |
| { name: "10. Change-Induced Multi-Domain Incident", desc: "A recent change creates policy, packet core, and monitoring symptoms.", states: {6:"faulted",2:"faulted",8:"degraded",0:"degraded",7:"degraded"} } |
| ]; |
| |
| let domainStates = Array(10).fill("healthy"); |
| let domainAges = Array(10).fill(0); |
| let evidenceSeen = Array(10).fill(false); |
| let turn = 0; |
| let timer = null; |
| let selectedId = null; |
| let pulseTime = 0; |
| |
| const canvas = document.getElementById('scene'); |
| const scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0x070b18); |
| |
| const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 1000); |
| camera.position.set(0, 8, 18); |
| |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| |
| const labelRenderer = new CSS2DRenderer(); |
| labelRenderer.domElement.style.position = 'absolute'; |
| labelRenderer.domElement.style.top = '0'; |
| labelRenderer.domElement.style.pointerEvents = 'none'; |
| document.querySelector('.stage-card').appendChild(labelRenderer.domElement); |
| |
| const controls = new OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; |
| controls.dampingFactor = 0.06; |
| controls.minDistance = 9; |
| controls.maxDistance = 32; |
| controls.maxPolarAngle = Math.PI * 0.82; |
| |
| const ambient = new THREE.AmbientLight(0xffffff, 0.55); |
| scene.add(ambient); |
| const keyLight = new THREE.DirectionalLight(0xffffff, 1.1); |
| keyLight.position.set(8, 12, 10); |
| scene.add(keyLight); |
| const rimLight = new THREE.PointLight(0x7dd3fc, 2.2, 40); |
| rimLight.position.set(-8, 6, -8); |
| scene.add(rimLight); |
| |
| const nodeGroup = new THREE.Group(); |
| const edgeGroup = new THREE.Group(); |
| const particleGroup = new THREE.Group(); |
| scene.add(edgeGroup, particleGroup, nodeGroup); |
| |
| const nodeMeshes = []; |
| const labelObjects = []; |
| const edges = []; |
| const particles = []; |
| |
| const centerGeo = new THREE.SphereGeometry(1.35, 48, 48); |
| const centerMat = new THREE.MeshStandardMaterial({ color: 0x7dd3fc, emissive: 0x184c66, roughness: 0.35, metalness: 0.25 }); |
| const centerMesh = new THREE.Mesh(centerGeo, centerMat); |
| centerMesh.userData = { center: true }; |
| nodeGroup.add(centerMesh); |
| |
| addLabel(centerMesh, "Wireless Core", "Command Center", 0, 1.75, true); |
| |
| function makeTextSprite(text, sub, isCenter=false) { |
| const div = document.createElement('div'); |
| div.style.color = isCenter ? '#e8fbff' : '#eef3ff'; |
| div.style.fontFamily = 'Arial, Helvetica, sans-serif'; |
| div.style.fontSize = isCenter ? '18px' : '13px'; |
| div.style.fontWeight = '700'; |
| div.style.textAlign = 'center'; |
| div.style.textShadow = '0 2px 8px rgba(0,0,0,0.9)'; |
| div.style.padding = '4px 8px'; |
| div.style.borderRadius = '999px'; |
| div.style.background = isCenter ? 'rgba(15,121,168,0.35)' : 'rgba(7,12,28,0.58)'; |
| div.innerHTML = `${text}<br><span style="font-size:11px;font-weight:500;color:#a9b7d9">${sub}</span>`; |
| return div; |
| } |
| |
| function addLabel(mesh, text, sub, x=0, y=1.1, isCenter=false) { |
| const obj = new CSS2DObject(makeTextSprite(text, sub, isCenter)); |
| obj.position.set(x, y, 0); |
| mesh.add(obj); |
| labelObjects.push(obj); |
| } |
| |
| function createNodes() { |
| const radius = 7.2; |
| const verticalWave = [0, 1.1, -0.4, 0.9, -1.0, 1.6, -0.8, 0.8, -1.5, 0.2]; |
| domains.forEach((d, i) => { |
| const angle = (i / domains.length) * Math.PI * 2 - Math.PI / 2; |
| const x = Math.cos(angle) * radius; |
| const z = Math.sin(angle) * radius; |
| const y = verticalWave[i]; |
| |
| const geo = new THREE.SphereGeometry(0.82, 40, 40); |
| const mat = new THREE.MeshStandardMaterial({ |
| color: stateColors.healthy, |
| emissive: 0x082214, |
| roughness: 0.34, |
| metalness: 0.18 |
| }); |
| const mesh = new THREE.Mesh(geo, mat); |
| mesh.position.set(x, y, z); |
| mesh.userData = { id: i }; |
| nodeGroup.add(mesh); |
| nodeMeshes[i] = mesh; |
| addLabel(mesh, `${d.emoji} ${d.name}`, d.fn); |
| |
| const coreLine = createLine(new THREE.Vector3(0,0,0), mesh.position, 0x3d5f92, 0.45); |
| edgeGroup.add(coreLine); |
| edges.push({ line: coreLine, from: null, to: i, core: true }); |
| }); |
| |
| const created = new Set(); |
| Object.entries(dependencyEdges).forEach(([from, tos]) => { |
| tos.forEach(to => { |
| const key = [Number(from), to].sort((a,b) => a-b).join('-'); |
| if (!created.has(key)) { |
| created.add(key); |
| const line = createLine(nodeMeshes[from].position, nodeMeshes[to].position, 0x5b6f9f, 0.30); |
| edgeGroup.add(line); |
| edges.push({ line, from: Number(from), to, core: false }); |
| const particle = createParticle(nodeMeshes[from].position, nodeMeshes[to].position); |
| particleGroup.add(particle.mesh); |
| particles.push(particle); |
| } |
| }); |
| }); |
| } |
| |
| function createLine(start, end, color, opacity) { |
| const geometry = new THREE.BufferGeometry().setFromPoints([start.clone(), end.clone()]); |
| const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity }); |
| return new THREE.Line(geometry, material); |
| } |
| |
| function createParticle(start, end) { |
| const geo = new THREE.SphereGeometry(0.065, 12, 12); |
| const mat = new THREE.MeshBasicMaterial({ color: 0x7dd3fc, transparent: true, opacity: 0.72 }); |
| const mesh = new THREE.Mesh(geo, mat); |
| return { mesh, start: start.clone(), end: end.clone(), t: Math.random(), speed: 0.003 + Math.random() * 0.006 }; |
| } |
| |
| function addStarField() { |
| const geo = new THREE.BufferGeometry(); |
| const points = []; |
| for (let i=0; i<800; i++) { |
| points.push((Math.random()-0.5)*80, (Math.random()-0.5)*45, (Math.random()-0.5)*80); |
| } |
| geo.setAttribute('position', new THREE.Float32BufferAttribute(points, 3)); |
| const mat = new THREE.PointsMaterial({ color: 0x7dd3fc, size: 0.035, transparent: true, opacity: 0.38 }); |
| scene.add(new THREE.Points(geo, mat)); |
| } |
| |
| createNodes(); |
| addStarField(); |
| |
| const raycaster = new THREE.Raycaster(); |
| const mouse = new THREE.Vector2(); |
| |
| canvas.addEventListener('click', (event) => { |
| const rect = canvas.getBoundingClientRect(); |
| mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; |
| mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; |
| raycaster.setFromCamera(mouse, camera); |
| const intersects = raycaster.intersectObjects(nodeMeshes, false); |
| if (intersects.length > 0) { |
| const id = intersects[0].object.userData.id; |
| selectedId = id; |
| cycleState(id); |
| updateSelectedBox(id); |
| } |
| }); |
| |
| function resize() { |
| const rect = canvas.parentElement.getBoundingClientRect(); |
| const w = rect.width; |
| const h = canvas.clientHeight || 720; |
| camera.aspect = w / h; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(w, h, false); |
| labelRenderer.setSize(w, h); |
| } |
| window.addEventListener('resize', resize); |
| resize(); |
| |
| function updateNodeMaterials() { |
| nodeMeshes.forEach((mesh, i) => { |
| const state = domainStates[i]; |
| mesh.material.color.setHex(stateColors[state]); |
| const c = new THREE.Color(stateColors[state]); |
| mesh.material.emissive.copy(c).multiplyScalar(state === 'faulted' ? 0.55 : state === 'degraded' ? 0.28 : 0.18); |
| }); |
| |
| edges.forEach(e => { |
| if (e.core) { |
| e.line.material.color.setHex(0x3d5f92); |
| e.line.material.opacity = 0.34; |
| return; |
| } |
| const a = domainStates[e.from]; |
| const b = domainStates[e.to]; |
| if (a === 'faulted' || b === 'faulted') { |
| e.line.material.color.setHex(0xef476f); |
| e.line.material.opacity = 0.82; |
| } else if (a === 'degraded' || b === 'degraded') { |
| e.line.material.color.setHex(0xffd166); |
| e.line.material.opacity = 0.58; |
| } else if (a === 'mitigating' || b === 'mitigating') { |
| e.line.material.color.setHex(0x4cc9f0); |
| e.line.material.opacity = 0.75; |
| } else { |
| e.line.material.color.setHex(0x5b6f9f); |
| e.line.material.opacity = 0.26; |
| } |
| }); |
| } |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| pulseTime += 0.018; |
| nodeGroup.rotation.y += 0.0009; |
| edgeGroup.rotation.y = nodeGroup.rotation.y; |
| particleGroup.rotation.y = nodeGroup.rotation.y; |
| |
| nodeMeshes.forEach((mesh, i) => { |
| const state = domainStates[i]; |
| const pulse = state === 'faulted' ? 1 + Math.sin(pulseTime * 8) * 0.16 : |
| state === 'degraded' ? 1 + Math.sin(pulseTime * 5) * 0.08 : |
| state === 'mitigating' ? 1 + Math.sin(pulseTime * 6) * 0.11 : 1; |
| const selectedBoost = selectedId === i ? 1.18 : 1; |
| mesh.scale.setScalar(pulse * selectedBoost); |
| }); |
| |
| centerMesh.scale.setScalar(1 + Math.sin(pulseTime * 2) * 0.035); |
| |
| particles.forEach(p => { |
| p.t += p.speed; |
| if (p.t > 1) p.t = 0; |
| p.mesh.position.lerpVectors(p.start, p.end, p.t); |
| }); |
| |
| controls.update(); |
| renderer.render(scene, camera); |
| labelRenderer.render(scene, camera); |
| } |
| animate(); |
| |
| function stateWeight(state) { |
| return { healthy: 0, restored: 0, mitigating: 1, unknown: 1, degraded: 2, faulted: 4 }[state] ?? 0; |
| } |
| |
| function statusLabel() { |
| const faults = domainStates.filter(s => s === "faulted").length; |
| const degraded = domainStates.filter(s => s === "degraded").length; |
| if (domainStates[5] === "faulted") return "E911 CRITICAL"; |
| if (faults >= 4) return "Major Incident"; |
| if (faults > 0) return "Active Fault"; |
| if (degraded > 0) return "Degraded"; |
| return "Stable"; |
| } |
| |
| function renderUI() { |
| const counts = Object.fromEntries(STATES.map(s => [s, domainStates.filter(x => x === s).length])); |
| document.getElementById("turnCount").textContent = turn; |
| document.getElementById("healthyCount").textContent = counts.healthy + counts.restored; |
| document.getElementById("degradedCount").textContent = counts.degraded; |
| document.getElementById("faultedCount").textContent = counts.faulted; |
| document.getElementById("riskScore").textContent = domainStates.reduce((a, s) => a + stateWeight(s), 0); |
| document.getElementById("statusText").textContent = statusLabel(); |
| renderDomains(); |
| updateNodeMaterials(); |
| if (selectedId !== null) updateSelectedBox(selectedId); |
| } |
| |
| function renderDomains() { |
| const list = document.getElementById("domainList"); |
| list.innerHTML = ""; |
| domains.forEach((d, i) => { |
| const item = document.createElement("div"); |
| item.className = "domain"; |
| item.innerHTML = `<div>${d.emoji}</div><div><b>${d.name}</b><span>${d.fn}</span></div><div class="pill">${domainStates[i]}</div>`; |
| item.addEventListener('click', () => { selectedId = i; updateSelectedBox(i); }); |
| list.appendChild(item); |
| }); |
| } |
| |
| function updateSelectedBox(id) { |
| const d = domains[id]; |
| const neighborNames = (dependencyEdges[id] || []).map(n => domains[n].abbr).join(', '); |
| document.getElementById('selectedBox').innerHTML = ` |
| <b>${d.emoji} ${d.name}</b><br> |
| <span style="color:#a9b7d9">${d.fn}</span><br><br> |
| <b>State:</b> ${domainStates[id]}<br> |
| <b>Connected domains:</b> ${neighborNames}<br> |
| <b>Playbook:</b> ${d.playbook} |
| `; |
| } |
| |
| function cycleState(id) { |
| const idx = STATES.indexOf(domainStates[id]); |
| domainStates[id] = STATES[(idx + 1) % STATES.length]; |
| log(`${domains[id].name} changed to ${domainStates[id]}.`); |
| renderUI(); |
| } |
| |
| function resetGame() { |
| turn = 0; |
| domainStates = Array(10).fill("healthy"); |
| domainAges = Array(10).fill(0); |
| evidenceSeen = Array(10).fill(false); |
| selectedId = null; |
| document.getElementById('scenarioBox').textContent = "Board reset. Choose a known pattern, introduce a specific fault, or create a random incident."; |
| document.getElementById('selectedBox').textContent = "Selected node: Wireless Core center. Click an outer domain to view its persona, function, state, and playbook."; |
| document.getElementById('log').textContent = ""; |
| stopPlay(); |
| log("Reset complete. All Wireless Core domains are healthy."); |
| renderUI(); |
| } |
| |
| function setState(id, newState) { |
| if (domainStates[id] !== newState) { |
| domainStates[id] = newState; |
| domainAges[id] = 0; |
| } |
| } |
| |
| function randomSet() { |
| turn = 0; |
| domainStates = domainStates.map(() => { |
| const x = Math.random(); |
| if (x < 0.62) return "healthy"; |
| if (x < 0.82) return "degraded"; |
| if (x < 0.90) return "faulted"; |
| if (x < 0.96) return "unknown"; |
| return "restored"; |
| }); |
| domainAges = Array(10).fill(0); |
| evidenceSeen = Array(10).fill(false); |
| document.getElementById('scenarioBox').textContent = "Random network condition created. Use Inspect Signals before choosing a playbook. Fault spread is gradual unless ignored."; |
| log("Random incident set created."); |
| renderUI(); |
| } |
| |
| function loadPattern(index) { |
| turn = 0; |
| domainStates = Array(10).fill("healthy"); |
| domainAges = Array(10).fill(0); |
| evidenceSeen = Array(10).fill(false); |
| const p = patterns[index]; |
| Object.entries(p.states).forEach(([id, state]) => domainStates[Number(id)] = state); |
| document.getElementById('scenarioBox').textContent = p.desc; |
| log(`Known pattern loaded: ${p.name}`); |
| log("Tip: inspect signals or ask for a hint before choosing a playbook. The correct playbook targets the highest-risk active domain, not always the first symptom."); |
| renderUI(); |
| } |
| |
| function spreadProbability(from, to) { |
| const key1 = `${from}-${to}`; |
| const key2 = `${to}-${from}`; |
| return dependencyWeights[key1] ?? dependencyWeights[key2] ?? 0.18; |
| } |
| |
| function step() { |
| turn++; |
| const next = [...domainStates]; |
| const nextAges = domainAges.map(a => a + 1); |
| |
| |
| for (let i = 0; i < domains.length; i++) { |
| const state = domainStates[i]; |
| const neighbors = dependencyEdges[i] || []; |
| const faultedNeighbors = neighbors.filter(n => domainStates[n] === "faulted"); |
| const degradedNeighbors = neighbors.filter(n => domainStates[n] === "degraded"); |
| |
| if (state === "healthy") { |
| let pressure = 0; |
| faultedNeighbors.forEach(n => pressure += spreadProbability(n, i) * 0.35); |
| degradedNeighbors.forEach(n => pressure += spreadProbability(n, i) * 0.10); |
| if (Math.random() < Math.min(0.28, pressure)) { |
| next[i] = "degraded"; |
| nextAges[i] = 0; |
| log(`${domains[i].name} became degraded due to dependency pressure.`); |
| } |
| } |
| |
| if (state === "degraded") { |
| let escalation = 0.035 + (domainAges[i] * 0.015); |
| faultedNeighbors.forEach(n => escalation += spreadProbability(n, i) * 0.12); |
| if (Math.random() < Math.min(0.24, escalation)) { |
| next[i] = "faulted"; |
| nextAges[i] = 0; |
| log(`${domains[i].name} escalated from degraded to faulted.`); |
| } else if (faultedNeighbors.length === 0 && degradedNeighbors.length === 0 && domainAges[i] >= 2 && Math.random() < 0.18) { |
| next[i] = "healthy"; |
| nextAges[i] = 0; |
| log(`${domains[i].name} stabilized naturally after degradation.`); |
| } |
| } |
| |
| if (state === "faulted") { |
| |
| const chance = i === 5 ? 0.01 : 0.035; |
| if (domainAges[i] >= 4 && Math.random() < chance) { |
| next[i] = "degraded"; |
| nextAges[i] = 0; |
| log(`${domains[i].name} partially stabilized but still requires validation.`); |
| } |
| } |
| |
| if (state === "mitigating") { |
| if (domainAges[i] >= 1) { |
| next[i] = Math.random() < 0.82 ? "restored" : "degraded"; |
| nextAges[i] = 0; |
| log(`${domains[i].name} moved from mitigating to ${next[i]}.`); |
| } |
| } |
| |
| if (state === "restored") { |
| if (domainAges[i] >= 2 && Math.random() < 0.62) { |
| next[i] = "healthy"; |
| nextAges[i] = 0; |
| log(`${domains[i].name} returned to healthy after observation.`); |
| } |
| } |
| |
| if (state === "unknown") { |
| if (domainStates[8] !== "faulted" && domainAges[i] >= 1 && Math.random() < 0.45) { |
| next[i] = "degraded"; |
| nextAges[i] = 0; |
| log(`${domains[i].name} unknown state resolved into degraded evidence.`); |
| } |
| } |
| } |
| |
| |
| if (domainStates[8] === "faulted" && domainAges[8] >= 1) { |
| [2,3,4,6].forEach(id => { |
| if (Math.random() < 0.08 && next[id] !== "faulted") { |
| next[id] = "unknown"; |
| nextAges[id] = 0; |
| log(`${domains[id].name} visibility became unknown because telemetry is faulted.`); |
| } |
| }); |
| } |
| |
| domainStates = next; |
| domainAges = nextAges; |
| log(`Turn ${turn}: realistic cascade rules applied. Status: ${statusLabel()}.`); |
| if (domainStates[5] === "faulted") log("E911 critical: immediate validation and escalation required."); |
| renderUI(); |
| } |
| |
| function inspectSignals() { |
| const active = domains.map((d, i) => ({...d, state: domainStates[i], id: i})).filter(d => d.state !== "healthy"); |
| |
| if (active.length === 0) { |
| selectedId = null; |
| document.getElementById('scenarioBox').textContent = "Inspection result: all domains appear healthy. No playbook is recommended right now."; |
| document.getElementById('selectedBox').textContent = "Inspection result: no active degraded, faulted, or unknown domain was found."; |
| log("Inspection: all domains appear healthy. Continue monitoring KPIs, alarms, logs, and tickets."); |
| renderUI(); |
| return; |
| } |
| |
| |
| active.forEach(d => evidenceSeen[d.id] = true); |
| |
| |
| const target = getHighestPriorityActiveFault(); |
| selectedId = target; |
| document.getElementById('playbookSelect').value = String(target); |
| |
| const targetDomain = domains[target]; |
| const activeSummary = active.map(d => `${d.abbr}:${d.state}`).join(' | '); |
| |
| log("Inspection Summary:"); |
| active.forEach(d => { |
| log(`- ${d.name} (${d.fn}) is ${d.state}. Evidence to check: ${evidenceChecklist[d.id]}.`); |
| }); |
| log(`Inspection recommendation: select the ${targetDomain.fn} playbook for ${targetDomain.name}.`); |
| |
| document.getElementById('scenarioBox').innerHTML = ` |
| <b>Inspection Result:</b> ${active.length} active signal domain(s) found.<br> |
| <b>Active states:</b> ${activeSummary}<br> |
| <b>Recommended playbook:</b> ${targetDomain.name} — ${targetDomain.fn}<br> |
| <b>Why:</b> This is the highest-priority active fault based on E911-first, then Packet Core, Policy, Identity, Voice, Messaging, RAN, Subscriber, Telemetry, and External dependency priority. |
| `; |
| |
| document.getElementById('selectedBox').innerHTML = ` |
| <b>🔎 Inspected Domain: ${targetDomain.emoji} ${targetDomain.name}</b><br> |
| <span style="color:#a9b7d9">${targetDomain.fn}</span><br><br> |
| <b>Current State:</b> ${domainStates[target]}<br> |
| <b>Evidence to check:</b> ${evidenceChecklist[target]}<br> |
| <b>Recommended Playbook:</b> ${targetDomain.playbook}<br> |
| <b>Next Step:</b> Click “Apply Selected Playbook.” |
| `; |
| |
| renderUI(); |
| } |
| |
| function introduceFault() { |
| const id = Number(document.getElementById('faultSelect').value); |
| const d = domains[id]; |
| setState(id, "faulted"); |
| evidenceSeen[id] = true; |
| |
| |
| const neighbors = dependencyEdges[id] || []; |
| neighbors.forEach(n => { |
| if (domainStates[n] === "healthy" && Math.random() < 0.22) setState(n, "degraded"); |
| }); |
| |
| selectedId = id; |
| document.getElementById('scenarioBox').textContent = `Manual fault introduced: ${d.fn}. Use hints or select the correct playbook to mitigate it.`; |
| log(`Manual fault introduced in ${d.name} (${d.fn}). Neighboring dependent domains may degrade.`); |
| renderUI(); |
| } |
| |
| function getHighestPriorityActiveFault() { |
| |
| |
| const priority = [5,2,6,7,3,4,1,0,8,9]; |
| const faulted = priority.find(id => domainStates[id] === "faulted"); |
| if (faulted !== undefined) return faulted; |
| const degraded = priority.find(id => domainStates[id] === "degraded"); |
| if (degraded !== undefined) return degraded; |
| return priority.find(id => domainStates[id] === "unknown"); |
| } |
| |
| function provideHint() { |
| const target = getHighestPriorityActiveFault(); |
| if (target === undefined) { |
| log("Hint: no active degraded, faulted, or unknown domain exists. Keep monitoring."); |
| document.getElementById('scenarioBox').textContent = "Hint: all domains are healthy or restored. No playbook is needed right now."; |
| return; |
| } |
| |
| const d = domains[target]; |
| const reasonMap = { |
| 0: "Subscriber/device request issues often start with provisioning, SIM/profile validation, roaming eligibility, or APN/DNN configuration.", |
| 1: "RAN issues can create downstream symptoms before the Wireless Core sees clean service requests, so coverage, handoff, congestion, and access rejects should be checked first.", |
| 2: "Packet Core is central to mobile data sessions. Attach, registration, AMF, SMF, UPF, session setup, and IP allocation failures can cascade to multiple services.", |
| 3: "Voice issues commonly involve IMS registration, SIP signaling, SBC behavior, call setup, codec negotiation, or VoLTE/VoNR transport.", |
| 4: "Messaging delays or failures often come from SMSC/MMSC queues, gateway timeouts, routing failures, retry storms, or delivery receipt issues.", |
| 5: "E911 affects emergency routing and location lookup, so it should be handled as the highest-priority playbook with immediate escalation.", |
| 6: "Policy and Charging faults can block or degrade Packet Core sessions through PCRF/PCF timeouts, quota checks, service rules, or charging failures.", |
| 7: "Identity faults can prevent subscribers from authenticating, registering, roaming, or receiving authorized services through HSS, UDM, or AUSF failures.", |
| 8: "Operations Telemetry faults reduce visibility. Bad KPIs, stale dashboards, missing alarms, or log ingestion failures can hide the real incident.", |
| 9: "External Partner or OSS/BSS issues can affect roaming, interconnects, billing feeds, partner APIs, enterprise services, or DNS dependencies." |
| }; |
| |
| evidenceSeen[target] = true; |
| const hintText = `Hint: choose the ${d.fn} playbook for ${d.name}. Why: ${reasonMap[target]} Evidence to check: ${evidenceChecklist[target]}.`; |
| document.getElementById('scenarioBox').textContent = hintText; |
| log(hintText); |
| selectedId = target; |
| updateSelectedBox(target); |
| renderUI(); |
| } |
| |
| function applySelectedPlaybook() { |
| const selected = Number(document.getElementById('playbookSelect').value); |
| const target = getHighestPriorityActiveFault(); |
| const d = domains[selected]; |
| |
| if (target === undefined) { |
| log(`Playbook selected: ${d.fn}. No active fault requires mitigation.`); |
| return; |
| } |
| |
| const targetDomain = domains[target]; |
| selectedId = selected; |
| |
| const directMatch = selected === target; |
| const connectedMatch = (dependencyEdges[target] || []).includes(selected) && evidenceSeen[target]; |
| |
| if (directMatch || connectedMatch) { |
| const solvedId = directMatch ? selected : target; |
| const solvedDomain = domains[solvedId]; |
| |
| if (domainStates[solvedId] === "faulted") setState(solvedId, "mitigating"); |
| else if (["degraded", "unknown"].includes(domainStates[solvedId])) setState(solvedId, "restored"); |
| |
| |
| (dependencyEdges[solvedId] || []).forEach(n => { |
| if (domainStates[n] === "degraded" && Math.random() < 0.70) setState(n, "restored"); |
| if (domainStates[n] === "unknown" && Math.random() < 0.55) setState(n, "degraded"); |
| }); |
| |
| log(`Correct playbook: ${d.name} (${d.fn}). Active incident target: ${solvedDomain.name} (${solvedDomain.fn}).`); |
| log(`Why it worked: the playbook addressed the highest-priority active fault path and checked the right evidence: ${evidenceChecklist[solvedId]}.`); |
| document.getElementById('scenarioBox').textContent = `Playbook applied successfully. ${solvedDomain.name} is now ${domainStates[solvedId]}, and nearby symptoms may begin restoring.`; |
| if (solvedId === 5) log("E911 note: continue immediate escalation, validation, documentation, and compliance review even after mitigation starts."); |
| } else { |
| |
| turn++; |
| domainAges[target] += 1; |
| if (domainStates[target] === "degraded" && domainAges[target] >= 2 && Math.random() < 0.35) setState(target, "faulted"); |
| const neighbor = (dependencyEdges[target] || []).find(n => domainStates[n] === "healthy"); |
| if (neighbor !== undefined && Math.random() < 0.25) setState(neighbor, "degraded"); |
| |
| log(`Wrong playbook: ${d.name} (${d.fn}) was selected, but current evidence points to ${targetDomain.name} (${targetDomain.fn}).`); |
| log(`Why it missed: it does not address the primary failing evidence path: ${evidenceChecklist[target]}. Use Need a Hint or Inspect Signals.`); |
| document.getElementById('scenarioBox').textContent = `That playbook did not match the main fault. Current evidence points to ${targetDomain.name} — ${targetDomain.fn}.`; |
| } |
| |
| renderUI(); |
| } |
| |
| function applyPlaybook() { |
| const target = getHighestPriorityActiveFault(); |
| if (target === undefined) { |
| log("Auto Playbook: no degraded or faulted domain found. No action needed."); |
| return; |
| } |
| document.getElementById('playbookSelect').value = String(target); |
| applySelectedPlaybook(); |
| } |
| |
| function playPause() { |
| if (timer) stopPlay(); |
| else { |
| timer = setInterval(step, 1000); |
| document.getElementById("playBtn").textContent = "⏸ Pause"; |
| log("Play started. Fault cascade advancing every second."); |
| } |
| } |
| |
| function stopPlay() { |
| if (timer) clearInterval(timer); |
| timer = null; |
| document.getElementById("playBtn").textContent = "▶ Play"; |
| } |
| |
| function log(message) { |
| const logEl = document.getElementById('log'); |
| const stamp = new Date().toLocaleTimeString(); |
| logEl.textContent += `[${stamp}] ${message}\n`; |
| logEl.scrollTop = logEl.scrollHeight; |
| } |
| |
| function setupPatterns() { |
| const patternSelect = document.getElementById('patternSelect'); |
| patterns.forEach((p, i) => { |
| const opt = document.createElement("option"); |
| opt.value = i; |
| opt.textContent = p.name; |
| patternSelect.appendChild(opt); |
| }); |
| } |
| |
| function setupFaultAndPlaybookSelectors() { |
| const faultSelect = document.getElementById('faultSelect'); |
| const playbookSelect = document.getElementById('playbookSelect'); |
| |
| domains.forEach((d, i) => { |
| const faultOpt = document.createElement('option'); |
| faultOpt.value = i; |
| faultOpt.textContent = `${d.name} — ${d.fn} Fault`; |
| faultSelect.appendChild(faultOpt); |
| |
| const playbookOpt = document.createElement('option'); |
| playbookOpt.value = i; |
| playbookOpt.textContent = `${d.name} — ${d.fn} Playbook`; |
| playbookSelect.appendChild(playbookOpt); |
| }); |
| } |
| |
| document.getElementById("playBtn").addEventListener("click", playPause); |
| document.getElementById("stepBtn").addEventListener("click", step); |
| document.getElementById("resetBtn").addEventListener("click", resetGame); |
| document.getElementById("randomBtn").addEventListener("click", randomSet); |
| document.getElementById("inspectBtn").addEventListener("click", inspectSignals); |
| document.getElementById("playbookBtn").addEventListener("click", applyPlaybook); |
| document.getElementById("applySelectedPlaybookBtn").addEventListener("click", applySelectedPlaybook); |
| document.getElementById("hintBtn").addEventListener("click", provideHint); |
| document.getElementById("introduceFaultBtn").addEventListener("click", introduceFault); |
| document.getElementById("loadPatternBtn").addEventListener("click", () => loadPattern(Number(document.getElementById('patternSelect').value))); |
| |
| setupPatterns(); |
| setupFaultAndPlaybookSelectors(); |
| resetGame(); |
| </script> |
| </body> |
| </html> |
|
|