Files
BreakEscape/planning_notes/rfid_keycard/01_TECHNICAL_ARCHITECTURE.md
Z. Cliffe Schreuders a52f8da171 docs(rfid): Second review - fix critical conversation pattern error
CRITICAL ISSUE: Planned return-to-conversation pattern was fundamentally incorrect.
Fixed to use proven window.pendingConversationReturn pattern from container minigame.

Key changes:
- NEW: review2/CRITICAL_FINDINGS.md - 8 findings with detailed analysis
- NEW: review2/README.md - Quick action guide
- FIXED: Task 3.4 - Simplified conversation return (2.5h → 1h)
- ADDED: Task 3.9 - returnToConversationAfterRFID function (0.5h)
- FIXED: Section 2c in architecture doc - Correct minimal context pattern
- Updated time estimates: 102h → 101h

npcConversationStateManager handles all conversation state automatically.
No manual Ink story save/restore needed.

Reference: /js/minigames/container/container-minigame.js:720-754

Risk: HIGH → MEDIUM (after fix)
Confidence: 95% → 98%
2025-11-15 23:48:15 +00:00

45 KiB

RFID Keycard System - Technical Architecture

File Structure

js/
├── systems/
│   ├── unlock-system.js                    [MODIFY] Add rfid lock type case
│   ├── interactions.js                     [MODIFY] Add keycard click handler & RFID icon
│   └── inventory.js                        [NO CHANGE] Inventory calls interactions
│
├── minigames/
│   ├── rfid/
│   │   ├── rfid-minigame.js               [NEW] Main RFID minigame controller
│   │   ├── rfid-ui.js                     [NEW] Flipper Zero UI rendering
│   │   ├── rfid-data.js                   [NEW] Card data management
│   │   └── rfid-animations.js             [NEW] Reading/tap animations
│   │
│   ├── helpers/
│   │   └── chat-helpers.js                [MODIFY] Add clone_keycard tag
│   │
│   └── index.js                           [MODIFY] Register rfid minigame
│
└── systems/
    └── minigame-starters.js               [MODIFY] Add startRFIDMinigame()

css/
└── rfid-minigame.css                      [NEW] Flipper Zero styling

assets/
├── objects/
│   ├── keycard.png                        [NEW] Generic keycard sprite
│   ├── keycard-ceo.png                    [NEW] CEO keycard variant
│   ├── keycard-security.png               [NEW] Security keycard variant
│   ├── rfid_cloner.png                    [NEW] RFID cloner device
│   └── flipper-zero.png                   [NEW] Flipper Zero icon
│
└── icons/
    ├── rfid-icon.png                      [NEW] RFID lock icon
    └── nfc-waves.png                      [NEW] NFC signal waves

scenarios/
└── example-rfid-scenario.json             [NEW] Example scenario with RFID locks

planning_notes/rfid_keycard/
├── 00_OVERVIEW.md                         [THIS DOC]
├── 01_TECHNICAL_ARCHITECTURE.md           [THIS DOC]
├── 02_IMPLEMENTATION_TODO.md              [NEXT]
├── 03_ASSETS_REQUIREMENTS.md              [NEXT]
└── 04_TESTING_PLAN.md                     [NEXT]

Code Architecture

1. Unlock System Integration

File: /js/systems/unlock-system.js

Add new case in handleUnlock() function:

case 'rfid':
    console.log('RFID LOCK REQUESTED');
    const requiredCardId = lockRequirements.requires;

    // Get all keycards from player's inventory
    const playerKeycards = window.inventory.items.filter(item =>
        item && item.scenarioData &&
        item.scenarioData.type === 'keycard'
    );

    // Check for RFID cloner
    const hasRFIDCloner = window.inventory.items.some(item =>
        item && item.scenarioData &&
        item.scenarioData.type === 'rfid_cloner'
    );

    if (playerKeycards.length > 0 || hasRFIDCloner) {
        // Start RFID minigame in unlock mode
        startRFIDMinigame(lockable, type, {
            mode: 'unlock',
            requiredCardId: requiredCardId,
            availableCards: playerKeycards,
            hasCloner: hasRFIDCloner,
            onComplete: (success) => {
                if (success) {
                    unlockTarget(lockable, type, lockable.layer);
                    window.gameAlert('Access Granted', 'success', 'RFID Unlock', 3000);
                } else {
                    window.gameAlert('Access Denied - Invalid Card', 'error', 'RFID Unlock', 3000);
                }
            }
        });
    } else {
        console.log('NO KEYCARD OR RFID CLONER');
        window.gameAlert('Requires RFID keycard', 'error', 'Locked', 4000);
    }
    break;

2. RFID Minigame Class

File: /js/minigames/rfid/rfid-minigame.js

import { MinigameScene } from '../framework/base-minigame.js';
import { RFIDUIRenderer } from './rfid-ui.js';
import { RFIDDataManager } from './rfid-data.js';
import { RFIDAnimations } from './rfid-animations.js';

