trioskosmos's picture
Upload folder using huggingface_hub
3167e93 verified
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<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
#[derive(Deserialize)]
struct CreateRoomReq {
mode: Option<String>,
public: Option<bool>,
decks: Option<HashMap<String, DeckConfig>>, // Keys can be "0" or "1"
}
#[derive(Deserialize, Clone)]
struct DeckConfig {
main: Vec<String>,
energy: Vec<String>,
#[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<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()));
}
}