use tiny_http::{Server, Response, Header, Request, Method, StatusCode}; use rust_embed::RustEmbed; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use mime_guess::from_path; use std::thread; use std::time::Duration; use serde::Deserialize; use serde_json::{json, Value}; use rand::Rng; use uuid::Uuid; // Import engine components use engine_rust::core::models::{GameState, CardDatabase, Phase, PlayerState}; use engine_rust::core::mcts::{SearchHorizon, EvalMode}; #[derive(RustEmbed)] #[folder = "static_content/"] struct Assets; #[derive(RustEmbed)] #[folder = "../ai/decks/"] struct Decks; // --- Game Server State --- struct Room { _id: String, state: GameState, players: HashMap, // Token -> Player ID (0 or 1) mode: String, // "pve" or "pvp" last_update: std::time::SystemTime, created_at: std::time::SystemTime, is_public: bool, } struct AppState { rooms: Mutex>, card_db: CardDatabase, energy_db: serde_json::Value, } // API Request Structures #[derive(Deserialize)] struct CreateRoomReq { mode: Option, public: Option, decks: Option>, // Keys can be "0" or "1" } #[derive(Deserialize, Clone)] struct DeckConfig { main: Vec, energy: Vec, #[serde(default)] #[serde(rename = "type")] _deck_type: String, } #[derive(Deserialize)] struct JoinRoomReq { room_id: String, } #[derive(Deserialize)] struct ActionReq { action_id: i32, } #[derive(Deserialize)] struct UploadDeckReq { player: usize, content: String, // Raw deck file content } #[derive(Deserialize)] struct AiSuggestReq { sims: usize, } #[derive(Deserialize)] #[allow(dead_code)] struct SetDeckReq { player: usize, deck: Vec, } struct ParsedDecks { members: Vec, lives: Vec, energy: Vec, } fn main() { // 1. Initialize Card Database from Embedded Asset println!("Loading card database..."); let db_file = Assets::get("data/cards_compiled.json").expect("Missing cards_compiled.json!"); let db_json = std::str::from_utf8(db_file.data.as_ref()).expect("Failed to read DB json"); let card_db = CardDatabase::from_json(db_json).expect("Failed to parse CardDatabase"); // Also load raw JSON for energy_db metadata let raw_db: serde_json::Value = serde_json::from_str(db_json).unwrap_or(json!({})); let energy_db = raw_db.get("energy_db").cloned().unwrap_or(json!({})); // 2. Initialize App State let app_state = Arc::new(AppState { rooms: Mutex::new(HashMap::new()), card_db, energy_db, }); // 3. Start Server let env_port = std::env::var("PORT").ok().and_then(|p| p.parse().ok()); let ports = if let Some(p) = env_port { vec![p] } else { vec![8000, 8080, 8888, 3000, 5000] }; let mut server = None; let mut port = 0; for p in ports { match Server::http(format!("0.0.0.0:{}", p)) { Ok(s) => { server = Some(s); port = p; break; } // If explicit port failed, don't fall back, just panic Err(e) if env_port.is_some() => panic!("Failed to bind to requested PORT {}: {}", p, e), Err(_) => continue, } } let server = server.expect("Failed to start server. Is the port blocked?"); // Print Network Info println!("--------------------------------------------------"); println!("Loveca Launcher (Multiplayer Host) is Running!"); println!("Local: http://127.0.0.1:{}", port); // Attempt to find local IP (naively) if let Ok(my_ip) = get_local_ip() { println!("Network: http://{}:{}", my_ip, port); } else { println!("Network: http://[YOUR-IP]:{}", port); } println!("--------------------------------------------------"); // Auto-open browser let url = format!("http://127.0.0.1:{}/index.html", port); thread::spawn(move || { thread::sleep(Duration::from_millis(1000)); let _ = webbrowser::open(&url); }); let shared_state = app_state.clone(); for request in server.incoming_requests() { let state_ref = shared_state.clone(); handle_request(request, state_ref); } } // --- Request Handler --- fn handle_request(mut request: Request, state: Arc) { let url = request.url().to_string(); let (path_raw, query) = match url.split_once('?') { Some((p, q)) => (p, Some(q)), None => (url.as_str(), None), }; // Normalize path (strip trailing slash for API match simplicity) let path = if path_raw.len() > 1 && path_raw.ends_with('/') { &path_raw[..path_raw.len() - 1] } else { path_raw }; // CORS Handling (Allow All for LAN simplicity) if request.method() == &Method::Options { let response = Response::empty(200) .with_header(Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap()) .with_header(Header::from_bytes(&b"Access-Control-Allow-Methods"[..], &b"GET, POST, OPTIONS"[..]).unwrap()) .with_header(Header::from_bytes(&b"Access-Control-Allow-Headers"[..], &b"Content-Type, X-Room-Id, X-Session-Token, X-Player-Idx"[..]).unwrap()); let _ = request.respond(response); return; } // Default Headers let json_header = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(); let cors_header = Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap(); // ---------------- API ROUTES ---------------- if path.starts_with("/api/") { let response_json: String; let mut status = 200; match path { "/api/status" => { response_json = json!({ "status": "rust_server", "version": "1.0.1" }).to_string(); }, "/api/rooms/create" => { if let Ok(body) = parse_body::(&mut request) { let mut rooms = state.rooms.lock().unwrap(); let room_id = generate_room_code(); let token = Uuid::new_v4().to_string(); let mut players = HashMap::new(); players.insert(token.clone(), 0); // Creator is P0 let mut game_state = GameState::default(); // Initialize decks if provided if let Some(decks_config) = body.decks { let p0_conf = decks_config.get("0"); let p1_conf = decks_config.get("1"); let p0 = p0_conf.map(|c| resolve_deck(&c.main, &c.energy, &state.card_db, &state.energy_db)).unwrap_or_else(|| ParsedDecks { members: vec![1; 20], lives: vec![2; 3], energy: vec![40000; 12] }); let p1 = p1_conf.map(|c| resolve_deck(&c.main, &c.energy, &state.card_db, &state.energy_db)).unwrap_or_else(|| ParsedDecks { members: vec![1; 20], lives: vec![2; 3], energy: vec![40000; 12] }); game_state.initialize_game( p0.members, p1.members, p0.energy, p1.energy, p0.lives, p1.lives ); } let new_room = Room { _id: room_id.clone(), state: game_state, players, mode: body.mode.unwrap_or("pve".to_string()), last_update: std::time::SystemTime::now(), created_at: std::time::SystemTime::now(), is_public: body.public.unwrap_or(false), }; rooms.insert(room_id.clone(), new_room); response_json = json!({ "success": true, "room_id": room_id, "session": { "token": token, "player_id": 0 } }).to_string(); } else { status = 400; response_json = json!({"error": "Bad Request or Invalid Payload"}).to_string(); } }, "/api/rooms/list" => { let rooms_lock = state.rooms.lock().unwrap(); let mut public_rooms = Vec::new(); for (rid, room) in rooms_lock.iter() { if room.is_public { let occupied = room.players.values().fold(0, |acc, &p| if p < 2 { acc + 1 } else { acc }); public_rooms.push(json!({ "room_id": rid, "mode": room.mode, "players": occupied, "turn": room.state.turn, "phase": format!("{:?}", room.state.phase), "created_at": format!("{:?}", room.created_at) // Simple placeholder })); } } response_json = json!({ "success": true, "rooms": public_rooms }).to_string(); }, "/api/rooms/join" => { if let Ok(body) = parse_body::(&mut request) { let mut rooms = state.rooms.lock().unwrap(); let rid = body.room_id.to_uppercase(); if let Some(room) = rooms.get_mut(&rid) { // Assignment logic: if P1 is free, take it. let taken_pids: Vec = room.players.values().cloned().collect(); if !taken_pids.contains(&1) { let token = Uuid::new_v4().to_string(); room.players.insert(token.clone(), 1); response_json = json!({ "success": true, "room_id": rid, "session": { "token": token, "player_id": 1 } }).to_string(); } else { status = 400; response_json = json!({"success": false, "error": "Room is full (P1 taken)"}).to_string(); } } else { status = 404; response_json = json!({"success": false, "error": "Room not found"}).to_string(); } } else { status = 400; response_json = json!({"error": "Bad Request"}).to_string(); } }, "/api/state" => { let room_id = get_header(&request, "X-Room-Id"); let viewer_idx_str = get_header(&request, "X-Player-Idx").unwrap_or("0".to_string()); let viewer_idx: usize = viewer_idx_str.parse().unwrap_or(0); if let Some(rid_raw) = room_id { let rid = rid_raw.to_uppercase(); let mut rooms = state.rooms.lock().unwrap(); if let Some(room) = rooms.get_mut(&rid) { room.last_update = std::time::SystemTime::now(); // Check AI if room.mode == "pve" && room.state.current_player == 1 && room.state.phase != Phase::Terminal { let mut steps = 0; while room.state.current_player == 1 && room.state.phase != Phase::Terminal && steps < 10 { let suggestions = room.state.get_mcts_suggestions(&state.card_db, 100, SearchHorizon::GameEnd, EvalMode::Normal); if let Some((action, _, _)) = suggestions.first() { let _ = room.state.step(&state.card_db, *action); } else { let _ = room.state.step(&state.card_db, 0); } steps += 1; } } let rich_state = serialize_state_rich(&room.state, &state.card_db, &state.energy_db, &room.mode, viewer_idx); response_json = json!({ "success": true, "state": rich_state }).to_string(); } else { status = 404; response_json = json!({"error": "Room not found"}).to_string(); } } else { status = 412; response_json = json!({"error": "Missing X-Room-Id"}).to_string(); } }, "/api/action" | "/api/do_action" => { let room_id = get_header(&request, "X-Room-Id"); let token = get_header(&request, "X-Session-Token"); let viewer_idx_str = get_header(&request, "X-Player-Idx"); if let (Some(rid_raw), Ok(body)) = (room_id, parse_body::(&mut request)) { let rid = rid_raw.to_uppercase(); let mut rooms = state.rooms.lock().unwrap(); if let Some(room) = rooms.get_mut(&rid) { let viewer_idx = if let Some(idx_s) = viewer_idx_str { idx_s.parse().unwrap_or(0) } else if let Some(t) = token { *room.players.get(&t).unwrap_or(&0) } else { 0 }; room.last_update = std::time::SystemTime::now(); match room.state.step(&state.card_db, body.action_id) { Ok(_) => { let rich_state = serialize_state_rich(&room.state, &state.card_db, &state.energy_db, &room.mode, viewer_idx); response_json = json!({"success": true, "state": rich_state}).to_string(); }, Err(e) => { response_json = json!({"success": false, "error": e}).to_string(); } } } else { status = 404; response_json = json!({"error": "Room not found"}).to_string(); } } else { status = 400; response_json = json!({"error": "Bad Request or Missing Room ID"}).to_string(); } }, "/api/rooms/reset" => { let room_id = get_header(&request, "X-Room-Id"); if let Some(rid_raw) = room_id { let rid = rid_raw.to_uppercase(); let mut rooms = state.rooms.lock().unwrap(); if let Some(room) = rooms.get_mut(&rid) { room.state = GameState::default(); response_json = json!({"success": true, "message": "Game state reset"}).to_string(); } else { status = 404; response_json = json!({"error": "Room not found"}).to_string(); } } else { status = 400; response_json = json!({"error": "Missing X-Room-Id"}).to_string(); } }, "/api/get_test_deck" => { handle_get_test_deck(request, query, &state.card_db); return; }, "/api/upload_deck" => { let room_id = get_header(&request, "X-Room-Id"); if let (Some(rid_raw), Ok(body)) = (room_id, parse_body::(&mut request)) { let rid = rid_raw.to_uppercase(); let mut rooms = state.rooms.lock().unwrap(); if let Some(room) = rooms.get_mut(&rid) { let parsed = parse_deck_content(&body.content, &state.card_db, &state.energy_db); let p_idx = body.player; // In simple upload, we might just be setting ONE player's deck and defaulting the other let default_p = ParsedDecks { members: vec![1; 20], lives: vec![2; 3], energy: vec![40000; 12] }; let p0 = if p_idx == 0 { &parsed } else { &default_p }; let p1 = if p_idx == 1 { &parsed } else { &default_p }; room.state.initialize_game( p0.members.clone(), p1.members.clone(), p0.energy.clone(), p1.energy.clone(), p0.lives.clone(), p1.lives.clone(), ); response_json = json!({"success": true, "message": "Deck uploaded & Game Reset"}).to_string(); } else { status = 404; response_json = json!({"error": "Room not found"}).to_string(); } } else { status = 400; response_json = json!({"error": "Bad Request"}).to_string(); } }, "/api/set_deck" => { let room_id = get_header(&request, "X-Room-Id"); if let (Some(rid_raw), Ok(_body)) = (room_id, parse_body::(&mut request)) { let rid = rid_raw.to_uppercase(); let mut rooms = state.rooms.lock().unwrap(); if let Some(_room) = rooms.get_mut(&rid) { response_json = json!({"success": true}).to_string(); } else { status = 404; response_json = json!({"error": "Room not found"}).to_string(); } } else { status = 400; response_json = json!({"error": "Bad Request"}).to_string(); } }, "/api/ai_suggest" => { let room_id = get_header(&request, "X-Room-Id"); if let (Some(rid_raw), Ok(body)) = (room_id, parse_body::(&mut request)) { let rid = rid_raw.to_uppercase(); let mut rooms = state.rooms.lock().unwrap(); if let Some(room) = rooms.get_mut(&rid) { let suggestions = room.state.get_mcts_suggestions(&state.card_db, body.sims, SearchHorizon::GameEnd, EvalMode::Normal); let json_suggs: Vec = suggestions.iter().map(|(a, v, n)| { json!({ "desc": format!("ID {}", a), "value": v, "visits": n, "action_id": a }) }).collect(); response_json = json!({ "success": true, "suggestions": json_suggs }).to_string(); } else { status = 404; response_json = json!({"error": "Room not found"}).to_string(); } } else { status = 400; response_json = json!({"error": "Bad Request"}).to_string(); } }, _ => { status = 404; response_json = json!({"error": "API Route Not Found in Launcher"}).to_string(); } } let response = Response::from_string(response_json) .with_status_code(status) .with_header(json_header) .with_header(cors_header); let _ = request.respond(response); } else { // Static File Handling handle_static_file(request); } } // --- Helper Functions --- fn resolve_deck(main_codes: &[String], energy_codes: &[String], db: &CardDatabase, edb: &Value) -> ParsedDecks { let mut members = Vec::new(); let mut lives = Vec::new(); let mut energy = Vec::new(); let mut code_map = HashMap::new(); for (id, card) in &db.members { code_map.insert(card.card_no.clone(), (*id, "member")); } for (id, card) in &db.lives { code_map.insert(card.card_no.clone(), (*id, "live")); } if let Some(obj) = edb.as_object() { for (id_s, card) in obj { if let Some(no) = card["card_no"].as_str() { let id = id_s.parse::().unwrap_or(0); code_map.insert(no.to_string(), (id, "energy")); } } } for code in main_codes { if let Some(&(id, kind)) = code_map.get(code) { match kind { "member" => members.push(id), "live" => lives.push(id), _ => {} } } else if let Ok(id) = code.parse::() { // Fallback: If it's a raw ID, determine type by range // Ranges: 1-10000 (Members), 20000-29999 (Lives), 40000+ (Energy) - approximate if db.members.contains_key(&id) { members.push(id); } else if db.lives.contains_key(&id) { lives.push(id); } } } for code in energy_codes { if let Some(&(id, _)) = code_map.get(code) { energy.push(id); } else if let Ok(id) = code.parse::() { // Validate it exists in energy db (keys are strings in JSON but IDs are numbers) // We can just assume valid if > 30000 for now or check map values energy.push(id); } } // Critical: SIC needs 3 lives and some energy to function properly if lives.len() < 3 { // Pad with default lives (ID 2 usually exists, or find first available) let def_live = *db.lives.keys().next().unwrap_or(&2); lives.extend(std::iter::repeat(def_live).take(3 - lives.len())); } // Explicit Energy Default: 10 Energy cards if energy.len() < 10 { // Find a valid energy ID (usually 40000 corresponds to plain energy) // Or scan edb for the first valid one let mut def_energy = 40000; if let Some(obj) = edb.as_object() { if let Some(k) = obj.keys().next() { def_energy = k.parse().unwrap_or(40000); } } energy.extend(std::iter::repeat(def_energy).take(12 - energy.len())); } if members.is_empty() { let def_mem = *db.members.keys().next().unwrap_or(&1); members.extend(std::iter::repeat(def_mem).take(20)); } ParsedDecks { members, lives, energy } } fn get_local_ip() -> Result { use std::net::UdpSocket; let socket = UdpSocket::bind("0.0.0.0:0").map_err(|_| ())?; socket.connect("8.8.8.8:80").map_err(|_| ())?; Ok(socket.local_addr().map_err(|_| ())?.ip().to_string()) } fn handle_static_file(request: Request) { let url = request.url().to_string(); let (path_str, _) = match url.split_once('?') { Some((p, _)) => (p, ()), None => (url.as_str(), ()), }; let clean_path = if path_str == "/" || path_str == "" { "index.html" } else { path_str.trim_start_matches('/') }; let mut file = Assets::get(clean_path); // WebP Fallback Optimization: If requesting .png or .jpg but only .webp exists if file.is_none() && (clean_path.ends_with(".png") || clean_path.ends_with(".jpg")) { let webp_path = format!("{}.webp", &clean_path[..clean_path.len() - 4]); file = Assets::get(&webp_path); } if let Some(file_data) = file { let mut mime = from_path(clean_path).first_or_octet_stream(); // Correct MIME if we fell back to webp // Since we only reached here if file was Some, and we know we tried .webp if original failed if (clean_path.ends_with(".png") || clean_path.ends_with(".jpg")) && file_data.metadata.last_modified().is_some() { // We can't easily check the extension of the found file from Metadata in rust-embed 6 // but if the original failed and we have a file, it's the webp one. // Actually, let's just use a more robust check in the fallback block. } // Let's refine the MIME logic slightly to be 100% sure if clean_path.ends_with(".png") || clean_path.ends_with(".jpg") { // Check if the original exists or if we used the webp fallback if Assets::get(clean_path).is_none() { mime = "image/webp".parse().unwrap(); } } if clean_path.ends_with(".wasm") { mime = "application/wasm".parse().unwrap(); } let mut response = Response::from_data(file_data.data.into_owned()); let header = Header::from_bytes(&b"Content-Type"[..], mime.as_ref().as_bytes()).unwrap(); response.add_header(header); response.add_header(Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap()); if mime.type_() == mime::IMAGE { let cache = Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=31536000"[..]).unwrap(); response.add_header(cache); } let _ = request.respond(response); } else { let _ = request.respond(Response::from_string("404 Not Found").with_status_code(StatusCode(404))); } } fn parse_body(request: &mut Request) -> Result { let mut content = String::new(); request.as_reader().read_to_string(&mut content).map_err(|_| ())?; serde_json::from_str(&content).map_err(|_| ()) } fn get_header(request: &Request, name: &str) -> Option { request.headers().iter() .find(|h| h.field.to_string().eq_ignore_ascii_case(name)) .map(|h| h.value.as_str().to_string()) } fn generate_room_code() -> String { let mut rng = rand::thread_rng(); let chars: Vec = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".chars().collect(); (0..4).map(|_| chars[rng.gen_range(0..chars.len())]).collect() } fn parse_deck_content(content: &str, db: &CardDatabase, edb: &Value) -> ParsedDecks { let mut codes = Vec::new(); for line in content.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } if let Some((code, count_s)) = trimmed.split_once('x') { let n: usize = count_s.trim().parse().unwrap_or(1); for _ in 0..n { codes.push(code.trim().to_string()); } } else { codes.push(trimmed.to_string()); } } resolve_deck(&codes, &[], db, edb) } // --- RICH SERIALIZATION --- fn serialize_card(cid: i16, db: &CardDatabase, edb: &serde_json::Value, viewable: bool) -> Value { if cid < 0 { return Value::Null; } let id_u = (cid as u32) & 0xFFFFF; let id_u16 = id_u as u16; if !viewable { return json!({ "id": cid, "img": "cards/back.png", "name": "???", "hidden": true }); } if let Some(m) = db.members.get(&id_u16) { return json!({ "id": cid, "card_no": m.card_no, "name": m.name, "type": "member", "cost": m.cost, "blade": m.blades, "img": m.img_path, "hearts": m.hearts, "blade_hearts": m.blade_hearts, "text": m.ability_text, "original_text": "" }); } else if let Some(l) = db.lives.get(&id_u16) { return json!({ "id": cid, "card_no": l.card_no, "name": l.name, "type": "live", "score": l.score, "img": l.img_path, "required_hearts": l.required_hearts, "text": l.ability_text, "original_text": "" }); } else if let Some(e) = edb.get(&id_u16.to_string()) { return json!({ "id": cid, "card_no": e["card_no"], "name": e["name"], "type": "energy", "img": e["img_path"], "text": e["ability_text"], "original_text": "" }); } json!({ "id": cid, "name": format!("Card {}", id_u), "img": "icon_blade.png" }) } fn serialize_player_rich(p: &PlayerState, gs: &GameState, db: &CardDatabase, edb: &Value, p_idx: usize, viewer_idx: usize, legal_mask: &[bool]) -> Value { let viewable = p_idx == viewer_idx; let hand: Vec = p.hand.iter().enumerate().map(|(i, &cid)| { let mut c = serialize_card(cid as i16, db, edb, viewable); if viewable { c["is_new"] = json!(p.hand_added_turn.get(i).map(|&t| t as u32 == gs.turn as u32).unwrap_or(false)); let mut valid_actions = Vec::new(); // Mapping from Python serializer for area in 0..3 { let aid = 1 + i * 3 + area; if aid < legal_mask.len() && legal_mask[aid] { valid_actions.push(aid); } } for aid in [300 + i, 400 + i, 500 + i] { if aid < legal_mask.len() && legal_mask[aid] { valid_actions.push(aid); } } c["valid_actions"] = json!(valid_actions); } c }).collect(); let discard: Vec = p.discard.iter().map(|&cid| serialize_card(cid as i16, db, edb, true)).collect(); let success: Vec = p.success_lives.iter().map(|&cid| serialize_card(cid as i16, db, edb, true)).collect(); let mut energy = Vec::new(); for (i, &cid) in p.energy_zone.iter().enumerate() { energy.push(json!({ "id": i, "tapped": p.tapped_energy[i], "card": serialize_card(cid as i16, db, edb, false) // Energy cards are usually hidden })); } let mut stage = Vec::new(); for i in 0..3 { let cid = p.stage[i]; if cid >= 0 { let mut c = serialize_card(cid, db, edb, true); c["tapped"] = json!(p.tapped_members[i]); c["energy"] = json!(p.stage_energy_count[i]); // Effective Stats let eff_blade = gs.get_effective_blades(p_idx, i, db); let eff_hearts = gs.get_effective_hearts(p_idx, i, db); c["blade"] = json!(eff_blade); c["hearts"] = json!(eff_hearts); // Modifiers for UI let mut modifiers = Vec::new(); if let Some(m) = db.members.get(&((cid as u32 & 0xFFFF) as u16)) { if eff_blade > m.blades as u32 { modifiers.push(json!({"type": "blade", "value": eff_blade - m.blades as u32, "label": format!("Attack +{}", eff_blade - m.blades as u32)})); } } c["modifiers"] = json!(modifiers); // Valid actions for stage highlighting let mut valid_actions = Vec::new(); for ab_idx in 0..10 { let aid = 200 + i * 10 + ab_idx; if aid < legal_mask.len() && legal_mask[aid] { valid_actions.push(aid); } } let sel_stage = 560 + i; if sel_stage < legal_mask.len() && legal_mask[sel_stage] { valid_actions.push(sel_stage); } c["valid_actions"] = json!(valid_actions); stage.push(c); } else { stage.push(Value::Null); } } let total_hearts = gs.get_total_hearts(p_idx, db); let mut temp_hearts = total_hearts.clone(); let mut lives = Vec::new(); for i in 0..3 { let cid = p.live_zone[i]; if cid >= 0 { let mut c = serialize_card(cid, db, edb, p.live_zone_revealed[i] || viewable); // Fulfillment Logic for Live Guide if let Some(l) = db.lives.get(&((cid as u32 & 0xFFFF) as u16)) { let mut filled = [0u32; 7]; let mut cleared = true; for color_idx in 0..6 { let take = temp_hearts[color_idx].min(l.required_hearts[color_idx] as u32); filled[color_idx] = take; temp_hearts[color_idx] -= take; if filled[color_idx] < l.required_hearts[color_idx] as u32 { cleared = false; } } // Any let any_need = l.required_hearts[6] as u32; let rem_total: u32 = temp_hearts.iter().sum(); filled[6] = rem_total.min(any_need); if filled[6] < any_need { cleared = false; } c["filled_hearts"] = json!(filled); c["is_cleared"] = json!(cleared); } lives.push(c); } else { lives.push(Value::Null); } } json!({ "player_id": p.player_id, "score": p.score, "is_active": gs.current_player as usize == p_idx, "hand": hand, "hand_count": hand.len(), "deck_count": p.deck.len(), "discard": discard, "discard_count": discard.len(), "energy": energy, "energy_count": energy.len(), "energy_deck_count": p.energy_deck.len(), "energy_untapped": p.tapped_energy.iter().filter(|&&t| !t).count(), "stage": stage, "live_zone": lives, "success_lives": success, "total_hearts": total_hearts, "total_blades": gs.get_total_blades(p_idx, db), "mulligan_selection": (0..hand.len()).filter(|&i| (p.mulligan_selection >> i) & 1 == 1).collect::>(), "looked_cards": p.looked_cards.iter().map(|&cid| serialize_card(cid as i16, db, edb, true)).collect::>(), }) } fn serialize_state_rich(gs: &GameState, db: &CardDatabase, edb: &Value, mode: &str, viewer_idx: usize) -> Value { let mut legal_mask = vec![false; 3000]; gs.get_legal_actions_into(db, &mut legal_mask); let p0 = serialize_player_rich(&gs.players[0], gs, db, edb, 0, viewer_idx, &legal_mask); let p1 = serialize_player_rich(&gs.players[1], gs, db, edb, 1, viewer_idx, &legal_mask); let mut enriched_actions = Vec::new(); let mut pending_choice = Value::Null; if gs.current_player as usize == viewer_idx { // --- Pending Choice Inference --- if gs.phase as u8 == 10 { // Phase::Response let mut p_type = "SELECT_FROM_LIST"; let mut p_desc = "選択してください"; let has_color = legal_mask.iter().enumerate().any(|(i, &v)| v && (i >= 580 && i <= 585)); let has_mode = legal_mask.iter().enumerate().any(|(i, &v)| v && (i >= 570 && i <= 579)); let has_stage = legal_mask.iter().enumerate().any(|(i, &v)| v && (i >= 560 && i <= 562)); if has_color { p_type = "COLOR_SELECT"; p_desc = "色を選択してください"; } else if has_mode { p_type = "SELECT_MODE"; p_desc = "モードを選択してください"; } else if has_stage { p_type = "SELECT_STAGE"; p_desc = "メンバーを選択してください"; } let mut source_member = "Game".to_string(); let mut source_img = "icon_blade.png".to_string(); if gs.pending_card_id >= 0 { let c = serialize_card(gs.pending_card_id, db, edb, true); source_member = c["name"].as_str().unwrap_or("Unknown").to_string(); source_img = c["img"].as_str().unwrap_or("icon_blade.png").to_string(); } pending_choice = json!({ "type": p_type, "description": p_desc, "source_member": source_member, "source_img": source_img, "min": 1, "max": 1, "can_skip": legal_mask[0] }); } else if gs.phase == Phase::LiveResult { pending_choice = json!({ "type": "SELECT_SUCCESS_LIVE", "description": "獲得するライブカードを1枚選んでください", "source_member": "Live Success", "source_img": "icon_blade.png", "min": 1, "max": 1, "can_skip": false }); } // --- Action Enrichment --- let curr_p = &gs.players[gs.current_player as usize]; for (i, &v) in legal_mask.iter().enumerate() { if v { let mut meta = json!({ "id": i, "name": format!("Action {}", i), "desc": format!("Action {}", i) }); if i == 0 { let mut name = "【パス】何もしない"; let phase_val = gs.phase as i8; if phase_val == 4 { name = "【終了】メインフェイズを終了する"; } // Main else if phase_val == 5 { name = "【確認】ライブカードをセットして続行"; } // LiveSet else if phase_val == 8 { name = "【進む】次へ進む"; } // LiveResult else if phase_val == -1 || phase_val == 0 { name = "【確認】マリガンを実行"; } // Mulligan else if !pending_choice.is_null() { let source = pending_choice["source_member"].as_str().unwrap_or("アビリティ"); meta = json!({ "id": 0, "type": "PASS", "name": format!("【スキップ】{}の効果を使用しない", source), "desc": "Skip Optional Effect" }); enriched_actions.push(meta); continue; } meta = json!({ "id": 0, "type": "PASS", "name": name, "desc": "Confirm/Pass" }); } else if 580 <= i && i <= 585 { let colors = ["赤", "青", "緑", "黄", "紫", "ピンク"]; meta = json!({ "id": i, "type": "COLOR_SELECT", "index": i - 580, "name": format!("【色選択】 {}", colors[i-580]) }); } else if 500 <= i && i <= 509 { let idx = i - 500; if let Some(&cid) = curr_p.hand.get(idx) { let c = serialize_card(cid as i16, db, edb, true); let mut desc = "選択"; if !pending_choice.is_null() { let p_type = pending_choice["type"].as_str().unwrap_or(""); if p_type == "RECOVER_MEMBER" { desc = "回収"; } else if p_type == "DISCARD" { desc = "捨てる"; } } meta = json!({ "id": i, "type": "SELECT_HAND", "hand_idx": idx, "name": format!("【手札選択】 {}を{}", c["name"], desc), "img": c["img"] }); } } else if i >= 1 && i <= 180 { let hand_idx = (i - 1) / 3; let area_idx = (i - 1) % 3; if let Some(&cid) = curr_p.hand.get(hand_idx) { let c = serialize_card(cid as i16, db, edb, true); let cost = gs.get_member_cost(gs.current_player as usize, cid as i32, area_idx as i32, db); // Check for OnPlay [登場] let mut suffix = ""; // Note: Checking abilities requires iterating db.members, which is fine here. if let Some(_m) = db.members.get(&(cid as u16)) { // Simplify: Just check raw text or ability list if accessible. // For now, we assume if it has logic it might be OnPlay. // Accurate check: iterate m.abilities for TriggerType::OnPlay (1) -> Not exposed easily in JSON db without parsing. // Fallback: Check if text contains [On Play] or [登場] if c["text"].as_str().unwrap_or("").contains("【登場時】") { suffix = " [登場]"; } } let areas = ["左", "センター", "右"]; meta = json!({ "id": i, "type": "PLAY", "hand_idx": hand_idx, "area_idx": area_idx, "name": format!("【{}に置く】 {}{} (コスト {})", areas[area_idx], c["name"], suffix, cost), "img": c["img"], "cost": cost, "text": c["text"] }); } } else if i >= 200 && i <= 299 { let slot_idx = (i - 200) / 10; let ab_idx = (i - 200) % 10; let areas = ["左", "センター", "右"]; let area_name = if slot_idx < 3 { areas[slot_idx] } else { "Unknown" }; if let Some(cid) = curr_p.stage.get(slot_idx).cloned() { if cid >= 0 { let c = serialize_card(cid, db, edb, true); meta = json!({ "id": i, "type": "ABILITY", "location": "stage", "area_idx": slot_idx, "source_card_id": cid, "ability_idx": ab_idx, "name": format!("【起動】{}: アビリティ ({})", c["name"], area_name), "img": c["img"], "text": c["text"] }); } } } else if i >= 2000 && i <= 2999 { let adj = i - 2000; let d_idx = adj / 10; let ab_idx = adj % 10; if let Some(&cid) = curr_p.discard.get(d_idx) { let c = serialize_card(cid as i16, db, edb, true); meta = json!({ "id": i, "type": "ABILITY", "location": "discard", "discard_idx": d_idx, "ability_idx": ab_idx, "name": format!("【控え召喚】 {}: 効果", c["name"]), "img": c["img"], "text": c["text"] }); } } else if i >= 300 && i <= 499 { // Mulligan 300-359, LiveSet 400-459. Hand Select 500-509 is handled above. let (h_idx, a_type) = if i < 360 { (i - 300, "MULLIGAN") } else { (i - 400, "LIVE_SET") }; if let Some(&cid) = curr_p.hand.get(h_idx) { let c = serialize_card(cid as i16, db, edb, true); let prefix = if a_type == "MULLIGAN" { "【マリガン】" } else { "【ライブセット】" }; meta = json!({ "id": i, "type": a_type, "hand_idx": h_idx, "name": format!("{} {}を選択", prefix, c["name"]), "img": c["img"], "text": c["text"] }); } } else if i >= 560 && i <= 562 { let area_idx = i - 560; let areas = ["左", "センター", "右"]; let cid = curr_p.stage[area_idx]; let name = if cid >= 0 { db.members.get(&(cid as u16)).map(|m| m.name.as_str()).unwrap_or("メンバー") } else { "空エリア" }; let mut desc = "選択"; if !pending_choice.is_null() { let p_type = pending_choice["type"].as_str().unwrap_or(""); if p_type == "MOVE_MEMBER" { desc = "移動元"; } else if p_type == "TAP_MEMBER" { desc = "ウェイト"; } else if p_type == "PLAY_MEMBER_FROM_HAND" || p_type == "PLAY_MEMBER_FROM_DISCARD" { desc = "に置く"; } } meta = json!({ "id": i, "type": "SELECT_STAGE", "area_idx": area_idx, "name": format!("【ステージ選択】 {}: {}を{}", areas[area_idx], name, desc) }); } else if i >= 570 && i <= 579 { let mut mode_label = format!("モード {}", i - 570 + 1); if !pending_choice.is_null() { if let Some(opts) = pending_choice["params"]["options"].as_array() { if let Some(val) = opts.get(i - 570) { mode_label = val.as_str().unwrap_or(&mode_label).to_string(); } } } meta = json!({ "id": i, "type": "SELECT_MODE", "index": i - 570, "name": format!("【モード選択】 {}", mode_label) }); } else if i >= 590 && i <= 599 { meta = json!({ "id": i, "type": "ABILITY_ORDER", "index": i - 590, "name": format!("【能力解決】 ({})", i - 590 + 1) }); } else if i >= 600 && i <= 602 { let target_p = &gs.players[1 - gs.current_player as usize]; let cid = target_p.stage[i - 600]; let areas = ["左", "センター", "右"]; if cid >= 0 { let c = serialize_card(cid, db, edb, true); meta = json!({ "id": i, "type": "TARGET_OPPONENT", "index": i - 600, "name": format!("【ターゲット】 相手のステージ ({}: {})", areas[i-600], c["name"]), "img": c["img"] }); } else { meta = json!({ "id": i, "type": "TARGET_OPPONENT", "index": i - 600, "name": format!("【ターゲット】 相手のステージ ({}: 空エリア)", areas[i-600]) }); } } else if i >= 820 && i <= 822 { let area_idx = i - 820; let cid = curr_p.live_zone[area_idx]; let name = if cid >= 0 { db.lives.get(&(cid as u16)).map(|l| l.name.as_str()).unwrap_or("Live") } else { "None" }; let areas = ["左", "センター", "右"]; meta = json!({ "id": i, "type": "SELECT_LIVE", "area_idx": area_idx, "name": format!("【ライブ選択】 {}: {}", areas[area_idx], name) }); } else if i >= 1000 && i <= 1999 { let adj = i - 1000; let h_idx = adj / 100; let s_idx = (adj % 100) / 10; let c_idx = adj % 10; let mut choice_label = format!("選択 {}", c_idx + 1); // Resolve label from pending choice params (Order Deck, Color, Mode, List) if !pending_choice.is_null() { let p_type = pending_choice["type"].as_str().unwrap_or(""); let params = &pending_choice["params"]; if p_type == "ORDER_DECK" { if let Some(cards_v) = params["cards"].as_array() { if let Some(cid_val) = cards_v.get(c_idx) { let cid = cid_val.as_i64().unwrap_or(-1) as i16; let c = serialize_card(cid, db, edb, true); choice_label = format!("{}をトップへ", c["name"]); } else { choice_label = "【確定】".to_string(); } } } else if p_type == "COLOR_SELECT" { let colors = ["赤", "青", "緑", "黄", "紫", "ピンク"]; if c_idx < 6 { choice_label = format!("{}を選択", colors[c_idx]); } } else if p_type == "SELECT_MODE" { if let Some(opts) = params["options"].as_array() { if let Some(val) = opts.get(c_idx) { choice_label = val.as_str().unwrap_or(&choice_label).to_string(); } } } else if p_type == "SELECT_FROM_LIST" { if let Some(cards_v) = params["cards"].as_array() { if let Some(cid_val) = cards_v.get(c_idx) { let cid = cid_val.as_i64().unwrap_or(-1) as i16; let c = serialize_card(cid, db, edb, true); choice_label = format!("{}を選択", c["name"]); } } } } if let Some(&cid) = curr_p.hand.get(h_idx) { let c = serialize_card(cid as i16, db, edb, true); let cost = gs.get_member_cost(gs.current_player as usize, cid as i32, s_idx as i32, db); meta = json!({ "id": i, "type": "PLAY", "hand_idx": h_idx, "area_idx": s_idx, "choice_idx": c_idx, "name": format!("【登場選択】 {} ({})", c["name"], choice_label), "img": c["img"], "cost": cost, "text": c["text"] }); } } else if i >= 550 && i <= 849 { let adj = i - 550; let s_idx = adj / 100; // let ab_idx = (adj % 100) / 10; // Unused in display logic generally let c_idx = adj % 10; let areas = ["左", "中", "右"]; let area_name = if s_idx < 3 { areas[s_idx] } else { "Slot" }; let mut card_name = "メンバー".to_string(); if let Some(cid) = curr_p.stage.get(s_idx).cloned() { if cid >= 0 { if let Some(m) = db.members.get(&(cid as u16)) { card_name = m.name.clone(); } } } let mut choice_label = format!("選択 {}", c_idx); if !pending_choice.is_null() { let p_type = pending_choice["type"].as_str().unwrap_or(""); let params = &pending_choice["params"]; if p_type == "ORDER_DECK" { if let Some(cards_v) = params["cards"].as_array() { if let Some(cid_val) = cards_v.get(c_idx) { let cid = cid_val.as_i64().unwrap_or(-1) as i16; let c = serialize_card(cid, db, edb,true); choice_label = format!("トップへ: {}", c["name"]); } else { choice_label = "【確定】".to_string(); } } } else if p_type == "COLOR_SELECT" { let colors = ["赤", "青", "緑", "黄", "紫", "ピンク"]; if c_idx < 6 { choice_label = format!("色: {}", colors[c_idx]); } } else if p_type == "SELECT_MODE" { if let Some(opts) = params["options"].as_array() { if let Some(val) = opts.get(c_idx) { choice_label = val.as_str().unwrap_or(&choice_label).to_string(); } } } else if p_type == "SELECT_FROM_LIST" { if let Some(cards_v) = params["cards"].as_array() { if let Some(cid_val) = cards_v.get(c_idx) { let cid = cid_val.as_i64().unwrap_or(-1) as i16; let c = serialize_card(cid,db,edb,true); choice_label = format!("選択: {}", c["name"]); } } } } meta = json!({ "id": i, "type": "CHOICE", "area_idx": s_idx, "choice_idx": c_idx, "name": format!("[{}] {} ({})", card_name, choice_label, area_name) }); } else if i >= 900 && i <= 902 { let area_idx = i - 900; let cid = curr_p.live_zone[area_idx]; let areas = ["左", "センター", "右"]; if cid >= 0 { let c = serialize_card(cid, db, edb, curr_p.live_zone_revealed[area_idx] || viewer_idx == gs.current_player as usize); meta = json!({ "id": i, "type": "PERFORMANCE", "area_idx": area_idx, "name": format!("【パフォーマンス】 {}: {}", areas[area_idx], c["name"]), "img": c["img"] }); } } enriched_actions.push(meta); } } } json!({ "mode": mode, "active_player": gs.current_player, "current_player": gs.current_player, "phase": gs.phase as i8, "turn": gs.turn, "players": [p0, p1], "legal_actions": enriched_actions, "pending_choice": pending_choice }) } // Legacy handler for test deck list fn handle_get_test_deck(request: Request, query: Option<&str>, _db: &CardDatabase) { let mut available = Vec::new(); for file in Decks::iter() { if file.ends_with(".txt") && !file.starts_with("verify") { available.push(file.replace(".txt", "")); } } let mut deck_name = String::new(); if let Some(q) = query { for pair in q.split('&') { if let Some((k, v)) = pair.split_once('=') { if k == "deck" { deck_name = v.to_string(); } } } } if !deck_name.is_empty() { let f_name = format!("{}.txt", deck_name); if let Some(f) = Decks::get(&f_name) { let s = std::str::from_utf8(f.data.as_ref()).unwrap_or(""); let mut codes = Vec::new(); for line in s.lines() { if let Some((c, _)) = line.split_once('x') { codes.push(c.trim().to_string()); } } let main_codes: Vec = codes.iter().filter(|c| !c.contains("ENERGY")).cloned().collect(); // Don't send invalid codes. Let resolve_deck fill defaults. let energy_codes: Vec = Vec::new(); let json = json!({ "success": true, "main_deck": main_codes, "energy_deck": energy_codes, "available_decks": available }); let _ = request.respond(Response::from_string(json.to_string())); } else { let _ = request.respond(Response::from_string(json!({"success":false,"error":"Not found", "available_decks": available}).to_string())); } } else { let json = json!({ "available_decks": available, "success": true }); let _ = request.respond(Response::from_string(json.to_string())); } }