export class RFIDMinigame extends MinigameScene {
    constructor(container, params) {
        params = params || {};
        params.title = params.mode === 'clone' ? 'RFID Cloner' : 'RFID Reader';
        params.showCancel = true;
        params.cancelText = 'Back';

        super(container, params);

        // Minigame configuration
        this.mode = params.mode || 'unlock';  // 'unlock' or 'clone'
        this.requiredCardId = params.requiredCardId;
        this.availableCards = params.availableCards || [];
        this.hasCloner = params.hasCloner || false;
        this.cardToClone = params.cardToClone;  // For clone mode

        // Components
        this.ui = new RFIDUIRenderer(this);
        this.dataManager = new RFIDDataManager();
        this.animations = new RFIDAnimations(this);

        // State
        this.currentView = 'main';  // 'main', 'saved', 'emulate', 'read'
        this.selectedSavedCard = null;
        this.readingProgress = 0;
    }

    init() {
        super.init();
        console.log('RFID minigame initializing in mode:', this.mode);

        this.container.className += ' rfid-minigame-container';
        this.gameContainer.className += ' rfid-minigame-game-container';

        // Create the appropriate interface based on mode
        if (this.mode === 'unlock') {
            this.ui.createUnlockInterface();
        } else if (this.mode === 'clone') {
            this.ui.createCloneInterface();
        }
    }

    start() {
        super.start();
        console.log('RFID minigame started');

        if (this.mode === 'clone') {
            // Automatically start reading animation
            this.startCardReading();
        }
    }

    // Unlock mode methods
    handleCardTap(card) {
        console.log('Card tapped:', card);

        if (card.key_id === this.requiredCardId) {
            this.animations.showTapSuccess();
            setTimeout(() => {
                this.complete(true);
            }, 1000);
        } else {
            this.animations.showTapFailure();
            setTimeout(() => {
                this.complete(false);
            }, 1000);
        }
    }

    handleEmulate(savedCard) {
        console.log('Emulating card:', savedCard);

        // Show Flipper Zero emulation screen
        this.ui.showEmulationScreen(savedCard);

        // Check if emulated card matches required
        if (savedCard.key_id === this.requiredCardId) {
            this.animations.showEmulationSuccess();
            setTimeout(() => {
                this.complete(true);
            }, 2000);
        } else {
            this.animations.showEmulationFailure();
            setTimeout(() => {
                this.complete(false);
            }, 2000);
        }
    }

    // Clone mode methods
    startCardReading() {
        console.log('Starting card reading...');
        this.currentView = 'read';
        this.readingProgress = 0;

        // Show reading screen
        this.ui.showReadingScreen();

        // Simulate reading progress
        this.animations.animateReading((progress) => {
            this.readingProgress = progress;
            this.ui.updateReadingProgress(progress);

            if (progress >= 100) {
                // Reading complete - show card data
                this.showCardData();
            }
        });
    }

    showCardData() {
        console.log('Showing card data');

        // Generate or use provided card data
        const cardData = this.cardToClone || this.dataManager.generateRandomCard();

        // Show card data screen with Flipper Zero formatting
        this.ui.showCardDataScreen(cardData);
    }

    handleSaveCard(cardData) {
        console.log('Saving card:', cardData);

        // Save to RFID cloner inventory item
        this.dataManager.saveCardToCloner(cardData);

        // Show success message
        window.gameAlert('Card saved successfully', 'success', 'RFID Cloner', 2000);

        // Complete minigame
        setTimeout(() => {
            this.complete(true, { cardData });
        }, 1000);
    }

    complete(success, result) {
        super.complete(success, result);
    }

    cleanup() {
        this.animations.cleanup();
        super.cleanup();
    }
}

// Starter function
export function startRFIDMinigame(lockable, type, params) {
    console.log('Starting RFID minigame with params:', params);

    // Register minigame if not already done
    if (window.MinigameFramework && !window.MinigameFramework.registeredScenes['rfid']) {
        window.MinigameFramework.registerScene('rfid', RFIDMinigame);
    }

    // Start the minigame
    window.MinigameFramework.startMinigame('rfid', null, params);
}

// Return to conversation function
export function returnToConversationAfterRFID(conversationContext) {
    if (!window.MinigameFramework) return;

    // Re-open conversation minigame
    window.MinigameFramework.startMinigame('person-chat', null, {
        npcId: conversationContext.npcId,
        resumeState: conversationContext.conversationState
    });
}

2a. Complete Registration and Export Pattern

File: /js/minigames/index.js

The RFID minigame must follow the complete pattern used by other minigames:

// 1. IMPORT the minigame and starter at the top
import { RFIDMinigame, startRFIDMinigame, returnToConversationAfterRFID } from './rfid/rfid-minigame.js';

// 2. EXPORT for module consumers
export { RFIDMinigame, startRFIDMinigame, returnToConversationAfterRFID };

// Later in the file after other registrations...

// 3. REGISTER the minigame scene
MinigameFramework.registerScene('rfid', RFIDMinigame);

// 4. MAKE GLOBALLY AVAILABLE on window
window.startRFIDMinigame = startRFIDMinigame;
window.returnToConversationAfterRFID = returnToConversationAfterRFID;

This four-step pattern ensures the minigame works in all contexts:

  • Module imports (ES6)
  • Window global access (legacy code)
  • Framework registration (minigame system)
  • Function availability (starter functions)

2b. Event Dispatcher Integration

Integration Points: All minigame methods that perform significant actions

