(() => { function sameTarget(left, right) { return left?.x === right?.x && left?.y === right?.y; } function createTargetActivator({ canFire, select, fire, thresholdMs = 360, now = () => Date.now() }) { let lastTap; let firing = false; async function fireOnce(target) { if (firing || !canFire(target)) return false; firing = true; lastTap = undefined; try { await fire(target); return true; } finally { firing = false; } } function tap(target) { if (firing || !canFire(target)) return { action: 'ignored' }; const timestampMs = now(); if (lastTap && sameTarget(lastTap.target, target) && timestampMs - lastTap.timestampMs <= thresholdMs) { return { action: 'fire', promise: fireOnce(target) }; } select(target); lastTap = { target, timestampMs }; return { action: 'select' }; } function keyboard(target) { if (firing || !canFire(target)) return { action: 'ignored' }; select(target); lastTap = undefined; return { action: 'select' }; } return { tap, keyboard, doubleActivate: (target) => ({ action: 'fire', promise: fireOnce(target) }), isFiring: () => firing, }; } function detectFeedback(previous, next, viewer) { if (!previous || !next || previous.gameId !== next.gameId || next.version <= previous.version) return undefined; const player = viewer === 'player1' ? 0 : viewer === 'player2' ? 1 : -1; if (previous.phase !== next.phase && next.phase === 'finished') { if (player < 0) return { type: 'finish' }; return { type: next.winner === player ? 'victory' : 'defeat' }; } if (previous.phase !== 'in_progress' && next.phase === 'in_progress') return { type: 'start' }; if (!Array.isArray(previous.boards) || !Array.isArray(next.boards)) return undefined; let best; for (let board = 0; board < 2; board += 1) { if (typeof previous.boards[board] !== 'string' || typeof next.boards[board] !== 'string') continue; for (let cell = 0; cell < 100; cell += 1) { const before = previous.boards[board][cell]; const after = next.boards[board][cell]; if (before === after) continue; const perspective = player < 0 ? 'watch' : board === player ? 'damage' : 'attack'; if (after === '4') best = { type: perspective === 'damage' ? 'sunk-damage' : perspective === 'attack' ? 'sunk' : 'watch-sunk' }; else if (!best && after === '3') best = { type: perspective === 'damage' ? 'damage' : perspective === 'attack' ? 'hit' : 'watch-hit' }; else if (!best && after === '2') best = { type: perspective === 'attack' ? 'miss' : perspective === 'damage' ? 'dodged' : 'watch-miss' }; } } if (best) return best; if (player >= 0 && previous.turn !== next.turn && next.turn === viewer) return { type: 'turn' }; return undefined; } function createReactionPicker(catalog, random = Math.random) { const previousByType = new Map(); return type => { const variants = catalog[type]; if (!Array.isArray(variants) || variants.length === 0) return undefined; if (variants.length === 1) return variants[0]; const previous = previousByType.get(type); const candidateCount = previous === undefined ? variants.length : variants.length - 1; let index = Math.min(candidateCount - 1, Math.max(0, Math.floor(random() * candidateCount))); if (previous !== undefined && index >= previous) index += 1; previousByType.set(type, index); return variants[index]; }; } const api = { createTargetActivator, detectFeedback, createReactionPicker }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else globalThis.BattleshipTargetInteraction = api; })();