Spaces:
Running
Running
| 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}; | |
| struct Assets; | |
| struct Decks; | |
| // --- Game Server State --- | |
| struct Room { | |
| _id: String, | |
| state: GameState, | |
| players: HashMap<String, usize>, // 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<HashMap<String, Room>>, | |
| card_db: CardDatabase, | |
| energy_db: serde_json::Value, | |
| } | |
| // API Request Structures | |
| struct CreateRoomReq { | |
| mode: Option<String>, | |
| public: Option<bool>, | |
| decks: Option<HashMap<String, DeckConfig>>, // Keys can be "0" or "1" | |
| } | |
| struct DeckConfig { | |
| main: Vec<String>, | |
| energy: Vec<String>, | |
| _deck_type: String, | |
| } | |
| struct JoinRoomReq { | |
| room_id: String, | |
| } | |
| struct ActionReq { | |
| action_id: i32, | |
| } | |
| struct UploadDeckReq { | |
| player: usize, | |
| content: String, // Raw deck file content | |
| } | |
| struct AiSuggestReq { | |
| sims: usize, | |
| } | |
| struct SetDeckReq { | |
| player: usize, | |
| deck: Vec<String>, | |
| } | |
| struct ParsedDecks { | |
| members: Vec<u16>, | |
| lives: Vec<u16>, | |
| energy: Vec<u16>, | |
| } | |
| 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<AppState>) { | |
| 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::<CreateRoomReq>(&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::<JoinRoomReq>(&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<usize> = 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::<ActionReq>(&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::<UploadDeckReq>(&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::<SetDeckReq>(&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::<AiSuggestReq>(&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<serde_json::Value> = 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::<u16>().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::<u16>() { | |
| // 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::<u16>() { | |
| // 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<String, ()> { | |
| 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<T: serde::de::DeserializeOwned>(request: &mut Request) -> Result<T, ()> { | |
| 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<String> { | |
| 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<char> = "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<Value> = 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<Value> = p.discard.iter().map(|&cid| serialize_card(cid as i16, db, edb, true)).collect(); | |
| let success: Vec<Value> = 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::<Vec<_>>(), | |
| "looked_cards": p.looked_cards.iter().map(|&cid| serialize_card(cid as i16, db, edb, true)).collect::<Vec<_>>(), | |
| }) | |
| } | |
| 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<String> = codes.iter().filter(|c| !c.contains("ENERGY")).cloned().collect(); | |
| // Don't send invalid codes. Let resolve_deck fill defaults. | |
| let energy_codes: Vec<String> = 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())); | |
| } | |
| } | |