// In RFIDMinigame.handleSaveCard()
handleSaveCard(cardData) {
    console.log('Saving card:', cardData);

    // Save to RFID cloner inventory item
    this.dataManager.saveCardToCloner(cardData);

    // Emit event for card cloning
    if (window.eventDispatcher) {
        window.eventDispatcher.emit('card_cloned', {
            cardName: cardData.name,
            cardHex: cardData.rfid_hex,
            npcId: window.currentConversationNPCId, // If cloned from NPC
            timestamp: Date.now()
        });
    }

    window.gameAlert('Card saved successfully', 'success', 'RFID Cloner', 2000);

    setTimeout(() => {
        this.complete(true, { cardData });
    }, 1000);
}

// In RFIDMinigame.handleEmulate()
handleEmulate(savedCard) {
    console.log('Emulating card:', savedCard);

    // Show emulation screen
    this.ui.showEmulationScreen(savedCard);

    // Emit event for card emulation
    if (window.eventDispatcher) {
        window.eventDispatcher.emit('card_emulated', {
            cardName: savedCard.name,
            cardHex: savedCard.hex,
            success: savedCard.key_id === this.requiredCardId,
            timestamp: Date.now()
        });
    }

    // Check if emulated card matches required
    if (savedCard.key_id === this.requiredCardId) {
        this.animations.showEmulationSuccess();
        setTimeout(() => {
            this.complete(true);
        }, 2000);
    } else {
        this.animations.showEmulationFailure();
        setTimeout(() => {
            this.complete(false);
        }, 2000);
    }
}

// In RFIDMinigame.init() for unlock mode
if (this.mode === 'unlock') {
    // Emit event for RFID lock access
    if (window.eventDispatcher) {
        window.eventDispatcher.emit('rfid_lock_accessed', {
            lockId: this.params.lockable?.objectId,
            requiredCardId: this.requiredCardId,
            hasCloner: this.hasCloner,
            availableCardsCount: this.availableCards.length,
            timestamp: Date.now()
        });
    }
    this.ui.createUnlockInterface();
}

Event Summary:

  • card_cloned: When player saves a card to cloner
  • card_emulated: When player attempts to emulate a card
  • rfid_lock_accessed: When player opens RFID minigame on a lock

These events allow:

  • NPCs to react to card cloning
  • Game telemetry and analytics
  • Achievement/quest tracking
  • Security detection systems (if implemented)

2c. Return to Conversation Pattern

IMPORTANT: Uses proven window.pendingConversationReturn pattern from container minigame. Reference: /js/minigames/container/container-minigame.js:720-754 and /js/systems/npc-game-bridge.js:237-242

File: /js/minigames/helpers/chat-helpers.js (Updated clone_keycard case)

case 'clone_keycard':
    if (param) {
        const [cardName, cardHex] = param.split('|').map(s => s.trim());

        // Check if player has RFID cloner
        const hasCloner = window.inventory.items.some(item =>
            item && item.scenarioData &&
            item.scenarioData.type === 'rfid_cloner'
        );

        if (!hasCloner) {
            result.message = '⚠️ You need an RFID cloner to clone cards';
            if (ui) ui.showNotification(result.message, 'warning');
            break;
        }

        // Generate card data
        const cardData = {
            name: cardName,
            rfid_hex: cardHex,
            rfid_facility: parseInt(cardHex.substring(0, 2), 16),
            rfid_card_number: parseInt(cardHex.substring(2, 6), 16),
            rfid_protocol: 'EM4100',
            key_id: `cloned_${cardName.toLowerCase().replace(/\s+/g, '_')}`
        };

        // Set pending conversation return (MINIMAL CONTEXT!)
        // Conversation state automatically managed by npcConversationStateManager
        window.pendingConversationReturn = {
            npcId: window.currentConversationNPCId,
            type: window.currentConversationMinigameType || 'person-chat'
        };

        // Start RFID minigame in clone mode
        if (window.startRFIDMinigame) {
            window.startRFIDMinigame(null, null, {
                mode: 'clone',
                cardToClone: cardData
            });
        }

        result.success = true;
        result.message = `📡 Starting card clone: ${cardName}`;
    }
    break;

Return Function: /js/minigames/rfid/rfid-minigame.js

/**
 * Return to conversation after RFID minigame
 * Follows exact pattern from container minigame
 */
export function returnToConversationAfterRFID() {
    console.log('Returning to conversation after RFID minigame');

    // Check if there's a pending conversation return
    if (window.pendingConversationReturn) {
        const conversationState = window.pendingConversationReturn;

        // Clear the pending return state
        window.pendingConversationReturn = null;

        console.log('Restoring conversation:', conversationState);

        // Restart the appropriate conversation minigame
        if (window.MinigameFramework) {
            // Small delay to ensure RFID minigame is fully closed
            setTimeout(() => {
                if (conversationState.type === 'person-chat') {
                    // Restart person-chat minigame
                    window.MinigameFramework.startMinigame('person-chat', null, {
                        npcId: conversationState.npcId,
                        fromTag: true  // Flag to indicate resuming from tag action
                    });
                } else if (conversationState.type === 'phone-chat') {
                    // Restart phone-chat minigame
                    window.MinigameFramework.startMinigame('phone-chat', null, {
                        npcId: conversationState.npcId,
                        fromTag: true
                    });
                }
            }, 50);
        }
    } else {
        console.log('No pending conversation return found');
    }
}

