LovecaSim / frontend /web_ui /js /wasm_adapter.js
trioskosmos's picture
Upload folder using huggingface_hub
1d0beb6 verified
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();