import type { Client, Item } from 'archipelago.js'; import { clientStatuses, itemsHandlingFlags } from 'archipelago.js'; import { useArchipelagoItems, AP_LOCATIONS } from './useArchipelagoItems'; import type { ConnectionStatus, MessageLogEntry } from '~/utils/types'; import { DEATH_LINK_COOLDOWN_MS, MAX_MESSAGE_LOG_ENTRIES } from '~/utils/constants'; // Track if event handlers have been set up (to avoid duplicates) let eventHandlersInitialized = false; // Track the highest item index we've processed to avoid reprocessing on reconnect // Load from localStorage to survive page refreshes let highestItemIndexProcessed = (() => { if (import.meta.client) { const stored = localStorage.getItem('blockupelago_ap_highestItemIndex'); return stored ? parseInt(stored, 10) : -1; } return -1; })(); export function useArchipelago() { const nuxt = useNuxtApp(); const client = nuxt.$apClient as Client; const host = useState('ap_host', () => 'archipelago.gg'); const port = useState('ap_port', () => 38281); const slot = useState('ap_slot', () => 'BlockupelagoP1'); const password = useState('ap_password', () => ''); const status = useState('ap_status', () => 'disconnected'); const lastMessage = useState('ap_lastMessage', () => ''); // Secure connection setting (true = wss://, false = ws://) const useSecureConnection = useState('ap_useSecure', () => true); // Track if we should auto-reconnect on page load const shouldAutoReconnect = useState('ap_shouldAutoReconnect', () => false); // Death Link state const deathLinkEnabled = useState('ap_deathLink', () => false); const lastDeathTime = useState('ap_lastDeathTime', () => 0); // Goal state const goalCompleted = useState('ap_goalCompleted', () => false); // Slot data from server const slotData = useState>('ap_slotData', () => ({})); // Chat/event log const messageLog = useState('ap_messageLog', () => []); // Get items composable const items = useArchipelagoItems(); function addLogMessage(text: string, type: 'info' | 'item' | 'chat' | 'error' = 'info') { messageLog.value.push({ time: new Date(), text, type }); // Keep log limited to last N messages if (messageLog.value.length > MAX_MESSAGE_LOG_ENTRIES) { messageLog.value = messageLog.value.slice(-MAX_MESSAGE_LOG_ENTRIES); } } // Set up event handlers once function setupEventHandlers() { if (eventHandlersInitialized) return; eventHandlersInitialized = true; // Handle received items client.items.on('itemsReceived', (receivedItems: Item[], startingIndex: number) => { console.log('[AP → Client] itemsReceived event:', { receivedItems, startingIndex, highestProcessed: highestItemIndexProcessed, totalItems: receivedItems.length, }); for (let i = 0; i < receivedItems.length; i++) { const currentIndex = startingIndex + i; // Skip items we've already processed if (currentIndex <= highestItemIndexProcessed) { console.log('[AP → Client] Skipping already processed item at index', currentIndex); continue; } // Update the highest index we've processed and persist it highestItemIndexProcessed = currentIndex; if (import.meta.client) { localStorage.setItem('blockupelago_ap_highestItemIndex', currentIndex.toString()); } const item = receivedItems[i]; if (!item) continue; // Safety check console.log('[AP → Client] Processing item:', { index: currentIndex, itemId: item.id, item }); // item.id is the item ID from the AP world const itemName = handleItemReceived(item.id); if (itemName) { // Format: Player1 sent Tromino I to You from Location "Reach 1000 Points" const sender = item.sender?.name || 'Unknown'; const receiver = slot.value; // Try to get location name if available let locationInfo = ''; if (item.locationId) { // You could map locationId to location name here if needed locationInfo = ` from location #${item.locationId}`; } // Add item class info let itemClass = ''; if (itemName.includes('Ability') || itemName.includes('Slot')) { itemClass = ' [Ability]'; } else if (itemName.includes('Multiplier')) { itemClass = ' [Score Boost]'; } else { itemClass = ' [Piece]'; } addLogMessage(`📦 ${sender} → ${receiver}: ${itemName}${itemClass}${locationInfo}`.replace(/ ,/g, ''), 'item'); } } }); // Handle chat messages client.messages.on('message', (content: string) => { console.log('[AP → Client] Chat message:', content); addLogMessage(content, 'chat'); }); // Handle disconnection client.socket.on('disconnected', () => { console.log('[AP] Disconnected from server'); status.value = 'disconnected'; lastMessage.value = 'Disconnected from server.'; addLogMessage('Connection lost.', 'error'); }); // Handle bounced packets (for Death Link) client.socket.on('bounced', (packet: any, data: any) => { console.log('[AP → Client] Bounced packet:', { packet, data }); if (data?.time && data?.cause && data?.source) { // This is a Death Link handleDeathLinkReceived(data.source, data.cause); } }); } // Handle receiving a Death Link function handleDeathLinkReceived(source: string, cause: string) { if (!deathLinkEnabled.value) return; // Prevent death loops - ignore if we just died const now = Date.now(); if (now - lastDeathTime.value < DEATH_LINK_COOLDOWN_MS) return; addLogMessage(`☠️ Death Link from ${source}: ${cause}`, 'error'); // Death Link handling: In Blockudoku, this triggers a game over // The actual game state is managed by useBlockudoku composable // Emit a global event or call a callback that the game can listen to if (import.meta.client) { window.dispatchEvent(new CustomEvent('blockudoku:deathlink', { detail: { source, cause } })); } } // Send a Death Link to other players function sendDeathLink(cause: string = 'Lost all lives') { if (status.value !== 'connected' || !deathLinkEnabled.value) return; lastDeathTime.value = Date.now(); try { client.bounce( { tags: ['DeathLink'] }, { time: Date.now() / 1000, cause: cause, source: slot.value, }, ); addLogMessage(`☠️ Sent Death Link: ${cause}`, 'error'); } catch (e: any) { console.error('Failed to send Death Link:', e); } } async function connect() { try { console.log('[AP] Initiating connection...'); status.value = 'connecting'; lastMessage.value = ''; goalCompleted.value = false; // Enable Archipelago mode when connecting items.enableArchipelagoMode(); addLogMessage('Connecting to Archipelago...', 'info'); // Set up event handlers before connecting setupEventHandlers(); // Build the connection URL // archipelago.js v2 uses: client.login(url, name, game, options) const protocol = useSecureConnection.value ? 'wss' : 'ws'; const url = `${protocol}://${host.value}:${port.value}`; console.log('[Client → AP] Connecting to:', url, 'as', slot.value); // Build tags array const tags: string[] = []; if (deathLinkEnabled.value) { tags.push('DeathLink'); } const receivedSlotData = await client.login(url, slot.value, 'Blockupelago', { password: password.value || '', // Request all items (own, starting, others) items: itemsHandlingFlags.all, slotData: true, tags, }); console.log('[AP → Client] Login successful! Slot data:', receivedSlotData); console.log('[AP → Client] Initial items received:', client.items.received?.length || 0); // Store slot data for use by items composable slotData.value = receivedSlotData as Record; // Apply slot data settings if needed // TODO: Add Blockudoku-specific slot data handling here status.value = 'connected'; lastMessage.value = 'Connected!'; addLogMessage(`Connected to Archipelago server as ${slot.value}!`, 'info'); // Mark that we should auto-reconnect on refresh shouldAutoReconnect.value = true; // Update status to playing client.updateStatus(clientStatuses.playing); console.log('[Client → AP] Status updated to PLAYING'); // Check for items that should be received (handles initial connection and reconnection) // The itemsReceived event handler will process any items we haven't seen yet if (client.items.received && client.items.received.length > 0) { console.log('[AP] Processing initial items:', client.items.received.length); addLogMessage(`Checking ${client.items.received.length} received items...`, 'info'); // Manually trigger processing of existing items // This ensures items are processed even if the event didn't fire const allItems = client.items.received; for (let i = 0; i < allItems.length; i++) { // Skip items we've already processed if (i <= highestItemIndexProcessed) { continue; } // Update the highest index we've processed and persist it highestItemIndexProcessed = i; if (import.meta.client) { localStorage.setItem('blockupelago_ap_highestItemIndex', i.toString()); } const item = allItems[i]; if (!item) continue; console.log('[AP] Processing initial item:', { index: i, itemId: item.id, item }); const itemName = handleItemReceived(item.id); if (itemName) { const sender = item.sender?.name || 'Unknown'; const receiver = slot.value; let extra = ''; if (item.locationId) { extra = ` (Location #${item.locationId})`; } addLogMessage(`${sender} sent ${itemName} to ${receiver}${extra}`.replace(/ ,/g, ''), 'item'); } } } } catch (e: any) { status.value = 'error'; const errorMsg = e?.message ?? String(e); lastMessage.value = errorMsg; addLogMessage(`Connection error: ${errorMsg}`, 'error'); // Disable Archipelago mode on connection failure items.disableArchipelagoMode(); } } function disconnect() { try { client.socket.disconnect(); addLogMessage('Disconnected from server.', 'info'); } finally { status.value = 'disconnected'; shouldAutoReconnect.value = false; lastMessage.value = 'Disconnected.'; // Keep Archipelago mode active after disconnect to preserve state // User can manually switch to free play if desired } } function handleItemReceived(itemId: number): string | null { const result = items.receiveItem(itemId); if (result) { const itemName = items.getItemName(itemId); lastMessage.value = `Received item: ${itemName}`; return itemName; } return null; } // Send a location check to the server function checkLocation(locationId: number) { console.log('[Client → AP] Checking location:', locationId); if (status.value !== 'connected') return; try { client.check(locationId); addLogMessage(`Location ${locationId} checked.`, 'info'); } catch (e: any) { const errorMsg = e?.message ?? String(e); console.error('[AP] Error checking location:', errorMsg); lastMessage.value = errorMsg; addLogMessage(`Error checking location: ${errorMsg}`, 'error'); } } // Check multiple locations at once function checkLocations(locationIds: number[]) { console.log('[Client → AP] Checking locations:', locationIds, 'status:', status.value); if (status.value !== 'connected') { console.log('[Client → AP] Not connected, skipping location checks'); return; } try { client.check(...locationIds); console.log('[Client → AP] Successfully sent location checks:', locationIds); addLogMessage(`Checked ${locationIds.length} location(s).`, 'info'); } catch (e: any) { const errorMsg = e?.message ?? String(e); console.error('[AP] Error checking locations:', errorMsg, e); lastMessage.value = errorMsg; addLogMessage(`Error checking locations: ${errorMsg}`, 'error'); } } // Legacy function name for compatibility function checkPuzzleSolved() { // This should be called when a puzzle is completed // The actual location to check depends on how many puzzles have been completed addLogMessage('Puzzle completed! Checking for milestone locations...', 'info'); } // Mark the goal as completed function completeGoal() { if (status.value !== 'connected' || goalCompleted.value) return; goalCompleted.value = true; client.updateStatus(clientStatuses.goal); addLogMessage('🏆 Goal completed! Congratulations!', 'info'); } // Check if goal should be completed based on score function checkGoalCompletion(score: number) { if (goalCompleted.value) return; const goalScore = slotData.value?.goal_score ?? 10000; if (score >= goalScore) { completeGoal(); } } // Toggle Death Link function toggleDeathLink(enabled: boolean) { deathLinkEnabled.value = enabled; // Update tags if connected if (status.value === 'connected') { const tags = enabled ? ['DeathLink'] : []; client.updateTags(tags); addLogMessage(`Death Link ${enabled ? 'enabled' : 'disabled'}`, 'info'); } } // Try to auto-reconnect if we were previously connected async function autoReconnect() { if (import.meta.client && shouldAutoReconnect.value && status.value !== 'connected') { // Check if we have valid connection settings if (host.value && port.value && slot.value) { addLogMessage('Attempting to reconnect...', 'info'); await connect(); } } } // Debug function to simulate receiving an item (for testing) function debugReceiveItem(itemId: number) { const itemName = handleItemReceived(itemId); if (itemName) { addLogMessage(`[DEBUG] Received: ${itemName}`, 'item'); } } // Reset Archipelago state (called when clearing all progress) function resetArchipelagoState() { highestItemIndexProcessed = -1; if (import.meta.client) { localStorage.removeItem('blockupelago_ap_highestItemIndex'); } } // Force resync all items from server (useful if local state was cleared) function syncItems() { if (status.value !== 'connected') { addLogMessage('Must be connected to sync items.', 'error'); return; } // Clear current received items so the watcher re-processes from scratch without stacking items.clearReceivedItems(); // Reset the highest processed index to force reprocessing resetArchipelagoState(); addLogMessage('Resyncing items from server...', 'info'); // Re-add all items from the AP client — the watcher in index.vue will apply them if (client.items.received && client.items.received.length > 0) { const allItems = client.items.received; for (let i = 0; i < allItems.length; i++) { const item = allItems[i]; if (!item) continue; highestItemIndexProcessed = i; if (import.meta.client) { localStorage.setItem('blockupelago_ap_highestItemIndex', i.toString()); } handleItemReceived(item.id); } addLogMessage(`Resynced ${allItems.length} item(s).`, 'info'); } else { addLogMessage('No items to sync.', 'info'); } } // Send a chat message async function say(message: string) { if (status.value !== 'connected') return; try { await client.messages.say(message); } catch (e: any) { addLogMessage(`Error sending message: ${e?.message ?? String(e)}`, 'error'); } } // Debug: Complete a location and send to server immediately function debugCompleteCheck(locationId: number) { console.log('[Debug] Completing location check:', locationId); items.debugCompleteLocation(locationId); checkLocation(locationId); addLogMessage(`🔧 Debug: Manually completed location #${locationId}`, 'info'); } return { host, port, slot, password, useSecureConnection, status, lastMessage, messageLog, addLogMessage, slotData, deathLinkEnabled, goalCompleted, connect, disconnect, autoReconnect, checkLocation, checkLocations, checkPuzzleSolved, checkGoalCompletion, completeGoal, toggleDeathLink, sendDeathLink, debugReceiveItem, debugCompleteCheck, syncItems, resetArchipelagoState, say, // Expose items composable items, }; }