Called from RFIDMinigame.complete():

complete(success) {
    // Check if we need to return to conversation
    if (window.pendingConversationReturn && window.returnToConversationAfterRFID) {
        console.log('Returning to conversation after RFID minigame');
        setTimeout(() => {
            window.returnToConversationAfterRFID();
        }, 100);
    }

    // Call parent complete
    super.complete(success, this.gameResult);
}

Why This Pattern:

  • Automatic State Management: npcConversationStateManager saves/restores Ink story state automatically
  • Proven: Already working in container → conversation flow
  • Simpler: No manual Ink state manipulation needed
  • Reliable: Restarting conversation automatically restores state via restoreNPCState()

3. RFID UI Renderer

File: /js/minigames/rfid/rfid-ui.js

export class RFIDUIRenderer {
    constructor(minigame) {
        this.minigame = minigame;
        this.container = minigame.gameContainer;
    }

    createUnlockInterface() {
        const ui = document.createElement('div');
        ui.className = 'rfid-unlock-interface';

        // Create Flipper Zero device frame
        const flipperFrame = this.createFlipperFrame();
        ui.appendChild(flipperFrame);

        // Create screen content area
        const screen = document.createElement('div');
        screen.className = 'flipper-screen';
        screen.id = 'flipper-screen';

        // Show main menu
        this.showMainMenu(screen);

        flipperFrame.appendChild(screen);
        this.container.appendChild(ui);
    }

    createFlipperFrame() {
        const frame = document.createElement('div');
        frame.className = 'flipper-zero-frame';

        // Add device styling (orange border, black screen, etc.)
        frame.innerHTML = `
            <div class="flipper-header">
                <div class="flipper-logo">Flipper Zero</div>
                <div class="flipper-battery">100%</div>
            </div>
        `;

        return frame;
    }

    showMainMenu(screen) {
        screen.innerHTML = `
            <div class="flipper-menu">
                <div class="flipper-breadcrumb">RFID</div>
                <div class="flipper-menu-items">
                    ${this.minigame.availableCards.length > 0 ?
                        '<div class="flipper-menu-item" data-action="tap">▶ Read</div>' : ''}
                    ${this.minigame.hasCloner ?
                        '<div class="flipper-menu-item" data-action="saved">▶ Saved</div>' : ''}
                </div>
            </div>
        `;

        // Add event listeners
        screen.querySelectorAll('.flipper-menu-item').forEach(item => {
            item.addEventListener('click', (e) => {
                const action = e.target.dataset.action;
                if (action === 'tap') {
                    this.showTapInterface();
                } else if (action === 'saved') {
                    this.showSavedCards();
                }
            });
        });
    }

    showTapInterface() {
        const screen = document.getElementById('flipper-screen');

        screen.innerHTML = `
            <div class="flipper-read-screen">
                <div class="flipper-breadcrumb">RFID > Read</div>
                <div class="flipper-content">
                    <div class="rfid-tap-area">
                        <div class="rfid-waves"></div>
                        <div class="rfid-instruction">Place card on reader...</div>
                    </div>
                    <div class="rfid-card-list">
                        ${this.minigame.availableCards.map(card => `
                            <div class="rfid-card-item" data-card-id="${card.scenarioData.key_id}">
${card.scenarioData.name}
                            </div>
                        `).join('')}
                    </div>
                </div>
            </div>
        `;

        // Add click handlers for cards
        screen.querySelectorAll('.rfid-card-item').forEach(item => {
            item.addEventListener('click', (e) => {
                const cardId = e.target.dataset.cardId;
                const card = this.minigame.availableCards.find(
                    c => c.scenarioData.key_id === cardId
                );
                if (card) {
                    this.minigame.handleCardTap(card.scenarioData);
                }
            });
        });
    }

    showSavedCards() {
        const screen = document.getElementById('flipper-screen');
        const savedCards = this.getSavedCardsFromCloner();

        screen.innerHTML = `
            <div class="flipper-saved-screen">
                <div class="flipper-breadcrumb">RFID > Saved</div>
                <div class="flipper-content">
                    ${savedCards.length === 0 ?
                        '<div class="flipper-empty">No saved cards</div>' :
                        savedCards.map((card, idx) => `
                            <div class="flipper-menu-item" data-card-index="${idx}">
${card.name}
                            </div>
                        `).join('')
                    }
                </div>
            </div>
        `;

        // Add click handlers
        screen.querySelectorAll('.flipper-menu-item').forEach(item => {
            item.addEventListener('click', (e) => {
                const cardIndex = parseInt(e.target.dataset.cardIndex);
                const card = savedCards[cardIndex];
                if (card) {
                    this.showEmulationScreen(card);
                }
            });
        });
    }

