diff --git a/app/views/break_escape/games/show.html.erb b/app/views/break_escape/games/show.html.erb
index c8e3bbb7..d16a2f2a 100644
--- a/app/views/break_escape/games/show.html.erb
+++ b/app/views/break_escape/games/show.html.erb
@@ -58,6 +58,7 @@
+
diff --git a/public/break_escape/css/shredded-document-minigame.css b/public/break_escape/css/shredded-document-minigame.css
new file mode 100644
index 00000000..6b9ee707
--- /dev/null
+++ b/public/break_escape/css/shredded-document-minigame.css
@@ -0,0 +1,223 @@
+/* ============================================================
+ Shredded Document Reconstruction Minigame (MG-B)
+ ============================================================ */
+
+.sdm-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+.sdm-game-container {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+ margin: 0 !important;
+}
+
+.sdm-container .minigame-close-button {
+ top: 0;
+ right: 0;
+}
+
+/* ── Outer panel ─────────────────────────────────────────── */
+
+.sdm-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: #1a1a2e;
+ font-family: 'Press Start 2P', monospace;
+}
+
+/* ── Instruction bar ─────────────────────────────────────── */
+
+.sdm-instruction {
+ padding: 8px 14px;
+ font-size: 7px;
+ color: #8a9bb5;
+ border-bottom: 2px solid #2a3a5a;
+ line-height: 1.6;
+ flex-shrink: 0;
+}
+
+/* ── Scrollable strip area ───────────────────────────────── */
+
+.sdm-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 14px 20px;
+}
+
+/* ── Document title ──────────────────────────────────────── */
+
+.sdm-doc-title {
+ font-size: 7px;
+ color: #4a6090;
+ letter-spacing: 1px;
+ text-align: center;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #2a3a5a;
+}
+
+/* ── Strip list ──────────────────────────────────────────── */
+
+.sdm-strips-area {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 0 8px;
+}
+
+/* ── Individual strip tile ───────────────────────────────── */
+
+.sdm-strip {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 10px;
+ background: #f5f0e8;
+ border-top: 1px dashed #b0a090;
+ border-bottom: 1px dashed #b0a090;
+ border-left: 3px solid #8a7a6a;
+ border-right: 3px solid #8a7a6a;
+ cursor: grab;
+ user-select: none;
+ overflow: hidden;
+ transform: rotate(var(--tilt, 0deg));
+ box-shadow: 2px 3px 6px rgba(0, 0, 0, 0.22);
+ transition: opacity 0.1s;
+ min-height: 34px;
+}
+
+.sdm-strip:active {
+ cursor: grabbing;
+}
+
+/* Drag states */
+
+.sdm-strip-dragging {
+ opacity: 0.45;
+ border-color: #6a82aa !important;
+}
+
+.sdm-insert-before::before,
+.sdm-insert-after::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: #4a90d9;
+ border-radius: 2px;
+ z-index: 2;
+}
+
+.sdm-insert-before::before {
+ top: -6px;
+}
+
+.sdm-insert-after::after {
+ bottom: -6px;
+}
+
+/* Rotated strip — rotate the content wrapper, flip button stays upright */
+
+.sdm-strip-content {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+}
+
+.sdm-strip-rotated .sdm-strip-content {
+ transform: rotate(180deg);
+}
+
+/* Locked (completed) strip */
+
+.sdm-strip-locked {
+ cursor: default;
+ border-color: #6a9a6a;
+ background: #f0f5f0;
+}
+
+/* ── Strip contents ──────────────────────────────────────── */
+
+.sdm-drag-handle {
+ color: #b0a090;
+ font-size: 14px;
+ flex-shrink: 0;
+ line-height: 1;
+}
+
+.sdm-strip-text {
+ font-family: 'VT323', monospace;
+ font-size: 17px;
+ color: #1a1008;
+ line-height: 1.3;
+ flex: 1;
+}
+
+.sdm-strip-locked .sdm-strip-text {
+ color: #2a3a1a;
+}
+
+/* ── Flip button ─────────────────────────────────────────── */
+
+.sdm-flip-btn {
+ font-family: 'Press Start 2P', monospace;
+ font-size: 9px;
+ background: #e8e0d0;
+ color: #5a4a3a;
+ border: 2px solid #b0a090;
+ padding: 3px 6px;
+ cursor: pointer;
+ flex-shrink: 0;
+ line-height: 1;
+}
+
+.sdm-flip-btn:hover {
+ background: #d8cfc0;
+ border-color: #8a7a6a;
+}
+
+/* ── Completed state ─────────────────────────────────────── */
+
+.sdm-completed-banner {
+ padding: 10px 16px;
+ background: #062010;
+ border-bottom: 2px solid #00c853;
+ color: #00c853;
+ font-size: 7px;
+ letter-spacing: 1px;
+ flex-shrink: 0;
+}
+
+.sdm-success-reveal {
+ padding: 10px 16px;
+ background: #081808;
+ border-bottom: 2px solid #2a3a5a;
+ color: #c8ffcc;
+ font-family: 'VT323', monospace;
+ font-size: 16px;
+ line-height: 1.4;
+ flex-shrink: 0;
+}
+
+/* ── Empty state ─────────────────────────────────────────── */
+
+.sdm-empty-state {
+ font-size: 7px;
+ color: #4a6090;
+ text-align: center;
+ padding: 24px 0;
+}
+
diff --git a/public/break_escape/js/minigames/index.js b/public/break_escape/js/minigames/index.js
index 73304e22..40c89ff3 100644
--- a/public/break_escape/js/minigames/index.js
+++ b/public/break_escape/js/minigames/index.js
@@ -37,6 +37,7 @@ export { LogFilterMinigame } from './log-filter/log-filter-minigame.js';
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 { ShreddedDocumentMinigame } from './shredded-document/shredded-document-minigame.js';
export { CryptexMinigame } from './cryptex/cryptex-minigame.js';
export { CombinationMinigame } from './combination/combination-minigame.js';
@@ -120,6 +121,7 @@ import { LogFilterMinigame } from './log-filter/log-filter-minigame.js';
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 { ShreddedDocumentMinigame } from './shredded-document/shredded-document-minigame.js';
import { CryptexMinigame } from './cryptex/cryptex-minigame.js';
import { CombinationMinigame } from './combination/combination-minigame.js';
@@ -167,6 +169,7 @@ MinigameFramework.registerScene('log-filter', LogFilterMinigame);
MinigameFramework.registerScene('drug-library-integrity', DrugLibraryIntegrityMinigame);
MinigameFramework.registerScene('coverage-decision-form', CoverageDecisionFormMinigame);
MinigameFramework.registerScene('warranty-checklist', WarrantyChecklistMinigame);
+MinigameFramework.registerScene('shredded-document', ShreddedDocumentMinigame);
MinigameFramework.registerScene('cryptex', CryptexMinigame);
MinigameFramework.registerScene('combination', CombinationMinigame);
diff --git a/public/break_escape/js/minigames/shredded-document/shredded-document-minigame.js b/public/break_escape/js/minigames/shredded-document/shredded-document-minigame.js
new file mode 100644
index 00000000..6d673e4a
--- /dev/null
+++ b/public/break_escape/js/minigames/shredded-document/shredded-document-minigame.js
@@ -0,0 +1,291 @@
+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 });
+ }
+}
+
+export class ShreddedDocumentMinigame extends MinigameScene {
+ constructor(container, params) {
+ super(container, {
+ ...params,
+ title: params.title || 'Document Reconstruction',
+ showCancel: true,
+ cancelText: params.cancelText || 'Close'
+ });
+
+ const minigameData = params.lockable?.scenarioData?.minigameData || {};
+
+ this.allowRotation = params.allowRotation ?? minigameData.allowRotation ?? false;
+ this.documentTitle = params.documentTitle || minigameData.documentTitle || null;
+ this.successMessage = params.successMessage || minigameData.successMessage || 'Document reconstructed.';
+ this.stateWriteVar = params.stateWrites?.onComplete || minigameData.stateWrites?.onComplete || null;
+
+ // content + stripCount: split a full document string into N word-boundary strips.
+ // This creates mid-sentence breaks, making the puzzle significantly harder than
+ // the strips[] array where each entry is a complete semantic unit.
+ const content = params.content || minigameData.content || null;
+ const stripCount = params.stripCount || minigameData.stripCount || 10;
+ this.correctStrips = content
+ ? this._generateStripsFromContent(content, stripCount)
+ : (params.strips || minigameData.strips || []);
+
+ this.currentOrder = [];
+ this.draggedIndex = null;
+ this.completed = false;
+ }
+
+ init() {
+ super.init();
+ this.container.classList.add('sdm-container');
+ this.gameContainer.classList.add('sdm-game-container');
+ if (this.headerElement) this.headerElement.style.display = 'none';
+
+ this.completed = this._isAlreadyCompleted();
+ this.currentOrder = this.completed
+ ? this.correctStrips.map((text, i) => ({ id: i, text, rotated: false, tilt: 0 }))
+ : this._shuffleStrips();
+
+ this.render();
+ }
+
+ start() {
+ super.start();
+ }
+
+ _generateStripsFromContent(content, stripCount) {
+ // Split content into words, preserving intentional line breaks as visible markers
+ const words = content.trim().replace(/\n/g, ' ↵ ').split(/\s+/).filter(Boolean);
+ const total = words.length;
+ const strips = [];
+ for (let i = 0; i < stripCount; i++) {
+ const start = Math.round((i / stripCount) * total);
+ const end = Math.round(((i + 1) / stripCount) * total);
+ const chunk = words.slice(start, end);
+ if (chunk.length > 0) strips.push(chunk.join(' '));
+ }
+ return strips;
+ }
+
+ _isAlreadyCompleted() {
+ if (!this.stateWriteVar) return false;
+ return window.gameState?.globalVariables?.[this.stateWriteVar] === true;
+ }
+
+ _shuffleStrips() {
+ const strips = this.correctStrips.map((text, i) => ({ id: i, text, rotated: false, tilt: (Math.random() - 0.5) * 2.4 }));
+
+ // Fisher-Yates shuffle
+ for (let i = strips.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [strips[i], strips[j]] = [strips[j], strips[i]];
+ }
+
+ // Guarantee the result is not trivially solved (matches correct order)
+ if (strips.length > 1 && strips.every((s, i) => s.id === i)) {
+ [strips[0], strips[1]] = [strips[1], strips[0]];
+ }
+
+ // Apply random rotations to ~40% of strips when rotation mode is on
+ if (this.allowRotation) {
+ strips.forEach(s => { s.rotated = Math.random() < 0.4; });
+ }
+
+ return strips;
+ }
+
+ _checkCompletion() {
+ const inOrder = this.currentOrder.every((strip, i) => strip.id === i);
+ const allUpright = !this.allowRotation || this.currentOrder.every(s => !s.rotated);
+ if (inOrder && allUpright) this._onComplete();
+ }
+
+ _onComplete() {
+ this.completed = true;
+ if (this.stateWriteVar) {
+ setGlobalAndNotify(this.stateWriteVar, true);
+ }
+ this.showSuccess(escapeHtml(this.successMessage), true, 3000);
+ }
+
+ render() {
+ const prevScrollTop = this.gameContainer.querySelector('.sdm-scroll')?.scrollTop || 0;
+
+ if (this.completed) {
+ this._renderCompleted();
+ } else {
+ this._renderPuzzle();
+ }
+
+ const scrollable = this.gameContainer.querySelector('.sdm-scroll');
+ if (scrollable && prevScrollTop > 0) scrollable.scrollTop = prevScrollTop;
+
+ this.bindEvents();
+ }
+
+ _renderCompleted() {
+ const titleHtml = this.documentTitle
+ ? `
${escapeHtml(this.documentTitle)}
`
+ : '';
+
+ const stripsHtml = this.currentOrder
+ .map(s => `${escapeHtml(s.text)}
`)
+ .join('');
+
+ this.gameContainer.innerHTML = `
+
+
Document already reconstructed.
+
${escapeHtml(this.successMessage)}
+
+
+ `;
+ }
+
+ _renderPuzzle() {
+ const titleHtml = this.documentTitle
+ ? `${escapeHtml(this.documentTitle)}
`
+ : '';
+
+ const instructionText = this.allowRotation
+ ? 'Drag the strips into the correct reading order. Flip any upside-down strips using ↕.'
+ : 'Drag the strips into the correct reading order.';
+
+ const stripsHtml = this.currentOrder.map((strip, i) => {
+ const rotatedClass = strip.rotated ? ' sdm-strip-rotated' : '';
+ const flipBtn = this.allowRotation
+ ? ``
+ : '';
+ return `
+
+
+ ⠿
+ ${escapeHtml(strip.text)}
+
+ ${flipBtn}
+
+ `;
+ }).join('');
+
+ const emptyState = this.correctStrips.length === 0
+ ? 'No document strips configured in scenario.
'
+ : stripsHtml;
+
+ this.gameContainer.innerHTML = `
+
+
${escapeHtml(instructionText)}
+
+
+ `;
+ }
+
+ bindEvents() {
+ if (this.completed) return;
+
+ const stripEls = this.gameContainer.querySelectorAll('.sdm-strip[draggable]');
+ stripEls.forEach((el, i) => {
+ this.addEventListener(el, 'dragstart', (e) => this._handleDragStart(e, i));
+ this.addEventListener(el, 'dragover', (e) => this._handleDragOver(e, i));
+ this.addEventListener(el, 'drop', (e) => this._handleDrop(e, i));
+ this.addEventListener(el, 'dragend', () => this._handleDragEnd());
+ });
+
+ if (this.allowRotation) {
+ const flipBtns = this.gameContainer.querySelectorAll('.sdm-flip-btn');
+ flipBtns.forEach(btn => {
+ const index = parseInt(btn.getAttribute('data-index'), 10);
+ this.addEventListener(btn, 'click', (e) => {
+ e.stopPropagation();
+ this._handleFlip(index);
+ });
+ });
+ }
+ }
+
+ _handleDragStart(e, index) {
+ this.draggedIndex = index;
+ e.currentTarget.classList.add('sdm-strip-dragging');
+ e.dataTransfer.effectAllowed = 'move';
+ // Firefox requires at least one dataTransfer.setData call for drag to initiate
+ e.dataTransfer.setData('text/plain', String(index));
+ }
+
+ _isInsertAfter(e, index) {
+ const strips = this.gameContainer.querySelectorAll('.sdm-strip[draggable]');
+ const el = strips[index];
+ if (!el) return false;
+ const rect = el.getBoundingClientRect();
+ return e.clientY > rect.top + rect.height / 2;
+ }
+
+ _handleDragOver(e, index) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ if (index === this.draggedIndex) return;
+ const insertAfter = this._isInsertAfter(e, index);
+ this.gameContainer.querySelectorAll('.sdm-strip[draggable]').forEach((el, i) => {
+ el.classList.remove('sdm-insert-before', 'sdm-insert-after');
+ if (i === index) el.classList.add(insertAfter ? 'sdm-insert-after' : 'sdm-insert-before');
+ });
+ }
+
+ _handleDrop(e, index) {
+ e.preventDefault();
+ if (this.draggedIndex === null || this.draggedIndex === index) {
+ this.draggedIndex = null;
+ this.render();
+ return;
+ }
+
+ const insertAfter = this._isInsertAfter(e, index);
+ const item = this.currentOrder.splice(this.draggedIndex, 1)[0];
+ let insertIndex = index > this.draggedIndex ? index - 1 : index;
+ if (insertAfter) insertIndex++;
+ this.currentOrder.splice(insertIndex, 0, item);
+
+ this.draggedIndex = null;
+ this.render();
+ this._checkCompletion();
+ }
+
+ _handleDragEnd() {
+ this.draggedIndex = null;
+ this.gameContainer.querySelectorAll('.sdm-strip').forEach(el => {
+ el.classList.remove('sdm-strip-dragging', 'sdm-insert-before', 'sdm-insert-after');
+ });
+ }
+
+ _handleFlip(index) {
+ this.currentOrder[index].rotated = !this.currentOrder[index].rotated;
+ this.render();
+ this._checkCompletion();
+ }
+}
diff --git a/public/break_escape/js/systems/interactions.js b/public/break_escape/js/systems/interactions.js
index 01509b44..061f0f78 100644
--- a/public/break_escape/js/systems/interactions.js
+++ b/public/break_escape/js/systems/interactions.js
@@ -961,6 +961,17 @@ export function handleObjectInteraction(sprite) {
return;
}
+ // Handle Shredded Document Reconstruction (MG-B)
+ if (sprite.scenarioData.type === 'shredder' ||
+ sprite.scenarioData.interactionType === 'shredded_document') {
+ if (window.startShreddedDocumentMinigame) {
+ window.startShreddedDocumentMinigame(sprite);
+ } else {
+ window.gameAlert('Shredded document 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 0d593c80..8b3c13b1 100644
--- a/public/break_escape/js/systems/minigame-starters.js
+++ b/public/break_escape/js/systems/minigame-starters.js
@@ -816,6 +816,38 @@ export function startWarrantyChecklistMinigame(lockable, options = {}) {
}
window.startWarrantyChecklistMinigame = startWarrantyChecklistMinigame;
+export function startShreddedDocumentMinigame(lockable, options = {}) {
+ console.log('Starting Shredded Document minigame', { lockable, options });
+
+ if (!window.MinigameFramework) {
+ console.error('MinigameFramework not available');
+ window.gameAlert('Shredded document 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('shredded-document', null, {
+ title: options.title || minigameData.title || scenarioData.name || 'Document Reconstruction',
+ documentTitle: options.documentTitle || minigameData.documentTitle || null,
+ strips: options.strips || minigameData.strips || [],
+ allowRotation: options.allowRotation ?? minigameData.allowRotation ?? false,
+ successMessage: options.successMessage || minigameData.successMessage || 'Document reconstructed.',
+ stateWrites: options.stateWrites || minigameData.stateWrites || {},
+ lockable,
+ onComplete: (success, result) => {
+ if (typeof options.onComplete === 'function') {
+ options.onComplete(success, result);
+ }
+ }
+ });
+}
+
// Export for global access
window.startLockpickingMinigame = startLockpickingMinigame;
window.startKeySelectionMinigame = startKeySelectionMinigame;
@@ -829,6 +861,7 @@ window.startSiemMinigame = startSiemMinigame;
window.startCommandBoardMinigame = startCommandBoardMinigame;
window.startClaimsManagementSystemMinigame = startClaimsManagementSystemMinigame;
window.startEsdPushbuttonMinigame = startEsdPushbuttonMinigame;
+window.startShreddedDocumentMinigame = startShreddedDocumentMinigame;
export function startInfusionPumpMinigame(lockable, type, callback) {
console.log('Starting infusion pump minigame for', type, { lockable });
diff --git a/scenarios/test-shredded-document/mission.json b/scenarios/test-shredded-document/mission.json
new file mode 100644
index 00000000..9bc63a82
--- /dev/null
+++ b/scenarios/test-shredded-document/mission.json
@@ -0,0 +1,14 @@
+{
+ "display_name": "Test: Shredded Document Reconstruction",
+ "description": "Test scenario for MG-B — Shredded Document Reconstruction. Covers easy mode (no rotation), hard mode (with rotation), and reopen-after-completion behaviour.",
+ "difficulty_level": 1,
+ "secgen_scenario": null,
+ "collection": "testing",
+ "cybok": [
+ {
+ "ka": "POR",
+ "topic": "Privacy & Online Rights",
+ "keywords": ["Information disposal", "Paper shredder OSINT", "Dumpster diving", "Physical security"]
+ }
+ ]
+}
diff --git a/scenarios/test-shredded-document/scenario.json.erb b/scenarios/test-shredded-document/scenario.json.erb
new file mode 100644
index 00000000..7a7d3536
--- /dev/null
+++ b/scenarios/test-shredded-document/scenario.json.erb
@@ -0,0 +1,118 @@
+{
+ "scenario_brief": "You are investigating a physical security breach at Zero Day Systems. Intelligence suggests a staff memo containing temporary VPN credentials was improperly disposed of. Check the shredder waste bin in the IT office and the classified archive shredder in the server room.",
+ "endGoal": "Reconstruct the shredded documents to recover the VPN credentials and classified contact details.",
+ "startRoom": "it_office",
+ "startItemsInInventory": [],
+ "globalVariables": {
+ "vpn_creds_found": false,
+ "classified_contact_found": false
+ },
+ "objectives": [
+ {
+ "aimId": "recover_vpn_creds",
+ "title": "Recover VPN Credentials",
+ "description": "Reconstruct the shredded memo from the IT office waste bin to recover the temporary VPN credentials.",
+ "status": "active",
+ "order": 0,
+ "tasks": [
+ {
+ "taskId": "reconstruct_vpn_memo",
+ "title": "Reconstruct the shredded IT memo",
+ "type": "unlock_object",
+ "targetObject": "shredder_waste_bin",
+ "status": "active"
+ }
+ ]
+ },
+ {
+ "aimId": "recover_classified_contact",
+ "title": "Recover Classified Contact",
+ "description": "Reconstruct the classified archive fragment in the server room. The document has been cross-cut and some strips are face-down.",
+ "status": "active",
+ "order": 1,
+ "tasks": [
+ {
+ "taskId": "reconstruct_classified_doc",
+ "title": "Reconstruct the classified archive fragment",
+ "type": "unlock_object",
+ "targetObject": "archive_shredder",
+ "status": "active"
+ }
+ ]
+ }
+ ],
+ "rooms": {
+ "it_office": {
+ "name": "IT Office",
+ "type": "room_office",
+ "connections": {
+ "north": "server_room"
+ },
+ "objects": [
+ {
+ "type": "shredder",
+ "sprite": "notes",
+ "id": "shredder_waste_bin",
+ "name": "Shredder Waste Bin",
+ "takeable": false,
+ "interactable": true,
+ "active": true,
+ "observations": "A strip-cut shredder with a full waste bin beneath it. The strips are narrow but the text is still legible.",
+ "scenarioData": {
+ "id": "shredder_waste_bin",
+ "type": "shredder",
+ "name": "Shredder Waste Bin",
+ "interactionType": "shredded_document",
+ "minigameData": {
+ "title": "Shredded Document",
+ "documentTitle": "ZERO DAY SYSTEMS — INTERNAL MEMO",
+ "content": "CONFIDENTIAL MEMORANDUM — Zero Day Systems IT Operations. To: Facilities Management Team. Date: 22 November 2023. Ref: ZDS-IT-2023-0847. Following the failure of the primary badge reader on Level 3, temporary access credentials have been issued to the site audit team pending hardware repair. The VPN gateway has been reconfigured to accept the following credentials until the fault is resolved: Username auditor_tmp Password Aud!t2023# — these must be treated as strictly confidential and revoked no later than 30 November 2023. The audit team should be briefed that all access is logged and monitored. Under no circumstances should these credentials be forwarded or shared outside of the named individuals on the approved access list. Please ensure this memorandum is destroyed immediately after briefing. Retention of this document beyond 24 hours constitutes a breach of ZDS Security Policy SP-04. J. Moretti, Head of IT Operations.",
+ "stripCount": 12,
+ "allowRotation": true,
+ "successMessage": "Memo reconstructed. VPN credentials recovered: Username auditor_tmp | Password Aud!t2023#",
+ "stateWrites": {
+ "onComplete": "vpn_creds_found"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "server_room": {
+ "name": "Server Room",
+ "type": "room_servers",
+ "connections": {
+ "south": "it_office"
+ },
+ "objects": [
+ {
+ "type": "shredder",
+ "sprite": "notes",
+ "id": "archive_shredder",
+ "name": "Classified Archive Shredder",
+ "takeable": false,
+ "interactable": true,
+ "active": true,
+ "observations": "A cross-cut shredder used for classified document disposal. The waste tray contains small rectangular fragments — several are face-down.",
+ "scenarioData": {
+ "id": "archive_shredder",
+ "type": "shredder",
+ "name": "Classified Archive Shredder",
+ "interactionType": "shredded_document",
+ "minigameData": {
+ "title": "Classified Archive Fragment",
+ "documentTitle": "SAFETYNET — CLASSIFIED — EYES ONLY",
+ "content": "OPERATION SAFETYNET — Classification SECRET — Eyes Only. This document authorises asset handler contact protocols for the current reporting period. Asset codename CARDINAL has been active in the target organisation for eleven months without compromise. Primary contact for CARDINAL is D. Harlow, Security Reference SN-0047, accessible via the encrypted channel on frequency 447.2 during the agreed window. In the event that the primary channel is unavailable or believed compromised, the fallback contact method is a physical dead drop at Locker 14, Central Station left luggage. The locker key is held under the reference SN-14 at the usual arrangement. All contact attempts must be logged against case file OP-SN-2019-04 within 48 hours. This document must be destroyed by the authorised handler immediately upon reading. Retention beyond the session in which it was accessed constitutes a breach of SAFETYNET protocol and will trigger a full operational review. Destruction confirmed by handler signature. Archive copy retained under SAFETYNET Records administration, 2019.",
+ "stripCount": 10,
+ "allowRotation": true,
+ "successMessage": "Document reconstructed. Asset CARDINAL, contact D. Harlow (SN-0047). Dead drop: Locker 14, Central Station. Key ref: SN-14.",
+ "stateWrites": {
+ "onComplete": "classified_contact_found"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+}