Spaces:
Running
Running
| import init, { WasmEngine } from '../pkg/engine_rust.js'; | |
| export class WasmAdapter { | |
| constructor() { | |
| this.engine = null; | |
| this.initialized = false; | |
| this.cardDbRaw = null; | |
| this.cardDb = null; | |
| this.initPromise = null; | |
| } | |
| async init() { | |
| if (this.initialized) return; | |
| if (this.initPromise) return this.initPromise; | |
| this.initPromise = (async () => { | |
| console.log("[WASM] Initializing..."); | |
| try { | |
| await init(); | |
| console.log("[WASM] Loaded."); | |
| // Load Card DB | |
| const base = getAppBaseUrl(); | |
| const res = await fetch(`${base}data/cards_compiled.json`); | |
| const text = await res.text(); | |
| this.cardDbRaw = text; | |
| this.cardDb = JSON.parse(text); // Keep a JS copy for lookups | |
| this.engine = new WasmEngine(this.cardDbRaw); | |
| this.initialized = true; | |
| console.log("[WASM] Engine Ready."); | |
| // Create a default game state | |
| this.createOfflineGame(); | |
| } catch (e) { | |
| console.error("[WASM] Init failed:", e); | |
| throw e; | |
| } | |
| })(); | |
| return this.initPromise; | |
| } | |
| createOfflineGame() { | |
| // Default init with blank boards | |
| this.engine.init_game( | |
| new Uint32Array([]), new Uint32Array([]), | |
| new Uint32Array([]), new Uint32Array([]), | |
| new Uint32Array([]), new Uint32Array([]), | |
| BigInt(Date.now()) | |
| ); | |
| } | |
| createGameWithDecks(p0, p1) { | |
| if (!this.engine) return { success: false, error: "Engine not initialized" }; | |
| console.log("[WASM] Init game with decks:", p0, p1); | |
| this.engine.init_game( | |
| new Uint32Array(p0.deck || []), new Uint32Array(p1.deck || []), | |
| new Uint32Array(p0.energy || []), new Uint32Array(p1.energy || []), | |
| new Uint32Array(p0.lives || []), new Uint32Array(p1.lives || []), | |
| BigInt(Date.now()) | |
| ); | |
| return { success: true }; | |
| } | |
| // --- API Replacements --- | |
| async fetchState() { | |
| if (!this.initialized) await this.init(); | |
| try { | |
| const json = this.engine.get_state_json(); | |
| const state = JSON.parse(json); | |
| // Augment state | |
| state.mode = "pve"; | |
| state.is_pvp = false; | |
| state.my_player_id = 0; | |
| // Generate enriched legal actions | |
| state.legal_actions = this.enrichLegalActions(state); | |
| return { success: true, state: state }; | |
| } catch (e) { | |
| console.error(e); | |
| return { success: false, error: e.toString() }; | |
| } | |
| } | |
| async doAction(actionId) { | |
| if (!this.initialized) return { success: false, error: "Not initialized" }; | |
| try { | |
| this.engine.step(actionId); | |
| return await this.fetchState(); | |
| } catch (e) { | |
| return { success: false, error: e.toString() }; | |
| } | |
| } | |
| async resetGame() { | |
| if (!this.initialized) return; | |
| // Reuse current decks if possible, or clear? | |
| // In Python reset uses stored decks. | |
| // We should store decks in this adapter. | |
| if (this.lastDecks) { | |
| this.engine.init_game( | |
| new Uint32Array(this.lastDecks.p0.deck), new Uint32Array(this.lastDecks.p1.deck), | |
| new Uint32Array(this.lastDecks.p0.energy), new Uint32Array(this.lastDecks.p1.energy), | |
| new Uint32Array(this.lastDecks.p0.lives), new Uint32Array(this.lastDecks.p1.lives), | |
| BigInt(Date.now()) | |
| ); | |
| } else { | |
| this.createOfflineGame(); | |
| } | |
| return await this.fetchState(); | |
| } | |
| async aiSuggest(sims) { | |
| if (!this.initialized) return { success: false }; | |
| try { | |
| const actionId = this.engine.ai_suggest(sims || 500); | |
| // Map ID to description for UI | |
| const enriched = this.enrichAction(actionId, this.getLastState()); | |
| const suggestions = [{ | |
| action_id: actionId, | |
| desc: enriched.desc || ("Action " + actionId), | |
| value: 0.5, // Dummy value | |
| visits: sims | |
| }]; | |
| return { success: true, suggestions: suggestions }; | |
| } catch (e) { | |
| return { success: false, error: e.toString() }; | |
| } | |
| } | |
| // --- Deck Management --- | |
| async uploadDeck(playerId, content) { | |
| // content is either raw HTML or JSON list of IDs | |
| let deckList = []; | |
| try { | |
| // Try JSON first | |
| deckList = JSON.parse(content); | |
| } catch { | |
| // Parse HTML (Deck Log) | |
| deckList = this.parseDeckLogHtml(content); | |
| } | |
| if (!deckList || deckList.length === 0) return { success: false, error: "Invalid deck content" }; | |
| const config = this.resolveDeckList(deckList); | |
| if (!this.lastDecks) this.lastDecks = { p0: { deck: [], energy: [], lives: [] }, p1: { deck: [], energy: [], lives: [] } }; | |
| this.lastDecks[playerId === 0 ? 'p0' : 'p1'] = config; | |
| // Re-init game with new decks | |
| this.createGameWithDecks(this.lastDecks.p0, this.lastDecks.p1); | |
| return { success: true, message: `Loaded ${config.deck.length} members, ${config.lives.length} lives, ${config.energy.length} energy.` }; | |
| } | |
| async loadNamedDeck(deckName) { | |
| try { | |
| // Try relative path first (GitHub Pages / Static) | |
| const res = await fetch(`decks/${deckName}.txt`); | |
| if (!res.ok) throw new Error(`Status ${res.status}`); | |
| const text = await res.text(); | |
| // Extract PL! IDs (simple regex parsing for the txt format) | |
| const matches = text.match(/(PL![A-Za-z0-9\-]+)/g); | |
| if (!matches) throw new Error("No card IDs found"); | |
| return this.resolveDeckList(matches); | |
| } catch (e) { | |
| console.error(`Failed to load named deck ${deckName}:`, e); | |
| return null; | |
| } | |
| } | |
| async resolveDeckList(deckList) { | |
| if (!this.initialized) await this.init(); | |
| if (!this.cardDb) throw new Error("Card database not loaded"); | |
| if (!this.cardMap) this.buildCardMap(); | |
| const deck = []; | |
| const energy = []; | |
| const lives = []; | |
| if (!deckList || !Array.isArray(deckList)) return { deck, energy, lives }; | |
| deckList.forEach(rawId => { | |
| let info = null; | |
| if (typeof rawId === 'number') { | |
| info = this.cardDb.member_db[rawId] || this.cardDb.energy_db[rawId]; | |
| if (!info && this.cardDb.live_db) info = this.cardDb.live_db[rawId]; | |
| } else { | |
| info = this.cardMap[rawId]; | |
| } | |
| if (info) { | |
| const id = info.card_id; | |
| if (id >= 20000) energy.push(id); | |
| else if (id >= 10000) lives.push(id); | |
| else deck.push(id); | |
| } | |
| }); | |
| return { deck, energy, lives }; | |
| } | |
| buildCardMap() { | |
| if (!this.cardDb) return; | |
| this.cardMap = {}; | |
| const dbs = [this.cardDb.member_db, this.cardDb.live_db, this.cardDb.energy_db]; | |
| for (const db of dbs) { | |
| if (!db) continue; | |
| for (const key in db) { | |
| const card = db[key]; | |
| if (card.card_no) this.cardMap[card.card_no] = card; | |
| this.cardMap[card.card_id] = card; // Also map ID | |
| } | |
| } | |
| } | |
| parseDeckLogHtml(html) { | |
| const regex = /title="([^"]+?) :[^"]*"[^>]*>.*?class="num">(\d+)<\/span>/gs; | |
| const cards = []; | |
| let match; | |
| while ((match = regex.exec(html)) !== null) { | |
| const cardNo = match[1].trim(); | |
| const qty = parseInt(match[2], 10); | |
| for (let i = 0; i < qty; i++) cards.push(cardNo); | |
| } | |
| return cards; | |
| } | |
| // --- Helpers --- | |
| getLastState() { | |
| // Helper to get state without parsing everything if possible, | |
| // but we need it for context. | |
| return JSON.parse(this.engine.get_state_json()); | |
| } | |
| enrichLegalActions(state) { | |
| const rawIds = this.engine.get_legal_actions(); // Uint32Array | |
| return Array.from(rawIds).map(id => this.enrichAction(id, state)); | |
| } | |
| enrichAction(id, state) { | |
| // Logic to reverse-engineer action details from ID and State | |
| const p = state.players[state.current_player]; | |
| if (id === 0) return { id, desc: "Pass / Confirm" }; | |
| // 1-180: Play Member (HandIdx 0-59 * 3 + Slot 0-2) | |
| if (id >= 1 && id <= 180) { | |
| const adj = id - 1; | |
| const handIdx = Math.floor(adj / 3); | |
| const slotIdx = adj % 3; | |
| const cardId = p.hand[handIdx]; | |
| const card = this.getCard(cardId); | |
| return { | |
| id, | |
| type: 'PLAY', | |
| hand_idx: handIdx, | |
| area_idx: slotIdx, | |
| name: card ? card.name : "Unknown", | |
| img: card ? (card.img_path.startsWith('img/') ? card.img_path : 'img/' + card.img_path) : null, | |
| cost: card ? card.cost : 0, // Should calc reduced cost | |
| desc: `Play ${card ? card.name : 'Card'} to Slot ${slotIdx}` | |
| }; | |
| } | |
| // 200-399: Ability (Slot 0-2 * 10 + AbIdx 0-9) | |
| // Wait, logic.rs mask is: 200 + slot_idx * 10 + ab_idx. | |
| // Assuming max 10 abilities per card (safe assumption). | |
| if (id >= 200 && id < 400) { | |
| const adj = id - 200; | |
| const slotIdx = Math.floor(adj / 10); | |
| const abIdx = adj % 10; | |
| const cardId = p.stage[slotIdx]; | |
| const card = this.getCard(cardId); | |
| // We don't have ability text easily available unless we look in DB | |
| // compiled DB structure: member_db -> abilities (array) -> text | |
| let abText = "Ability"; | |
| if (card && card.abilities && card.abilities[abIdx]) { | |
| abText = card.abilities[abIdx].raw_text || "Ability"; | |
| } | |
| return { | |
| id, | |
| type: 'ABILITY', | |
| area_idx: slotIdx, | |
| name: card ? card.name : "Unknown", | |
| img: card ? (card.img_path.startsWith('img/') ? card.img_path : 'img/' + card.img_path) : null, | |
| desc: `Activate ${card ? card.name : 'Card'}`, | |
| text: abText | |
| }; | |
| } | |
| // 400-499: Live Set (HandIdx 0-99) | |
| if (id >= 400 && id < 500) { | |
| const handIdx = id - 400; | |
| const cardId = p.hand[handIdx]; | |
| const card = this.getCard(cardId); | |
| return { | |
| id, | |
| type: 'LIVE_SET', | |
| hand_idx: handIdx, | |
| name: card ? card.name : "Unknown", | |
| img: card ? (card.img_path.startsWith('img/') ? card.img_path : 'img/' + card.img_path) : null, | |
| desc: `Set Live: ${card ? card.name : 'Card'}` | |
| }; | |
| } | |
| // 300-399: Mulligan ?? | |
| // In Rust `step`: `action >> i`. This means Action IS the mask. | |
| // But `get_legal_actions`: `mask[i] = true` where i is 0..2^N. | |
| // So ID is the bitmask. | |
| if (state.phase === -1 || state.phase === 0) { // Mulligan phases | |
| return { | |
| id, | |
| type: 'MULLIGAN', | |
| desc: `Mulligan Pattern ${id}` | |
| }; | |
| } | |
| // 560-562: Target Stage (Slot) | |
| if (id >= 560 && id <= 562) { | |
| return { id, type: 'SELECT_STAGE', area_idx: id - 560, desc: `Select Slot ${id - 560}` }; | |
| } | |
| // 600-659: Select List Item | |
| if (id >= 600 && id <= 659) { | |
| return { id, type: 'SELECT', index: id - 600, desc: `Select Item ${id - 600}` }; | |
| } | |
| // 660-719: Select Discard | |
| if (id >= 660 && id <= 719) { | |
| return { id, type: 'SELECT_DISCARD', index: id - 660, desc: `Select Discard ${id - 660}` }; | |
| } | |
| // Fallback | |
| return { id, desc: `Action ${id}` }; | |
| } | |
| getCard(id) { | |
| if (!this.cardDb) return null; | |
| return this.cardDb.member_db[id] || (this.cardDb.live_db ? this.cardDb.live_db[id] : null) || this.cardDb.energy_db[id]; | |
| } | |
| } | |
| // Singleton instance | |
| export const wasmAdapter = new WasmAdapter(); | |