    showEmulationScreen(card) {
        const screen = document.getElementById('flipper-screen');

        screen.innerHTML = `
            <div class="flipper-emulate-screen">
                <div class="flipper-breadcrumb">RFID > Saved > Emulate</div>
                <div class="flipper-content">
                    <div class="emulation-status">
                        <div class="emulation-icon">📡</div>
                        <div class="emulation-text">Emulating</div>
                        <div class="emulation-protocol">[${card.protocol || 'EM4100'}]</div>
                        <div class="emulation-name">${card.name}</div>
                    </div>
                    <div class="emulation-data">
                        <div>Hex: ${this.formatHex(card.hex)}</div>
                        <div>FC: ${card.facility || 'N/A'}</div>
                        <div>Card: ${card.card_number || 'N/A'}</div>
                    </div>
                    <div class="emulation-waves"></div>
                </div>
            </div>
        `;

        // Trigger emulation check
        this.minigame.handleEmulate(card);
    }

    // Clone mode UI
    createCloneInterface() {
        const ui = document.createElement('div');
        ui.className = 'rfid-clone-interface';

        const flipperFrame = this.createFlipperFrame();

        const screen = document.createElement('div');
        screen.className = 'flipper-screen';
        screen.id = 'flipper-screen';

        flipperFrame.appendChild(screen);
        ui.appendChild(flipperFrame);
        this.container.appendChild(ui);
    }

    showReadingScreen() {
        const screen = document.getElementById('flipper-screen');

        screen.innerHTML = `
            <div class="flipper-read-progress">
                <div class="flipper-breadcrumb">RFID > Read</div>
                <div class="flipper-content">
                    <div class="reading-status">Reading 1/2</div>
                    <div class="reading-modulation">> ASK PSK</div>
                    <div class="reading-instruction">Don't move card...</div>
                    <div class="reading-progress-bar">
                        <div class="reading-progress-fill" id="reading-progress-fill"></div>
                    </div>
                </div>
            </div>
        `;
    }

    updateReadingProgress(progress) {
        const fill = document.getElementById('reading-progress-fill');
        if (fill) {
            fill.style.width = progress + '%';
        }
    }

    showCardDataScreen(cardData) {
        const screen = document.getElementById('flipper-screen');

        screen.innerHTML = `
            <div class="flipper-card-data">
                <div class="flipper-breadcrumb">RFID > Read</div>
                <div class="flipper-content">
                    <div class="card-protocol">EM-Micro EM4100</div>
                    <div class="card-hex">Hex: ${this.formatHex(cardData.rfid_hex)}</div>
                    <div class="card-details">
                        <div>FC: ${cardData.rfid_facility} Card: ${cardData.rfid_card_number}</div>
                        <div>CL: ${this.calculateChecksum(cardData.rfid_hex)}</div>
                    </div>
                    <div class="card-dez">DEZ 8: ${this.toDEZ8(cardData.rfid_hex)}</div>
                    <div class="card-actions">
                        <button class="flipper-btn" id="save-card-btn">Save</button>
                        <button class="flipper-btn" id="cancel-card-btn">Cancel</button>
                    </div>
                </div>
            </div>
        `;

        // Add event listeners
        document.getElementById('save-card-btn').addEventListener('click', () => {
            this.minigame.handleSaveCard(cardData);
        });

        document.getElementById('cancel-card-btn').addEventListener('click', () => {
            this.minigame.complete(false);
        });
    }

    // Helper methods
    formatHex(hex) {
        // Format as: 4A C5 EF 44 DC
        return hex.match(/.{1,2}/g).join(' ').toUpperCase();
    }

    calculateChecksum(hex) {
        // EM4100 checksum: XOR of all bytes
        const bytes = hex.match(/.{1,2}/g).map(b => parseInt(b, 16));
        let checksum = 0;
        bytes.forEach(byte => {
            checksum ^= byte; // XOR all bytes
        });
        return checksum & 0xFF; // Keep only last byte
    }

    toDEZ8(hex) {
        // EM4100 DEZ 8: Last 3 bytes (6 hex chars) to decimal
        const lastThreeBytes = hex.slice(-6);
        const decimal = parseInt(lastThreeBytes, 16);
        return decimal.toString().padStart(8, '0');
    }

    getSavedCardsFromCloner() {
        // Get RFID cloner from inventory
        const cloner = window.inventory.items.find(item =>
            item && item.scenarioData &&
            item.scenarioData.type === 'rfid_cloner'
        );

        return cloner?.scenarioData?.saved_cards || [];
    }
}

4. RFID Data Manager

File: /js/minigames/rfid/rfid-data.js

export class RFIDDataManager {
    constructor() {
        this.protocols = ['EM4100', 'HID Prox', 'Indala'];
        this.MAX_SAVED_CARDS = 50;
    }

    // Hex ID Validation
    validateHex(hex) {
        if (!hex || typeof hex !== 'string') {
            return { valid: false, error: 'Hex ID must be a string' };
        }
        if (hex.length !== 10) {
            return { valid: false, error: 'Hex ID must be exactly 10 characters' };
        }
        if (!/^[0-9A-Fa-f]{10}$/.test(hex)) {
            return { valid: false, error: 'Hex ID must contain only hex characters (0-9, A-F)' };
        }
        return { valid: true };
    }

    generateRandomCard() {
        const hex = this.generateRandomHex();
        const { facility, cardNumber } = this.hexToFacilityCard(hex);

        // Generate more interesting names
        const names = [
            'Security Badge',
            'Access Card',
            'Employee ID',
            'Guest Pass',
            'Visitor Badge',
            'Contractor Card'
        ];
        const name = names[Math.floor(Math.random() * names.length)];

        return {
            name: name,
            rfid_hex: hex,
            rfid_facility: facility,
            rfid_card_number: cardNumber,
            rfid_protocol: 'EM4100',
            key_id: 'cloned_' + hex.toLowerCase()
        };
    }

    generateRandomHex() {
        let hex = '';
        for (let i = 0; i < 10; i++) {
            hex += Math.floor(Math.random() * 16).toString(16).toUpperCase();
        }
        return hex;
    }

    saveCardToCloner(cardData) {
        // Validate hex ID
        const validation = this.validateHex(cardData.rfid_hex);
        if (!validation.valid) {
            console.error('Invalid hex ID:', validation.error);
            window.gameAlert(validation.error, 'error');
            return false;
        }

        // Find RFID cloner in inventory
        const cloner = window.inventory.items.find(item =>
            item && item.scenarioData &&
            item.scenarioData.type === 'rfid_cloner'
        );

        if (!cloner) {
            console.error('RFID cloner not found in inventory');
            return false;
        }

        // Initialize saved_cards array if it doesn't exist
        if (!cloner.scenarioData.saved_cards) {
            cloner.scenarioData.saved_cards = [];
        }

        // Check storage limit
        if (cloner.scenarioData.saved_cards.length >= this.MAX_SAVED_CARDS) {
            console.warn('Cloner storage full');
            window.gameAlert(`Cloner storage full (${this.MAX_SAVED_CARDS} cards max)`, 'error');
            return false;
        }

        // Check if card already saved (by hex ID)
        const existingIndex = cloner.scenarioData.saved_cards.findIndex(
            card => card.hex === cardData.rfid_hex
        );

        if (existingIndex !== -1) {
            // Update existing card (overwrite strategy)
            console.log('Card already exists, updating...');
            cloner.scenarioData.saved_cards[existingIndex] = {
                name: cardData.name,
                hex: cardData.rfid_hex,
                facility: cardData.rfid_facility,
                card_number: cardData.rfid_card_number,
                protocol: cardData.rfid_protocol || 'EM4100',
                key_id: cardData.key_id,
                cloned_at: new Date().toISOString(),
                updated: true
            };
            console.log('Card updated in cloner:', cardData);
            return 'updated';
        }

        // Save new card
        cloner.scenarioData.saved_cards.push({
            name: cardData.name,
            hex: cardData.rfid_hex,
            facility: cardData.rfid_facility,
            card_number: cardData.rfid_card_number,
            protocol: cardData.rfid_protocol || 'EM4100',
            key_id: cardData.key_id,
            cloned_at: new Date().toISOString()
        });

        console.log('Card saved to cloner:', cardData);
        return true;
    }

    hexToFacilityCard(hex) {
        // EM4100 format: 10 hex chars = 40 bits
        // Facility code: First byte (2 hex chars)
        // Card number: Next 2 bytes (4 hex chars)
        const facility = parseInt(hex.substring(0, 2), 16);
        const cardNumber = parseInt(hex.substring(2, 6), 16);

        return { facility, cardNumber };
    }

    facilityCardToHex(facility, cardNumber) {
        // Reverse: Facility (1 byte) + Card Number (2 bytes) + padding
        const facilityHex = facility.toString(16).toUpperCase().padStart(2, '0');
        const cardHex = cardNumber.toString(16).toUpperCase().padStart(4, '0');
        // Add 4 more random hex chars for full 10-char ID
        const padding = Math.floor(Math.random() * 0x10000).toString(16).toUpperCase().padStart(4, '0');
        return facilityHex + cardHex + padding;
    }
}

5. RFID Animations

File: /js/minigames/rfid/rfid-animations.js

export class RFIDAnimations {
    constructor(minigame) {
        this.minigame = minigame;
        this.activeAnimations = [];
    }

    animateReading(progressCallback) {
        let progress = 0;
        const interval = setInterval(() => {
            progress += 2;
            progressCallback(progress);

            if (progress >= 100) {
                clearInterval(interval);
            }
        }, 50);  // 50ms intervals = 2.5 second total

        this.activeAnimations.push(interval);
    }

    showTapSuccess() {
        const screen = document.getElementById('flipper-screen');
        screen.innerHTML = `
            <div class="flipper-result success">
                <div class="result-icon">✓</div>
                <div class="result-text">Access Granted</div>
                <div class="result-detail">Card Accepted</div>
            </div>
        `;
    }

    showTapFailure() {
        const screen = document.getElementById('flipper-screen');
        screen.innerHTML = `
            <div class="flipper-result failure">
                <div class="result-icon">✗</div>
                <div class="result-text">Access Denied</div>
                <div class="result-detail">Invalid Card</div>
            </div>
        `;
    }

    showEmulationSuccess() {
        // Add success visual feedback to existing emulation screen
        const statusDiv = document.querySelector('.emulation-status');
        if (statusDiv) {
            statusDiv.classList.add('success');
        }
    }

    showEmulationFailure() {
        const statusDiv = document.querySelector('.emulation-status');
        if (statusDiv) {
            statusDiv.classList.add('failure');
        }
    }

    cleanup() {
        this.activeAnimations.forEach(anim => clearInterval(anim));
        this.activeAnimations = [];
    }
}

6. Ink Tag Handler

File: /js/minigames/helpers/chat-helpers.js (Add new case)

case 'clone_keycard':
    if (param) {
        const [cardName, cardHex] = param.split('|').map(s => s.trim());

        // Check if player has RFID cloner
        const hasCloner = window.inventory.items.some(item =>
            item && item.scenarioData &&
            item.scenarioData.type === 'rfid_cloner'
        );

        if (!hasCloner) {
            result.message = '⚠️ You need an RFID cloner to clone cards';
            if (ui) ui.showNotification(result.message, 'warning');
            break;
        }

        // Generate card data
        const cardData = {
            name: cardName,
            rfid_hex: cardHex,
            rfid_facility: parseInt(cardHex.substring(0, 2), 16),
            rfid_card_number: parseInt(cardHex.substring(2, 6), 16),
            rfid_protocol: 'EM4100',
            key_id: `cloned_${cardName.toLowerCase().replace(/\s+/g, '_')}`
        };

        // Start RFID minigame in clone mode
        if (window.startRFIDMinigame) {
            window.startRFIDMinigame(null, null, {
                mode: 'clone',
                cardToClone: cardData,
                onComplete: (success, cloneResult) => {
                    if (success) {
                        result.success = true;
                        result.message = `📡 Cloned: ${cardName}`;
                        if (ui) ui.showNotification(result.message, 'success');
                    }
                }
            });
        }

        result.success = true;
        result.message = `📡 Starting card clone: ${cardName}`;
        if (ui) ui.showNotification(result.message, 'info');
    }
    break;

7. Keycard Click Handler

File: /js/systems/interactions.js (Modify handleObjectInteraction)

Note: Inventory items call window.handleObjectInteraction() which is defined in interactions.js.

Add early in the handleObjectInteraction(sprite) function, before existing type checks:

// Special handling for keycard + RFID cloner combo
if (sprite.scenarioData?.type === 'keycard') {
    const hasCloner = window.inventory.items.some(item =>
        item && item.scenarioData &&
        item.scenarioData.type === 'rfid_cloner'
    );

    if (hasCloner) {
        // Start RFID minigame in clone mode
        if (window.startRFIDMinigame) {
            window.startRFIDMinigame(null, null, {
                mode: 'clone',
                cardToClone: sprite.scenarioData,
                onComplete: (success) => {
                    if (success) {
                        window.gameAlert('Keycard cloned successfully', 'success');
                    }
                }
            });
            return;  // Don't proceed with normal handling
        }
    } else {
        window.gameAlert('You need an RFID cloner to clone this card', 'info');
        return;
    }
}

8. Interaction Indicator System

File: /js/systems/interactions.js (Modify getInteractionSpriteKey)

Add RFID lock icon support to the getInteractionSpriteKey() function around line 350:

function getInteractionSpriteKey(obj) {
    // ... existing code for NPCs and doors ...

    // Check for locked containers and items
    if (data.locked === true) {
        // Check specific lock type
        const lockType = data.lockType;
        if (lockType === 'password') return 'password';
        if (lockType === 'pin') return 'pin';
        if (lockType === 'biometric') return 'fingerprint';
        if (lockType === 'rfid') return 'rfid-icon';  // ← ADD THIS LINE
        // Default to keyway for key locks or unknown types
        return 'keyway';
    }

    // ... rest of function ...
}

Also add for doors (around line 336):

if (obj.doorProperties) {
    if (obj.doorProperties.locked) {
        const lockType = obj.doorProperties.lockType;
        if (lockType === 'password') return 'password';
        if (lockType === 'pin') return 'pin';
        if (lockType === 'rfid') return 'rfid-icon';  // ← ADD THIS LINE
        return 'keyway';
    }
    return null;
}

Data Flow Diagrams

Unlock Mode Flow

Player clicks RFID-locked door
    ↓
handleUnlock() detects lockType: 'rfid'
    ↓
Check inventory for:
  - keycards (matching key_id)
  - rfid_cloner (with saved_cards)
    ↓
Start RFIDMinigame(mode: 'unlock')
    ↓
┌─────────────────────────────────────┐
│  Show Flipper Zero interface       │
│  ┌──────────────────────────────┐  │
│  │ RFID                         │  │
│  │ ▶ Read    (if has cards)     │  │
│  │ ▶ Saved   (if has cloner)    │  │
│  └──────────────────────────────┘  │
└─────────────────────────────────────┘
    ↓
Player chooses action:
    ├─ Read → Show available keycards
    │         Player taps card
    │         Check if key_id matches
    │         ✓ Success: Door unlocks
    │         ✗ Failure: Access Denied
    │
    └─ Saved → Show saved cards list
              Player selects card to emulate
              Show "Emulating [EM4100] CardName"
              Check if key_id matches
              ✓ Success: Door unlocks
              ✗ Failure: Access Denied

Clone Mode Flow (from Ink)

Ink dialogue option:
  [Secretly clone keycard]
    ↓
Ink tag: # clone_keycard:Security Officer|4AC5EF44DC
    ↓
processGameActionTags() in chat-helpers.js
    ↓
Check for rfid_cloner in inventory
    ↓
Start RFIDMinigame(mode: 'clone', cardToClone: data)
    ↓
┌────────────────────────────────────┐
│  Flipper Zero Reading Screen      │
│  ┌────────────────────────────┐   │
│  │ RFID > Read                │   │
│  │ Reading 1/2                │   │
│  │ > ASK PSK                  │   │
│  │ Don't move card...         │   │
│  │ [=========>      ] 75%     │   │
│  └────────────────────────────┘   │
└────────────────────────────────────┘
    ↓
Reading completes (2.5 seconds)
    ↓
┌────────────────────────────────────┐
│  Card Data Screen                  │
│  ┌────────────────────────────┐   │
│  │ EM-Micro EM4100            │   │
│  │ Hex: 4A C5 EF 44 DC        │   │
│  │ FC: 239 Card: 17628        │   │
│  │ CL: 64                     │   │
│  │ DEZ 8: 15680732            │   │
│  │                            │   │
│  │ [Save]     [Cancel]        │   │
│  └────────────────────────────┘   │
└────────────────────────────────────┘
    ↓
Player clicks Save
    ↓
Save to rfid_cloner.saved_cards[]
    ↓
Show success message
    ↓
Complete minigame

Clone Mode Flow (from Inventory)

Player has keycard in inventory
Player has rfid_cloner in inventory
    ↓
Player clicks keycard in inventory
    ↓
inventory.js calls window.handleObjectInteraction()
    ↓
interactions.js detects:
  - item.type === 'keycard'
  - inventory has 'rfid_cloner'
    ↓
Start RFIDMinigame(mode: 'clone', cardToClone: keycard.scenarioData)
    ↓
[Same flow as Clone Mode from Ink]

CSS Styling Strategy

Flipper Zero Aesthetic

/* Main container */
.flipper-zero-frame {
    width: 400px;
    height: 500px;
    background: #FF8200;  /* Flipper orange */
    border-radius: 20px;
    padding: 20px;
    box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}

/* Screen area */
.flipper-screen {
    width: 100%;
    height: 380px;
    background: #000;
    border: 2px solid #333;
    border-radius: 8px;
    padding: 10px;
    font-family: 'Courier New', monospace;
    color: #FF8200;
    font-size: 14px;
    overflow-y: auto;
}

/* Breadcrumb navigation */
.flipper-breadcrumb {
    color: #666;
    font-size: 12px;
    margin-bottom: 10px;
    border-bottom: 1px solid #333;
    padding-bottom: 5px;
}

/* Menu items */
.flipper-menu-item {
    padding: 8px;
    margin: 4px 0;
    cursor: pointer;
    transition: background 0.2s;
}

.flipper-menu-item:hover {
    background: #1a1a1a;
}

/* Emulation status */
.emulation-status {
    text-align: center;
    padding: 20px;
    animation: pulse 2s infinite;
}

@keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.7; }
}

/* Success/failure states */
.flipper-result.success {
    color: #00FF00;
}

.flipper-result.failure {
    color: #FF0000;
}

Integration Points Summary

System File Modification Type Description
Unlock System unlock-system.js Add case Add 'rfid' lock type handler
Interactions interactions.js Add handler + icon Keycard click + RFID lock icon
Minigame Registry index.js Import + Register + Export Full registration pattern
Chat Tags chat-helpers.js Add case Handle clone_keycard tag with return
Styles rfid-minigame.css New file Flipper Zero styling
Assets assets/objects/ New files Keycard and cloner sprites
Assets assets/icons/ New files RFID lock icon and waves
HTML index.html Add link CSS stylesheet link
Phaser Asset loading Add images Load all RFID sprites/icons

State Management

Global State Extensions

// RFID cloner item in inventory
window.inventory.items[] contains:
{
    scenarioData: {
        type: 'rfid_cloner',
        name: 'RFID Cloner',
        saved_cards: [
            {
                name: 'Security Officer',
                hex: '4AC5EF44DC',
                facility: 239,
                card_number: 17628,
                protocol: 'EM4100',
                key_id: 'cloned_security_officer',
                cloned_at: '2024-01-15T10:30:00Z'
            }
        ]
    }
}

Error Handling

Scenarios and Error Messages

Scenario Error Handling User Message
No keycard or cloner Block unlock attempt "Requires RFID keycard"
Wrong keycard Show failure animation "Access Denied - Invalid Card"
No cloner for clone Prevent clone initiation "You need an RFID cloner to clone cards"
Duplicate card save Skip save, notify "Card already saved"
Minigame not registered Auto-register on demand (Silent recovery)

Performance Considerations

  • Animations: Use CSS transforms, not layout changes
  • Card List: Limit to 50 saved cards maximum
  • Reading Animation: 2.5 second duration (not blocking)
  • Memory: Clean up intervals/timeouts in cleanup()
  • DOM: Reuse screen container, replace innerHTML

Accessibility

  • Keyboard Navigation: Arrow keys in menus, Enter to select
  • Screen Reader: ARIA labels on buttons
  • High Contrast: Ensure orange/black contrast ratio
  • Font Size: Minimum 14px, scalable

Security (In-Game)

  • Card Validation: Server-side key_id matching
  • Clone Limit: Optional max saved cards per cloner
  • Audit Log: Track card clones with timestamps
  • Detection: Optional NPC detection of cloning attempts