Skip to main content

self_hosted_node/engine_backend/
java_backend.rs

1#![allow(dead_code)]
2
3#[cfg(forge_backend)]
4use std::collections::HashMap;
5use std::env;
6#[cfg(feature = "java-forge")]
7use std::io::{BufRead, BufReader, BufWriter, Write};
8use std::path::{Path, PathBuf};
9#[cfg(feature = "java-forge")]
10use std::process::{Child, ChildStdin, Command, Stdio};
11use std::sync::atomic::AtomicBool;
12use std::sync::mpsc as std_mpsc;
13#[cfg(feature = "java-forge")]
14use std::sync::mpsc::RecvTimeoutError;
15#[cfg(forge_backend)]
16use std::sync::mpsc::TryRecvError;
17use std::sync::Arc;
18#[cfg(feature = "java-forge")]
19use std::sync::Mutex;
20#[cfg(forge_backend)]
21use std::time::Duration;
22#[cfg(forge_backend)]
23use std::time::Instant;
24
25use manabrew_protocol::deck_dto::{Deck, DeckCardIdentity};
26
27use crate::config::DeckSelection;
28#[cfg(feature = "java-forge")]
29use manabot::{BotAgent, SimpleAi};
30#[cfg(forge_backend)]
31use manabrew_agent_interface::game_view_dto::GameViewDto;
32use manabrew_agent_interface::prompt::{AgentMessage, ClientToServerMessage};
33#[cfg(forge_backend)]
34use manabrew_agent_interface::prompt::{
35    AgentPrompt, ChooseActionOutput, DiceRolledOutput, DirectiveInput, GameOverInput, PromptInput,
36    PromptOutput, ProtocolError, ProtocolErrorCode, ResponseViolation, StateUpdate,
37};
38#[cfg(feature = "java-forge")]
39use manabrew_agent_interface::prompt::{MulliganOutput, MulliganPutBackOutput};
40use serde::Serialize;
41#[cfg(feature = "java-forge")]
42use serde_json::json;
43#[cfg(feature = "java-forge")]
44use serde_json::Value;
45#[cfg(forge_backend)]
46use tracing::warn;
47#[cfg(forge_backend)]
48use tracing::{debug, info};
49
50use super::HostedGameOver;
51use crate::config::workspace_root;
52
53pub fn unsupported_message() -> &'static str {
54    "hosted java-forge backend is unavailable; rebuild self-hosted-node with --features java-forge"
55}
56
57#[cfg(feature = "java-forge")]
58pub fn run_smoke_game(max_prompts: usize) -> Result<(), String> {
59    let config = JavaRuntimeConfig::from_env();
60    let assets_dir = config.assets_dir.to_string_lossy().to_string();
61    let bridge = SubprocessBridge::spawn(&config)?;
62    let mut session = JavaForgeSession::new(bridge);
63    session.initialize(&assets_dir)?;
64
65    let deck_a = smoke_deck("Mountain", "Lightning Bolt");
66    let deck_b = smoke_deck("Forest", "Grizzly Bears");
67    let request = StartGameRequest::new(
68        "self-hosted-java-smoke".to_string(),
69        String::new(),
70        20,
71        42,
72        vec![
73            PlayerConfig::new("Smoke A".to_string(), &deck_a, Vec::new()),
74            PlayerConfig::new("Smoke B".to_string(), &deck_b, Vec::new()),
75        ],
76    );
77    let session_id = session.start_game(&request)?;
78    info!(session_id, "java-forge smoke session started");
79
80    let mut prompts_seen = 0usize;
81    while prompts_seen < max_prompts {
82        let Some(prompt_json) = wait_for_prompt(&mut session, 600)? else {
83            session.end_game()?;
84            return Err("timed out waiting for java-forge smoke prompt".to_string());
85        };
86        let prompt: AgentPrompt = serde_json::from_str(&prompt_json)
87            .map_err(|err| format!("failed to parse java-forge smoke prompt: {err}"))?;
88        let player = player_index(&prompt.deciding_player_id);
89        info!(prompts_seen, player, "java-forge smoke prompt");
90        let pass = PromptOutput::ChooseAction(ChooseActionOutput::Pass {
91            until: None,
92            exhaust_stack: false,
93        });
94        session.submit_action(&serde_json::to_string(&pass).map_err(|err| err.to_string())?)?;
95        prompts_seen += 1;
96    }
97
98    let snapshot_json = session.get_snapshot(Some(0))?;
99    let snapshot: Value = serde_json::from_str(&snapshot_json)
100        .map_err(|err| format!("failed to parse java-forge smoke snapshot: {err}"))?;
101    info!(
102        turn = snapshot
103            .get("turn")
104            .and_then(|value| value.as_i64())
105            .unwrap_or_default(),
106        phase = snapshot
107            .get("phase")
108            .and_then(|value| value.as_str())
109            .unwrap_or("<missing>"),
110        "java-forge smoke snapshot"
111    );
112    session.end_game()?;
113    Ok(())
114}
115
116#[cfg(not(feature = "java-forge"))]
117pub fn run_smoke_game(_max_prompts: usize) -> Result<(), String> {
118    Err(
119        "java-forge smoke requires building self-hosted-node with --features java-forge"
120            .to_string(),
121    )
122}
123
124// Unlike run_smoke_game, which spawns the JAR in a subprocess, this drives the
125// GraalVM native library in-process: isolate creation, forge_initialize (which
126// loads the card database) and a real game start. CI runs it after restoring
127// forge-harness/native/build from cache, where nothing else would notice a
128// stale or broken libforgeharness.
129#[cfg(feature = "graal-forge")]
130pub fn run_graal_smoke() -> Result<(), String> {
131    let config = JavaRuntimeConfig::from_env();
132    let engine = GraalEngineHandle::create(&config.assets_dir)?;
133
134    let deck_a = smoke_deck("Mountain", "Lightning Bolt");
135    let deck_b = smoke_deck("Forest", "Grizzly Bears");
136    let request = StartGameRequest::new(
137        "self-hosted-graal-smoke".to_string(),
138        String::new(),
139        20,
140        42,
141        vec![
142            PlayerConfig::new("Smoke A".to_string(), &deck_a, Vec::new()),
143            PlayerConfig::new("Smoke B".to_string(), &deck_b, Vec::new()),
144        ],
145    );
146
147    let session_id = engine.start_game(&request.to_json().map_err(|err| err.to_string())?)?;
148    info!(session_id, "graal-forge smoke session started");
149    engine.end_game(&session_id)?;
150    Ok(())
151}
152
153#[cfg(not(feature = "graal-forge"))]
154pub fn run_graal_smoke() -> Result<(), String> {
155    Err(
156        "graal-forge smoke requires building self-hosted-node with --features graal-forge"
157            .to_string(),
158    )
159}
160
161#[cfg(feature = "java-forge")]
162pub fn run_scenario(name: &str, max_prompts: usize) -> Result<(), String> {
163    let scenario = JavaScenario::from_name(name)?;
164    let config = JavaRuntimeConfig::from_env();
165    let assets_dir = config.assets_dir.to_string_lossy().to_string();
166    let bridge = SubprocessBridge::spawn(&config)?;
167    let mut session = JavaForgeSession::new(bridge);
168    session.initialize(&assets_dir)?;
169
170    let request = StartGameRequest::new(
171        format!("self-hosted-java-scenario-{}", scenario.name()),
172        String::new(),
173        20,
174        42,
175        vec![
176            PlayerConfig::new(
177                "Scenario A".to_string(),
178                &scenario_deck("Swamp"),
179                Vec::new(),
180            ),
181            PlayerConfig::new(
182                "Scenario B".to_string(),
183                &scenario_deck("Forest"),
184                Vec::new(),
185            ),
186        ],
187    );
188    let session_id = session.start_game(&request)?;
189    info!(
190        session_id,
191        scenario = scenario.name(),
192        "java-forge scenario started"
193    );
194
195    let result = run_scenario_loop(&mut session, scenario, max_prompts);
196    let end_result = session.end_game();
197    result.and(end_result)
198}
199
200#[cfg(not(feature = "java-forge"))]
201pub fn run_scenario(_name: &str, _max_prompts: usize) -> Result<(), String> {
202    Err(
203        "java-forge scenarios require building self-hosted-node with --features java-forge"
204            .to_string(),
205    )
206}
207
208#[cfg(feature = "java-forge")]
209pub fn run_self_play(
210    seats: &[DeckSelection],
211    starting_life: i32,
212    seed: u64,
213    max_prompts: usize,
214    games: usize,
215) -> Result<(), String> {
216    let config = JavaRuntimeConfig::from_env();
217    let assets_dir = config.assets_dir.to_string_lossy().to_string();
218    let bridge = SubprocessBridge::spawn(&config)?;
219    let mut session = JavaForgeSession::new(bridge);
220    session.initialize(&assets_dir)?;
221
222    let mut players = Vec::with_capacity(seats.len());
223    for (i, seat) in seats.iter().enumerate() {
224        let identities = deck_card_identities(&seat.deck);
225        players.push(PlayerConfig::new(
226            format!("Self-Play {}", i + 1),
227            &identities,
228            commander_names_for_java(&seat.deck, seat.commander_name.as_deref()),
229        ));
230    }
231
232    for game_index in 0..games.max(1) {
233        let request = StartGameRequest::new(
234            format!("self-hosted-java-self-play-{game_index}"),
235            String::new(),
236            starting_life,
237            seed.wrapping_add(game_index as u64),
238            players.clone(),
239        );
240        let session_id = session.start_game(&request)?;
241        info!(
242            session_id,
243            game_index,
244            games,
245            players = seats.len(),
246            starting_life,
247            max_prompts,
248            "java-forge self-play game started"
249        );
250        let result = run_self_play_loop(&mut session, max_prompts);
251        let end_result = session.end_game();
252        result.and(end_result)?;
253    }
254    Ok(())
255}
256
257#[cfg(not(feature = "java-forge"))]
258pub fn run_self_play(
259    _seats: &[DeckSelection],
260    _starting_life: i32,
261    _seed: u64,
262    _max_prompts: usize,
263    _games: usize,
264) -> Result<(), String> {
265    Err(
266        "java-forge self-play requires building self-hosted-node with --features java-forge"
267            .to_string(),
268    )
269}
270
271#[cfg(feature = "java-forge")]
272type SharedBridge = Arc<Mutex<SubprocessBridge>>;
273
274#[cfg(feature = "java-forge")]
275struct PoolSlot {
276    bridge: SharedBridge,
277    active: usize,
278}
279
280#[cfg(feature = "java-forge")]
281pub struct JavaEnginePool {
282    config: JavaRuntimeConfig,
283    max_sessions: usize,
284    sessions_per_process: usize,
285    slots: Mutex<Vec<PoolSlot>>,
286    in_use: Mutex<HashMap<String, SharedBridge>>,
287}
288
289#[cfg(feature = "java-forge")]
290#[derive(Clone)]
291pub struct JavaEngineHandle {
292    pool: Arc<JavaEnginePool>,
293}
294
295#[cfg(feature = "java-forge")]
296impl JavaEnginePool {
297    pub fn start(
298        config: &JavaRuntimeConfig,
299        max_sessions: usize,
300        sessions_per_process: usize,
301    ) -> Result<Arc<Self>, String> {
302        let max_sessions = max_sessions.max(1);
303        let sessions_per_process = sessions_per_process.max(1);
304        let processes = max_sessions.div_ceil(sessions_per_process);
305        let mut slots = Vec::with_capacity(processes);
306        for slot in 0..processes {
307            info!(
308                slot,
309                processes, sessions_per_process, "pre-warming java subprocess"
310            );
311            let bridge = SubprocessBridge::spawn(config)?;
312            slots.push(PoolSlot {
313                bridge: Arc::new(Mutex::new(bridge)),
314                active: 0,
315            });
316        }
317        Ok(Arc::new(Self {
318            config: config.clone(),
319            max_sessions,
320            sessions_per_process,
321            slots: Mutex::new(slots),
322            in_use: Mutex::new(HashMap::new()),
323        }))
324    }
325
326    pub fn handle(self: &Arc<Self>) -> JavaEngineHandle {
327        JavaEngineHandle {
328            pool: Arc::clone(self),
329        }
330    }
331}
332
333#[cfg(feature = "java-forge")]
334impl Drop for JavaEnginePool {
335    fn drop(&mut self) {
336        let slots = self.slots.get_mut().map(std::mem::take).unwrap_or_default();
337        for slot in slots {
338            if let Ok(mutex) = Arc::try_unwrap(slot.bridge) {
339                if let Ok(inner) = mutex.into_inner() {
340                    inner.shutdown();
341                }
342            }
343        }
344    }
345}
346
347#[cfg(feature = "java-forge")]
348impl JavaEnginePool {
349    fn acquire(&self) -> Result<SharedBridge, String> {
350        let deadline = Instant::now() + Duration::from_secs(60);
351        loop {
352            let claimed = {
353                let mut slots = self
354                    .slots
355                    .lock()
356                    .map_err(|_| "java engine slots poisoned".to_string())?;
357                let mut claimed = None;
358                for slot in slots.iter_mut() {
359                    if slot.active < self.sessions_per_process {
360                        slot.active += 1;
361                        claimed = Some(Arc::clone(&slot.bridge));
362                        break;
363                    }
364                }
365                claimed
366            };
367            if let Some(bridge) = claimed {
368                let alive = bridge
369                    .lock()
370                    .ok()
371                    .map(|mut guard| guard.is_alive())
372                    .unwrap_or(false);
373                if alive {
374                    return Ok(bridge);
375                }
376                warn!("discarding dead java subprocess from pool");
377                self.replace_slot(&bridge);
378                continue;
379            }
380            if Instant::now() >= deadline {
381                return Err(format!(
382                    "java engine pool exhausted (max_sessions={}); no free session slot after 60s",
383                    self.max_sessions
384                ));
385            }
386            std::thread::sleep(Duration::from_millis(50));
387        }
388    }
389
390    fn release(&self, bridge: SharedBridge) {
391        let now_idle = {
392            let mut slots = match self.slots.lock() {
393                Ok(slots) => slots,
394                Err(_) => return,
395            };
396            match slots.iter_mut().find(|s| Arc::ptr_eq(&s.bridge, &bridge)) {
397                Some(slot) => {
398                    slot.active = slot.active.saturating_sub(1);
399                    slot.active == 0
400                }
401                None => return,
402            }
403        };
404        if !now_idle {
405            return;
406        }
407        let healthy = {
408            let mut guard = match bridge.lock() {
409                Ok(guard) => guard,
410                Err(_) => return,
411            };
412            guard.is_alive() && guard.reset().is_ok()
413        };
414        if !healthy {
415            warn!("java subprocess unhealthy at idle; respawning");
416            self.replace_slot(&bridge);
417        }
418    }
419
420    fn replace_slot(&self, dead: &SharedBridge) {
421        let Ok(mut slots) = self.slots.lock() else {
422            return;
423        };
424        let Some(index) = slots.iter().position(|s| Arc::ptr_eq(&s.bridge, dead)) else {
425            return;
426        };
427        match SubprocessBridge::spawn(&self.config) {
428            Ok(replacement) => {
429                slots[index] = PoolSlot {
430                    bridge: Arc::new(Mutex::new(replacement)),
431                    active: 0,
432                };
433            }
434            Err(error) => {
435                warn!(%error, "failed to respawn java subprocess; retiring pool slot");
436                slots.remove(index);
437            }
438        }
439    }
440}
441
442#[cfg(feature = "java-forge")]
443impl JavaEngineHandle {
444    fn bridge_for(&self, session_id: &str) -> Result<SharedBridge, String> {
445        let in_use = self
446            .pool
447            .in_use
448            .lock()
449            .map_err(|_| "java engine in_use map poisoned".to_string())?;
450        in_use
451            .get(session_id)
452            .cloned()
453            .ok_or_else(|| format!("unknown java session: {session_id}"))
454    }
455
456    pub fn start_game(&self, request_json: &str) -> Result<String, String> {
457        let bridge = self.pool.acquire()?;
458        let response = {
459            let mut guard = bridge
460                .lock()
461                .map_err(|_| "java subprocess mutex poisoned".to_string())?;
462            guard.start_game_json(request_json)
463        };
464        let response = match response {
465            Ok(response) => response,
466            Err(error) => {
467                self.pool.release(bridge);
468                return Err(error);
469            }
470        };
471        let parsed: StartGameResponse = match serde_json::from_str(&response) {
472            Ok(parsed) => parsed,
473            Err(error) => {
474                self.pool.release(bridge);
475                return Err(format!("malformed startGame response: {error}"));
476            }
477        };
478        let session_id = parsed.session_id.clone();
479        let displaced = {
480            let mut in_use = self
481                .pool
482                .in_use
483                .lock()
484                .map_err(|_| "java engine in_use map poisoned".to_string())?;
485            in_use.insert(session_id.clone(), bridge)
486        };
487        if let Some(displaced) = displaced {
488            warn!(
489                session_id,
490                "session_id collision; releasing displaced java subprocess"
491            );
492            self.pool.release(displaced);
493        }
494        Ok(session_id)
495    }
496
497    pub fn submit_action(&self, session_id: &str, action_json: &str) -> Result<String, String> {
498        let bridge = self.bridge_for(session_id)?;
499        let mutex_started = Instant::now();
500        let mut guard = bridge
501            .lock()
502            .map_err(|_| "java subprocess mutex poisoned".to_string())?;
503        crate::metrics::record_forge_decision_stage("bridge_mutex", mutex_started.elapsed());
504        let call_started = Instant::now();
505        let result = guard.submit_action(session_id, action_json);
506        crate::metrics::record_forge_decision_stage("submit_action", call_started.elapsed());
507        result
508    }
509
510    pub fn get_prompt(
511        &self,
512        session_id: &str,
513        player_index: usize,
514    ) -> Result<Option<String>, String> {
515        let bridge = self.bridge_for(session_id)?;
516        let mut guard = bridge
517            .lock()
518            .map_err(|_| "java subprocess mutex poisoned".to_string())?;
519        guard.get_prompt(session_id, player_index)
520    }
521
522    pub fn is_game_over(&self, session_id: &str) -> Result<bool, String> {
523        let bridge = self.bridge_for(session_id)?;
524        let mut guard = bridge
525            .lock()
526            .map_err(|_| "java subprocess mutex poisoned".to_string())?;
527        guard.is_game_over(session_id)
528    }
529
530    pub fn get_snapshot(&self, session_id: &str, viewer: Option<usize>) -> Result<String, String> {
531        let bridge = self.bridge_for(session_id)?;
532        let mut guard = bridge
533            .lock()
534            .map_err(|_| "java subprocess mutex poisoned".to_string())?;
535        guard.get_snapshot(session_id, viewer)
536    }
537
538    pub fn end_game(&self, session_id: &str) -> Result<(), String> {
539        let bridge = {
540            let mut in_use = self
541                .pool
542                .in_use
543                .lock()
544                .map_err(|_| "java engine in_use map poisoned".to_string())?;
545            in_use.remove(session_id)
546        };
547        let Some(bridge) = bridge else {
548            return Ok(());
549        };
550        let result = {
551            let mut guard = bridge
552                .lock()
553                .map_err(|_| "java subprocess mutex poisoned".to_string())?;
554            guard.end_game(session_id)
555        };
556        self.pool.release(bridge);
557        result
558    }
559
560    pub fn abort_game(&self, session_id: &str) -> Result<(), String> {
561        let bridge = {
562            let mut in_use = self
563                .pool
564                .in_use
565                .lock()
566                .map_err(|_| "java engine in_use map poisoned".to_string())?;
567            in_use.remove(session_id)
568        };
569        let Some(bridge) = bridge else {
570            return Ok(());
571        };
572        let result = {
573            let mut guard = bridge
574                .lock()
575                .map_err(|_| "java subprocess mutex poisoned".to_string())?;
576            guard.abort_game(session_id)
577        };
578        self.pool.release(bridge);
579        result
580    }
581}
582
583#[cfg(feature = "java-forge")]
584static JAVA_ENGINE: std::sync::OnceLock<Arc<JavaEnginePool>> = std::sync::OnceLock::new();
585
586#[cfg(feature = "java-forge")]
587pub fn init_engine() -> Result<(), String> {
588    if JAVA_ENGINE.get().is_some() {
589        return Ok(());
590    }
591    let config = JavaRuntimeConfig::from_env();
592    // SELF_HOSTED_NODE_MAX_GAMES is the concurrent-session ceiling for this node.
593    // SELF_HOSTED_NODE_GAMES_PER_JVM multiplexes that many sessions into each
594    // subprocess (the engine is concurrency-safe since the endstep patches), so
595    // the pool spawns ceil(max_games / games_per_jvm) processes. Default 1 keeps
596    // the historical one-subprocess-per-game shape.
597    let max_sessions = env::var("SELF_HOSTED_NODE_MAX_GAMES")
598        .ok()
599        .and_then(|value| value.parse::<usize>().ok())
600        .filter(|n| *n >= 1)
601        .unwrap_or(1);
602    let sessions_per_process = env::var("SELF_HOSTED_NODE_GAMES_PER_JVM")
603        .ok()
604        .and_then(|value| value.parse::<usize>().ok())
605        .filter(|n| *n >= 1)
606        .unwrap_or(1);
607    let pool = JavaEnginePool::start(&config, max_sessions, sessions_per_process)?;
608    JAVA_ENGINE
609        .set(pool)
610        .map_err(|_| "java engine already initialized".to_string())
611}
612
613#[cfg(all(feature = "graal-forge", not(feature = "java-forge")))]
614pub fn init_engine() -> Result<(), String> {
615    // Loading the card database into the isolate takes tens of seconds on a
616    // small box. Pay it here, before any room is advertised, rather than
617    // leaving it for whoever starts the first game.
618    if shared_isolate_enabled() {
619        drop(GraalEngineHandle::create(
620            &JavaRuntimeConfig::from_env().assets_dir,
621        )?);
622    }
623    Ok(())
624}
625
626#[cfg(not(forge_backend))]
627pub fn init_engine() -> Result<(), String> {
628    Err(
629        "forge engine requires building self-hosted-node with --features java-forge or graal-forge"
630            .to_string(),
631    )
632}
633
634#[cfg(feature = "java-forge")]
635fn engine_handle() -> Result<JavaEngineHandle, String> {
636    JAVA_ENGINE
637        .get()
638        .map(JavaEnginePool::handle)
639        .ok_or_else(|| "java engine is not initialized".to_string())
640}
641
642#[cfg(feature = "java-forge")]
643type ForgeEngine = JavaEngineHandle;
644
645#[cfg(feature = "java-forge")]
646fn obtain_engine() -> Result<ForgeEngine, String> {
647    engine_handle()
648}
649
650#[cfg(all(feature = "graal-forge", not(feature = "java-forge")))]
651type ForgeEngine = GraalEngineHandle;
652
653#[cfg(all(feature = "graal-forge", not(feature = "java-forge")))]
654fn obtain_engine() -> Result<ForgeEngine, String> {
655    GraalEngineHandle::create(&JavaRuntimeConfig::from_env().assets_dir)
656}
657
658#[cfg(feature = "graal-forge")]
659mod graal_ffi {
660    use std::os::raw::{c_char, c_int};
661
662    #[allow(non_camel_case_types)]
663    pub type graal_isolate_t = std::ffi::c_void;
664    #[allow(non_camel_case_types)]
665    pub type graal_isolatethread_t = std::ffi::c_void;
666
667    extern "C" {
668        pub fn graal_create_isolate(
669            params: *mut std::ffi::c_void,
670            isolate: *mut *mut graal_isolate_t,
671            thread: *mut *mut graal_isolatethread_t,
672        ) -> c_int;
673        pub fn graal_tear_down_isolate(thread: *mut graal_isolatethread_t) -> c_int;
674        pub fn graal_attach_thread(
675            isolate: *mut graal_isolate_t,
676            thread: *mut *mut graal_isolatethread_t,
677        ) -> c_int;
678        pub fn graal_detach_thread(thread: *mut graal_isolatethread_t) -> c_int;
679        pub fn forge_initialize(
680            thread: *mut graal_isolatethread_t,
681            assets_dir: *const c_char,
682        ) -> *mut c_char;
683        pub fn forge_start_game(
684            thread: *mut graal_isolatethread_t,
685            request_json: *const c_char,
686        ) -> *mut c_char;
687        pub fn forge_submit_action(
688            thread: *mut graal_isolatethread_t,
689            session_id: *const c_char,
690            action_json: *const c_char,
691        ) -> *mut c_char;
692        pub fn forge_get_prompt(
693            thread: *mut graal_isolatethread_t,
694            session_id: *const c_char,
695            player_index: c_int,
696        ) -> *mut c_char;
697        pub fn forge_get_snapshot(
698            thread: *mut graal_isolatethread_t,
699            session_id: *const c_char,
700            viewer: c_int,
701        ) -> *mut c_char;
702        pub fn forge_get_game_over(
703            thread: *mut graal_isolatethread_t,
704            session_id: *const c_char,
705        ) -> *mut c_char;
706        pub fn forge_end_game(
707            thread: *mut graal_isolatethread_t,
708            session_id: *const c_char,
709        ) -> *mut c_char;
710        pub fn forge_abort_game(
711            thread: *mut graal_isolatethread_t,
712            session_id: *const c_char,
713        ) -> *mut c_char;
714        pub fn forge_free_string(thread: *mut graal_isolatethread_t, ptr: *mut c_char);
715        pub fn forge_dump_heap(
716            thread: *mut graal_isolatethread_t,
717            path: *const c_char,
718            live_objects_only: c_int,
719        ) -> *mut c_char;
720        pub fn forge_gc_stats(thread: *mut graal_isolatethread_t) -> *mut c_char;
721    }
722}
723
724/// Isolate-wide GC and heap counters, as reported by the engine itself.
725/// `collections` and `pause_millis` are -1 when the native image exposes no
726/// collector beans; the heap figures are always present.
727#[cfg(feature = "graal-forge")]
728#[derive(serde::Deserialize)]
729#[serde(rename_all = "camelCase")]
730pub struct GcStats {
731    pub heap_used_bytes: u64,
732    pub heap_total_bytes: u64,
733    pub heap_max_bytes: u64,
734    pub collections: i64,
735    pub pause_millis: i64,
736    /// Keyed by collector name. An incremental collection is a few milliseconds;
737    /// a complete one traces the whole live set, which here is a permanently
738    /// reachable card database, so it costs the same whether it reclaims
739    /// anything or not. The totals above hide that difference.
740    #[serde(default)]
741    pub by_collector: std::collections::HashMap<String, GcCollector>,
742    /// Wall clock the engine was stopped, measured inside the isolate by sleep
743    /// overshoot rather than by the collector, because a G1 image registers no
744    /// collector beans and leaves every field above at zero. Counts scheduler
745    /// preemption as well as collection pauses.
746    #[serde(default)]
747    pub stalled_millis: u64,
748    #[serde(default)]
749    pub max_stall_millis: u64,
750    #[serde(default)]
751    pub long_stalls: u64,
752}
753
754#[cfg(feature = "graal-forge")]
755#[derive(serde::Deserialize)]
756#[serde(rename_all = "camelCase")]
757pub struct GcCollector {
758    pub collections: u64,
759    pub pause_millis: u64,
760}
761
762#[cfg(feature = "graal-forge")]
763#[derive(serde::Deserialize)]
764struct ForgeReply {
765    ok: bool,
766    #[serde(default)]
767    result: String,
768    #[serde(default)]
769    error: Option<String>,
770}
771
772// A GraalVM isolate hosts the in-process Forge engine. The `thread` handle is
773// bound to the hosted-engine thread that created or attached it (isolate
774// threads are not portable), so the handle is Rc/!Send by design. In shared
775// mode one isolate serves all rooms: the first creator initializes Forge, later
776// rooms attach their own thread to it and sessions coexist in the adapter.
777#[cfg(feature = "graal-forge")]
778struct GraalBridge {
779    thread: *mut graal_ffi::graal_isolatethread_t,
780    attached: bool,
781}
782
783#[cfg(feature = "graal-forge")]
784struct SharedIsolate(*mut graal_ffi::graal_isolate_t);
785#[cfg(feature = "graal-forge")]
786unsafe impl Send for SharedIsolate {}
787
788#[cfg(feature = "graal-forge")]
789static SHARED_GRAAL_ISOLATE: std::sync::Mutex<Option<SharedIsolate>> = std::sync::Mutex::new(None);
790
791// One isolate hosts every room's game (safe since the endstep concurrency
792// patches), so the card database loads once per node. Off keeps the historical
793// isolate-per-game shape.
794#[cfg(feature = "graal-forge")]
795fn shared_isolate_enabled() -> bool {
796    let asked = env::var("SELF_HOSTED_NODE_SHARED_ISOLATE")
797        .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
798        .unwrap_or(false);
799    // G1 permits one isolate per process and aborts the process on the second:
800    //   guarantee(SVMIsolateData::_heap_base == nullptr) failed:
801    //   G1 doesn't support multiple isolates at the moment.
802    // The published image is built with G1, and the collector is fixed at build
803    // time and not visible from here, so isolate-per-game cannot be honoured
804    // without risking that abort on the second room. One isolate is correct for
805    // a Serial build too — it is what production has run since the card
806    // database became shared — so this is forced rather than merely defaulted.
807    if !asked {
808        warn!(
809            "isolate-per-game is not available: the engine image is built with G1, \
810             which aborts on a second isolate. Hosting every room in one isolate."
811        );
812    }
813    true
814}
815
816#[cfg(feature = "graal-forge")]
817impl GraalBridge {
818    fn create() -> Result<Self, String> {
819        let mut isolate: *mut graal_ffi::graal_isolate_t = std::ptr::null_mut();
820        let mut thread: *mut graal_ffi::graal_isolatethread_t = std::ptr::null_mut();
821        let rc = unsafe {
822            graal_ffi::graal_create_isolate(std::ptr::null_mut(), &mut isolate, &mut thread)
823        };
824        if rc != 0 {
825            return Err(format!("graal_create_isolate failed with code {rc}"));
826        }
827        Ok(Self {
828            thread,
829            attached: false,
830        })
831    }
832
833    fn create_in_shared_isolate(assets_dir: &Path) -> Result<Self, String> {
834        let mut guard = SHARED_GRAAL_ISOLATE
835            .lock()
836            .map_err(|_| "shared graal isolate poisoned".to_string())?;
837        if let Some(shared) = guard.as_ref() {
838            let mut thread: *mut graal_ffi::graal_isolatethread_t = std::ptr::null_mut();
839            let rc = unsafe { graal_ffi::graal_attach_thread(shared.0, &mut thread) };
840            if rc != 0 {
841                return Err(format!("graal_attach_thread failed with code {rc}"));
842            }
843            return Ok(Self {
844                thread,
845                attached: true,
846            });
847        }
848        let mut isolate: *mut graal_ffi::graal_isolate_t = std::ptr::null_mut();
849        let mut thread: *mut graal_ffi::graal_isolatethread_t = std::ptr::null_mut();
850        let rc = unsafe {
851            graal_ffi::graal_create_isolate(std::ptr::null_mut(), &mut isolate, &mut thread)
852        };
853        if rc != 0 {
854            return Err(format!("graal_create_isolate failed with code {rc}"));
855        }
856        let mut bridge = Self {
857            thread,
858            attached: false,
859        };
860        let assets = cstring(&assets_dir.to_string_lossy())?;
861        bridge.decode(unsafe { graal_ffi::forge_initialize(bridge.thread, assets.as_ptr()) })?;
862        bridge.attached = true;
863        *guard = Some(SharedIsolate(isolate));
864        info!("shared graal isolate initialized");
865        Ok(bridge)
866    }
867
868    fn decode(&self, raw: *mut std::os::raw::c_char) -> Result<String, String> {
869        if raw.is_null() {
870            return Err("forge native lib returned null".to_string());
871        }
872        let envelope = unsafe { std::ffi::CStr::from_ptr(raw) }
873            .to_string_lossy()
874            .into_owned();
875        unsafe { graal_ffi::forge_free_string(self.thread, raw) };
876        let reply: ForgeReply = serde_json::from_str(&envelope)
877            .map_err(|err| format!("malformed forge envelope: {err}"))?;
878        if reply.ok {
879            Ok(reply.result)
880        } else {
881            Err(reply
882                .error
883                .unwrap_or_else(|| "unknown forge error".to_string()))
884        }
885    }
886}
887
888#[cfg(feature = "graal-forge")]
889impl Drop for GraalBridge {
890    fn drop(&mut self) {
891        if self.attached {
892            unsafe { graal_ffi::graal_detach_thread(self.thread) };
893        } else {
894            // Reachable only from the isolate-per-game path, which
895            // `shared_isolate_enabled` forces off. Teardown refuses while any
896            // thread is still attached, and the engine now keeps a permanent
897            // stall-probe thread inside the isolate, so restoring that path
898            // means giving the probe a way to be stopped first.
899            unsafe { graal_ffi::graal_tear_down_isolate(self.thread) };
900        }
901    }
902}
903
904#[cfg(feature = "graal-forge")]
905#[derive(Clone)]
906struct GraalEngineHandle {
907    bridge: std::rc::Rc<GraalBridge>,
908}
909
910#[cfg(feature = "graal-forge")]
911impl GraalEngineHandle {
912    fn create(assets_dir: &Path) -> Result<Self, String> {
913        let shared = shared_isolate_enabled();
914        let bridge = if shared {
915            GraalBridge::create_in_shared_isolate(assets_dir)?
916        } else {
917            let bridge = GraalBridge::create()?;
918            let assets = cstring(&assets_dir.to_string_lossy())?;
919            bridge
920                .decode(unsafe { graal_ffi::forge_initialize(bridge.thread, assets.as_ptr()) })?;
921            bridge
922        };
923        if shared {
924            start_engine_gc_sampler();
925        }
926        Ok(Self {
927            bridge: std::rc::Rc::new(bridge),
928        })
929    }
930
931    fn start_game(&self, request_json: &str) -> Result<String, String> {
932        let request = cstring(request_json)?;
933        let response = self
934            .bridge
935            .decode(unsafe { graal_ffi::forge_start_game(self.bridge.thread, request.as_ptr()) })?;
936        let parsed: StartGameResponse = serde_json::from_str(&response)
937            .map_err(|err| format!("malformed startGame response: {err}"))?;
938        Ok(parsed.session_id)
939    }
940
941    fn submit_action(&self, session_id: &str, action_json: &str) -> Result<String, String> {
942        let session = cstring(session_id)?;
943        let action = cstring(action_json)?;
944        let started = Instant::now();
945        let result = self.bridge.decode(unsafe {
946            graal_ffi::forge_submit_action(self.bridge.thread, session.as_ptr(), action.as_ptr())
947        });
948        crate::metrics::record_forge_decision_stage("submit_action", started.elapsed());
949        result
950    }
951
952    /// Cumulative collections, pause time and heap for the whole isolate. Every
953    /// room on this node shares it, so a collection here stops all of them.
954    fn gc_stats(&self) -> Result<GcStats, String> {
955        gc_stats_via(&self.bridge)
956    }
957
958    fn get_prompt(&self, session_id: &str, player_index: usize) -> Result<Option<String>, String> {
959        let session = cstring(session_id)?;
960        let prompt = self.bridge.decode(unsafe {
961            graal_ffi::forge_get_prompt(
962                self.bridge.thread,
963                session.as_ptr(),
964                player_index as std::os::raw::c_int,
965            )
966        })?;
967        Ok((!prompt.is_empty()).then_some(prompt))
968    }
969
970    fn is_game_over(&self, session_id: &str) -> Result<bool, String> {
971        let session = cstring(session_id)?;
972        let value = self.bridge.decode(unsafe {
973            graal_ffi::forge_get_game_over(self.bridge.thread, session.as_ptr())
974        })?;
975        Ok(value.trim() == "true")
976    }
977
978    fn get_snapshot(&self, session_id: &str, viewer: Option<usize>) -> Result<String, String> {
979        let session = cstring(session_id)?;
980        let viewer = viewer.map_or(-1, |v| v as std::os::raw::c_int);
981        self.bridge.decode(unsafe {
982            graal_ffi::forge_get_snapshot(self.bridge.thread, session.as_ptr(), viewer)
983        })
984    }
985
986    fn end_game(&self, session_id: &str) -> Result<(), String> {
987        let session = cstring(session_id)?;
988        self.bridge
989            .decode(unsafe { graal_ffi::forge_end_game(self.bridge.thread, session.as_ptr()) })
990            .map(|_| ())
991    }
992
993    fn abort_game(&self, session_id: &str) -> Result<(), String> {
994        let session = cstring(session_id)?;
995        self.bridge
996            .decode(unsafe { graal_ffi::forge_abort_game(self.bridge.thread, session.as_ptr()) })
997            .map(|_| ())
998    }
999}
1000
1001/// Write an hprof dump of the shared isolate's heap. Attaches its own isolate
1002/// thread, so it is callable from a signal handler task rather than the engine
1003/// thread that owns the room's bridge.
1004#[cfg(feature = "graal-forge")]
1005pub fn dump_shared_heap(path: &str) -> Result<(), String> {
1006    let isolate = SHARED_GRAAL_ISOLATE
1007        .lock()
1008        .map_err(|_| "shared graal isolate poisoned".to_string())?
1009        .as_ref()
1010        .map(|shared| shared.0)
1011        .ok_or_else(|| "no shared isolate to dump".to_string())?;
1012    let mut thread: *mut graal_ffi::graal_isolatethread_t = std::ptr::null_mut();
1013    let rc = unsafe { graal_ffi::graal_attach_thread(isolate, &mut thread) };
1014    if rc != 0 {
1015        return Err(format!("graal_attach_thread failed with code {rc}"));
1016    }
1017    let bridge = GraalBridge {
1018        thread,
1019        attached: true,
1020    };
1021    let target = cstring(path)?;
1022    bridge
1023        .decode(unsafe { graal_ffi::forge_dump_heap(bridge.thread, target.as_ptr(), 1) })
1024        .map(|_| ())
1025}
1026
1027#[cfg(feature = "graal-forge")]
1028fn cstring(value: &str) -> Result<std::ffi::CString, String> {
1029    std::ffi::CString::new(value).map_err(|_| "string contained interior NUL".to_string())
1030}
1031
1032#[cfg(feature = "java-forge")]
1033pub fn run_concurrent_self_play(
1034    seats: &[DeckSelection],
1035    starting_life: i32,
1036    seed: u64,
1037    max_prompts: usize,
1038    concurrency: usize,
1039) -> Result<(), String> {
1040    let config = JavaRuntimeConfig::from_env();
1041    let games_per_process = env::var("SELF_HOSTED_NODE_GAMES_PER_JVM")
1042        .ok()
1043        .and_then(|value| value.parse::<usize>().ok())
1044        .filter(|n| *n >= 1)
1045        .unwrap_or(1);
1046    let pool = JavaEnginePool::start(&config, concurrency.max(1), games_per_process)?;
1047    info!(
1048        concurrency,
1049        "java-engine started; launching concurrent games"
1050    );
1051
1052    let mut players = Vec::with_capacity(seats.len());
1053    for (i, seat) in seats.iter().enumerate() {
1054        let identities = deck_card_identities(&seat.deck);
1055        players.push(PlayerConfig::new(
1056            format!("Self-Play {}", i + 1),
1057            &identities,
1058            commander_names_for_java(&seat.deck, seat.commander_name.as_deref()),
1059        ));
1060    }
1061
1062    let mut joins = Vec::with_capacity(concurrency.max(1));
1063    for game_index in 0..concurrency.max(1) {
1064        let handle = pool.handle();
1065        let request = StartGameRequest::new(
1066            format!("self-hosted-java-concurrent-{game_index}"),
1067            String::new(),
1068            starting_life,
1069            seed.wrapping_add(game_index as u64),
1070            players.clone(),
1071        );
1072        joins.push(std::thread::spawn(move || -> Result<(), String> {
1073            let request_json = request.to_json().map_err(|error| error.to_string())?;
1074            let session_id = handle.start_game(&request_json)?;
1075            info!(session_id, game_index, "concurrent java game started");
1076            let result = drive_game_via_handle(&handle, &session_id, max_prompts);
1077            let _ = handle.end_game(&session_id);
1078            result
1079        }));
1080    }
1081
1082    let mut outcome = Ok(());
1083    for join in joins {
1084        match join.join() {
1085            Ok(Ok(())) => {}
1086            Ok(Err(error)) => outcome = Err(error),
1087            Err(_) => outcome = Err("concurrent game thread panicked".to_string()),
1088        }
1089    }
1090    outcome
1091}
1092
1093#[cfg(not(feature = "java-forge"))]
1094pub fn run_concurrent_self_play(
1095    _seats: &[DeckSelection],
1096    _starting_life: i32,
1097    _seed: u64,
1098    _max_prompts: usize,
1099    _concurrency: usize,
1100) -> Result<(), String> {
1101    Err(
1102        "java-forge concurrent self-play requires building self-hosted-node with --features java-forge"
1103            .to_string(),
1104    )
1105}
1106
1107#[cfg(all(feature = "graal-forge", not(feature = "java-forge")))]
1108fn report_engine_gc(engine: &ForgeEngine) {
1109    match engine.gc_stats() {
1110        Ok(stats) => publish_engine_gc(&stats),
1111        Err(err) => debug!(%err, "engine gc stats unavailable"),
1112    }
1113}
1114
1115#[cfg(feature = "graal-forge")]
1116fn publish_engine_gc(stats: &GcStats) {
1117    crate::metrics::record_engine_gc(
1118        stats.collections,
1119        stats.pause_millis,
1120        stats.heap_used_bytes,
1121        stats.heap_max_bytes,
1122    );
1123    crate::metrics::record_engine_stall(
1124        stats.stalled_millis,
1125        stats.max_stall_millis,
1126        stats.long_stalls,
1127    );
1128    for (name, per) in &stats.by_collector {
1129        crate::metrics::record_engine_gc_collector(name, per.collections, per.pause_millis);
1130    }
1131}
1132
1133#[cfg(feature = "graal-forge")]
1134fn gc_stats_via(bridge: &GraalBridge) -> Result<GcStats, String> {
1135    let raw = bridge.decode(unsafe { graal_ffi::forge_gc_stats(bridge.thread) })?;
1136    serde_json::from_str(&raw).map_err(|err| format!("malformed gcStats response: {err}"))
1137}
1138
1139#[cfg(feature = "graal-forge")]
1140const ENGINE_GC_SAMPLE_INTERVAL: Duration = Duration::from_secs(15);
1141
1142/// Sampling on the decision path made an idle node and a wedged node look the
1143/// same: both stop reporting. A node that is stalling is exactly the node that
1144/// stops completing decisions, so the sample has to come from a thread that is
1145/// not on that path. It attaches its own isolate thread because the handles the
1146/// rooms hold are bound to their creating thread.
1147#[cfg(feature = "graal-forge")]
1148fn start_engine_gc_sampler() {
1149    static STARTED: std::sync::Once = std::sync::Once::new();
1150    STARTED.call_once(|| {
1151        let spawned = std::thread::Builder::new()
1152            .name("engine-gc-sampler".to_string())
1153            .spawn(|| {
1154                let isolate = match SHARED_GRAAL_ISOLATE.lock() {
1155                    Ok(guard) => guard.as_ref().map(|shared| shared.0),
1156                    Err(_) => None,
1157                };
1158                let Some(isolate) = isolate else {
1159                    return;
1160                };
1161                let mut thread: *mut graal_ffi::graal_isolatethread_t = std::ptr::null_mut();
1162                let rc = unsafe { graal_ffi::graal_attach_thread(isolate, &mut thread) };
1163                if rc != 0 {
1164                    warn!(rc, "gc sampler could not attach to the engine isolate");
1165                    return;
1166                }
1167                let bridge = GraalBridge {
1168                    thread,
1169                    attached: true,
1170                };
1171                loop {
1172                    std::thread::sleep(ENGINE_GC_SAMPLE_INTERVAL);
1173                    match gc_stats_via(&bridge) {
1174                        Ok(stats) => publish_engine_gc(&stats),
1175                        Err(err) => debug!(%err, "engine gc stats unavailable"),
1176                    }
1177                }
1178            });
1179        if let Err(err) = spawned {
1180            warn!(%err, "could not start the engine gc sampler");
1181        }
1182    });
1183}
1184
1185/// The subprocess backend already reports this by parsing its own GC log.
1186#[cfg(feature = "java-forge")]
1187fn report_engine_gc(_engine: &ForgeEngine) {}
1188
1189#[cfg(feature = "java-forge")]
1190fn drive_game_via_handle(
1191    handle: &JavaEngineHandle,
1192    session_id: &str,
1193    max_prompts: usize,
1194) -> Result<(), String> {
1195    let mut bots: HashMap<usize, SimpleAi> = HashMap::new();
1196    let mut last_prompt: Option<String> = None;
1197    let mut acted = 0usize;
1198    let mut seen_prompt = false;
1199    let max_iterations = max_prompts.saturating_mul(200).max(2_000);
1200
1201    for _ in 0..max_iterations {
1202        if let Some(prompt_json) = handle.get_prompt(session_id, 0)? {
1203            seen_prompt = true;
1204            if last_prompt.as_deref() == Some(prompt_json.as_str()) {
1205                if handle.is_game_over(session_id)? {
1206                    return Ok(());
1207                }
1208                std::thread::sleep(Duration::from_millis(20));
1209                continue;
1210            }
1211            let prompt: AgentPrompt = serde_json::from_str(&prompt_json)
1212                .map_err(|error| format!("failed to parse concurrent prompt: {error}"))?;
1213            let player = player_index(&prompt.deciding_player_id);
1214            if let Some(action) = bots.entry(player).or_default().decide(prompt) {
1215                let action_json = serde_json::to_string(&action).map_err(|err| err.to_string())?;
1216                handle.submit_action(session_id, &action_json)?;
1217                acted += 1;
1218                if acted >= max_prompts {
1219                    return Err(format!(
1220                        "concurrent game {session_id} did not finish within {max_prompts} decisions"
1221                    ));
1222                }
1223            }
1224            last_prompt = Some(prompt_json);
1225            continue;
1226        }
1227        if seen_prompt && handle.is_game_over(session_id)? {
1228            return Ok(());
1229        }
1230        std::thread::sleep(Duration::from_millis(20));
1231    }
1232    Err(format!(
1233        "concurrent game {session_id} exceeded its iteration cap"
1234    ))
1235}
1236
1237/// G1 keeps pauses roughly bounded as the live set grows; Serial does not.
1238const DEFAULT_JAVA_COLLECTOR: &str = "G1";
1239const DEFAULT_JAVA_GC_LOG: &str = "stderr";
1240const DEFAULT_JAVA_HEAP_MB: u64 = 1024;
1241const DEFAULT_JAVA_ACTIVE_PROCESSORS: u64 = 2;
1242
1243#[derive(Debug, Clone)]
1244pub struct JavaRuntimeConfig {
1245    pub assets_dir: PathBuf,
1246    pub harness_jar: PathBuf,
1247    pub java_home: Option<PathBuf>,
1248    pub extra_classpath: Vec<PathBuf>,
1249    pub heap_mb: Option<u64>,
1250    pub active_processor_count: Option<u64>,
1251    pub gc_log: Option<String>,
1252    pub collector: Option<String>,
1253    pub extra_jvm_args: Vec<String>,
1254}
1255
1256impl JavaRuntimeConfig {
1257    pub fn from_env() -> Self {
1258        let root = workspace_root();
1259        Self {
1260            assets_dir: env_path("SELF_HOSTED_NODE_FORGE_ASSETS_DIR")
1261                .or_else(|| env_path("MANA_BREW_FORGE_ASSETS_DIR"))
1262                .unwrap_or_else(|| root.join("forge/forge-gui")),
1263            harness_jar: env_path("SELF_HOSTED_NODE_FORGE_HARNESS_JAR")
1264                .or_else(|| env_path("MANA_BREW_FORGE_HARNESS_JAR"))
1265                .unwrap_or_else(|| {
1266                    root.join("forge-harness/target/forge-harness-jar-with-dependencies.jar")
1267                }),
1268            java_home: env_path("SELF_HOSTED_NODE_JAVA_HOME")
1269                .or_else(|| env_path("MANA_BREW_JAVA_HOME"))
1270                .or_else(|| env_path("JAVA_HOME")),
1271            extra_classpath: env_classpath("SELF_HOSTED_NODE_FORGE_EXTRA_CLASSPATH")
1272                .into_iter()
1273                .chain(env_classpath("MANA_BREW_FORGE_EXTRA_CLASSPATH"))
1274                .collect(),
1275            heap_mb: env_sizing("SELF_HOSTED_NODE_JAVA_HEAP_MB", DEFAULT_JAVA_HEAP_MB),
1276            active_processor_count: env_sizing(
1277                "SELF_HOSTED_NODE_JAVA_ACTIVE_PROCESSORS",
1278                DEFAULT_JAVA_ACTIVE_PROCESSORS,
1279            ),
1280            collector: env::var("SELF_HOSTED_NODE_JAVA_COLLECTOR")
1281                .ok()
1282                .map(|value| value.trim().to_string())
1283                .filter(|value| !value.is_empty())
1284                .or_else(|| Some(DEFAULT_JAVA_COLLECTOR.to_string())),
1285            // Defaults on: the fleet ships no logs, so this log exists to be
1286            // turned into metrics by the stderr pump (see metrics.rs).
1287            gc_log: env::var("SELF_HOSTED_NODE_JAVA_GC_LOG")
1288                .ok()
1289                .map(|value| value.trim().to_string())
1290                .filter(|value| !value.is_empty())
1291                .or_else(|| Some(DEFAULT_JAVA_GC_LOG.to_string())),
1292            extra_jvm_args: env::var("SELF_HOSTED_NODE_JAVA_OPTS")
1293                .unwrap_or_default()
1294                .split_whitespace()
1295                .map(str::to_string)
1296                .collect(),
1297        }
1298    }
1299
1300    #[cfg(feature = "java-forge")]
1301    fn jvm_args(&self) -> Vec<String> {
1302        let mut args = vec![
1303            "-Dfile.encoding=UTF-8".to_string(),
1304            "-Dsun.stdout.encoding=UTF-8".to_string(),
1305            "-Dsun.stderr.encoding=UTF-8".to_string(),
1306            "-Djava.awt.headless=true".to_string(),
1307            // Forge's endstep concurrency patches assume a heap exhaustion kills the process
1308            // instead of thrashing; a supervised node is restarted, a thrashing one is not.
1309            "-XX:+ExitOnOutOfMemoryError".to_string(),
1310            // Forge calls System.gc() on match boundaries (Match.java,
1311            // HostedMatch.java) and so does the harness. Each one is a full
1312            // collection, and a full collection costs about 1.3ms per MB of
1313            // live set, so on a large board they are seconds of stop-the-world
1314            // for no benefit the collector would not have reached anyway.
1315            "-XX:+DisableExplicitGC".to_string(),
1316        ];
1317        // Pause time is linear in the live set and Serial collects on one
1318        // thread, so a bigger heap under Serial means longer freezes, not
1319        // shorter ones. Name the collector rather than inheriting whatever the
1320        // host's environment happens to set.
1321        if let Some(collector) = &self.collector {
1322            args.push(format!("-XX:+Use{collector}GC"));
1323        }
1324        // A fleet runs many of these side by side, and every JVM sizes its heap and its GC
1325        // thread count from the whole machine unless told otherwise.
1326        if let Some(heap_mb) = self.heap_mb {
1327            args.push(format!("-Xmx{heap_mb}m"));
1328        }
1329        if let Some(processors) = self.active_processor_count {
1330            args.push(format!("-XX:ActiveProcessorCount={processors}"));
1331        }
1332        // In a container the only log that reaches Loki is the node's own, and the
1333        // subprocess owns stdout for the protocol, so "stderr" routes GC through the
1334        // stderr pump instead of a file nothing ships.
1335        match self.gc_log.as_deref() {
1336            None => {}
1337            Some("stderr") => args.push("-Xlog:gc*:stderr:time,uptime,level,tags".to_string()),
1338            Some(dir) => args.push(format!(
1339                "-Xlog:gc*:file={}:time,uptime,level,tags:filecount=5,filesize=20M",
1340                Path::new(dir).join("engine-gc-%p.log").display()
1341            )),
1342        }
1343        args.extend(self.extra_jvm_args.iter().cloned());
1344        args
1345    }
1346
1347    pub fn validate(&self) -> Result<(), String> {
1348        require_dir(&self.assets_dir, "Forge assets directory")?;
1349        require_file(&self.harness_jar, "Forge harness jar")?;
1350        if let Some(java_home) = &self.java_home {
1351            require_dir(java_home, "Java home")?;
1352        }
1353        for entry in &self.extra_classpath {
1354            if !entry.exists() {
1355                return Err(format!(
1356                    "Classpath entry does not exist: {}",
1357                    entry.display()
1358                ));
1359            }
1360        }
1361        Ok(())
1362    }
1363
1364    pub fn classpath_entries(&self) -> Vec<PathBuf> {
1365        let mut entries = Vec::with_capacity(1 + self.extra_classpath.len());
1366        entries.push(self.harness_jar.clone());
1367        entries.extend(self.extra_classpath.iter().cloned());
1368        entries
1369    }
1370}
1371
1372#[cfg(forge_backend)]
1373#[allow(clippy::too_many_arguments)]
1374pub fn run_hosted_engine_game(
1375    game_id: String,
1376    player_names: Vec<String>,
1377    decks: Vec<Deck>,
1378    commander_names: Vec<Option<String>>,
1379    commander_variant: bool,
1380    game_variant: String,
1381    local_player_index: Option<usize>,
1382    ai_player_indices: Vec<usize>,
1383    starting_life: i32,
1384    remote_prompt_tx: std_mpsc::Sender<(usize, AgentMessage)>,
1385    remote_response_rxs: Vec<(usize, std_mpsc::Receiver<ClientToServerMessage>)>,
1386    game_over_tx: std_mpsc::Sender<HostedGameOver>,
1387    cancel: Arc<AtomicBool>,
1388) -> Result<(), String> {
1389    run_hosted_engine_game_inner(
1390        game_id,
1391        player_names,
1392        decks,
1393        commander_names,
1394        commander_variant,
1395        game_variant,
1396        local_player_index,
1397        ai_player_indices,
1398        starting_life,
1399        remote_prompt_tx,
1400        remote_response_rxs,
1401        game_over_tx,
1402        cancel,
1403    )
1404}
1405
1406#[cfg(not(forge_backend))]
1407#[allow(clippy::too_many_arguments)]
1408pub fn run_hosted_engine_game(
1409    _game_id: String,
1410    _player_names: Vec<String>,
1411    _decks: Vec<Deck>,
1412    _commander_names: Vec<Option<String>>,
1413    _commander_variant: bool,
1414    _game_variant: String,
1415    _local_player_index: Option<usize>,
1416    _ai_player_indices: Vec<usize>,
1417    _starting_life: i32,
1418    _remote_prompt_tx: std_mpsc::Sender<(usize, AgentMessage)>,
1419    _remote_response_rxs: Vec<(usize, std_mpsc::Receiver<ClientToServerMessage>)>,
1420    _game_over_tx: std_mpsc::Sender<HostedGameOver>,
1421    _cancel: Arc<AtomicBool>,
1422) -> Result<(), String> {
1423    Err(unsupported_message().to_string())
1424}
1425
1426// 10s missed the ones that actually happen. A real game on 2026-08-25 ran 4094
1427// decisions with a median of 96ms and put 96 of them over a second and 12 over
1428// two, and none of it logged. The expensive ones are `chooseAction` on a large
1429// board: ~125 legal actions across 140 permanents cost 2.8s.
1430#[cfg(forge_backend)]
1431const SLOW_DECISION: Duration = Duration::from_millis(1500);
1432
1433/// How often the host loop looks for a player's answer and for the engine's
1434/// next prompt.
1435///
1436/// It polled every 50ms flat, and that was the floor under every decision:
1437/// over seven days of production the node-side histogram had no decision
1438/// under 50ms and 92% of them between 50 and 100ms, one poll cycle, with the
1439/// answer itself waiting up to a further 50ms in the channel before the loop
1440/// saw it. Now the loop runs hot for a moment after anything happens, which
1441/// is when the engine's reply is due, and idles at a fifth of the old
1442/// interval the rest of the time. A poll is a channel check per seat and one
1443/// call across the bridge, cheap enough at either rate.
1444#[cfg(forge_backend)]
1445const POLL_HOT: Duration = Duration::from_millis(2);
1446#[cfg(forge_backend)]
1447const POLL_IDLE: Duration = Duration::from_millis(10);
1448#[cfg(forge_backend)]
1449const POLL_HOT_FOR: Duration = Duration::from_millis(500);
1450
1451/// Serde tag of the prompt variant, for the slow-decision log. `PromptInput` is
1452/// internally tagged, so this is the same string the captures carry. Only called
1453/// on the rare slow path, so the trip through a `Value` is not worth avoiding.
1454#[cfg(forge_backend)]
1455fn prompt_input_name(input: &PromptInput) -> String {
1456    serde_json::to_value(input)
1457        .ok()
1458        .and_then(|value| {
1459            value
1460                .get("type")
1461                .and_then(serde_json::Value::as_str)
1462                .map(str::to_owned)
1463        })
1464        .unwrap_or_else(|| "unknown".to_string())
1465}
1466
1467#[cfg(forge_backend)]
1468#[allow(clippy::too_many_arguments)]
1469fn run_hosted_engine_game_inner(
1470    game_id: String,
1471    player_names: Vec<String>,
1472    decks: Vec<Deck>,
1473    commander_names: Vec<Option<String>>,
1474    commander_variant: bool,
1475    game_variant: String,
1476    local_player_index: Option<usize>,
1477    ai_player_indices: Vec<usize>,
1478    starting_life: i32,
1479    remote_prompt_tx: std_mpsc::Sender<(usize, AgentMessage)>,
1480    remote_response_rxs: Vec<(usize, std_mpsc::Receiver<ClientToServerMessage>)>,
1481    game_over_tx: std_mpsc::Sender<HostedGameOver>,
1482    cancel: Arc<AtomicBool>,
1483) -> Result<(), String> {
1484    let engine = obtain_engine()?;
1485
1486    let mut players = Vec::with_capacity(player_names.len());
1487    for (index, name) in player_names.iter().enumerate() {
1488        let identities = deck_card_identities(&decks[index]);
1489        let seat_commander_names = if commander_variant {
1490            commander_names_for_java(&decks[index], commander_names[index].as_deref())
1491        } else {
1492            Vec::new()
1493        };
1494        players.push(PlayerConfig::new(
1495            name.clone(),
1496            &identities,
1497            seat_commander_names,
1498        ));
1499    }
1500    for &idx in &ai_player_indices {
1501        if let Some(player) = players.get_mut(idx) {
1502            player.ai = true;
1503        }
1504    }
1505    let request = StartGameRequest::new(
1506        game_id.clone(),
1507        game_variant,
1508        starting_life,
1509        rand::random(),
1510        players,
1511    );
1512    let session_id = engine.start_game(&request.to_json().map_err(|err| err.to_string())?)?;
1513    info!(game_id, session_id, "hosted java-forge session started");
1514
1515    struct SessionGuard {
1516        engine: ForgeEngine,
1517        session_id: String,
1518        armed: std::cell::Cell<bool>,
1519    }
1520    impl Drop for SessionGuard {
1521        fn drop(&mut self) {
1522            if self.armed.get() {
1523                if let Err(error) = self.engine.abort_game(&self.session_id) {
1524                    warn!(session_id = %self.session_id, %error, "failed to abort java session; context may leak");
1525                }
1526            }
1527        }
1528    }
1529    let guard = SessionGuard {
1530        engine: engine.clone(),
1531        session_id: session_id.clone(),
1532        armed: std::cell::Cell::new(true),
1533    };
1534
1535    let mut remote_response_rxs: HashMap<usize, std_mpsc::Receiver<ClientToServerMessage>> =
1536        remote_response_rxs.into_iter().collect();
1537    let mut last_prompt: Option<AgentPrompt> = None;
1538    let mut pending_roll_acks: usize = 0;
1539    let mut decision_received: Option<Instant> = None;
1540    let mut decision_submitted: Option<Instant> = None;
1541    // The last time an answer went in or a prompt came out. The loop polls
1542    // hot for a moment after either, which is when the next is due.
1543    let mut last_activity = Instant::now();
1544
1545    loop {
1546        if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1547            info!(
1548                session_id,
1549                "hosted java-forge session cancelled; player left the game"
1550            );
1551            return Ok(());
1552        }
1553        for (player_index, rx) in &mut remote_response_rxs {
1554            loop {
1555                match rx.try_recv() {
1556                    Ok(ClientToServerMessage::Response {
1557                        action: PromptOutput::DiceRolled(DiceRolledOutput::DiceRolledAcknowledged),
1558                        ..
1559                    }) => {
1560                        if pending_roll_acks > 0 {
1561                            pending_roll_acks -= 1;
1562                            if pending_roll_acks == 0 {
1563                                let ack = serde_json::to_string(&PromptOutput::DiceRolled(
1564                                    DiceRolledOutput::DiceRolledAcknowledged,
1565                                ))
1566                                .map_err(|err| format!("failed to serialize roll ack: {err}"))?;
1567                                engine.submit_action(&session_id, &ack)?;
1568                            }
1569                        }
1570                    }
1571                    Ok(ClientToServerMessage::Response { prompt_id, action }) => {
1572                        // prompt_id 0 is transport-synthesized: the
1573                        // absent-player default, exempt from validation.
1574                        if prompt_id != 0 {
1575                            let Some(prompt) =
1576                                last_prompt.as_ref().filter(|p| p.prompt_id == prompt_id)
1577                            else {
1578                                reject_response(
1579                                    &remote_prompt_tx,
1580                                    *player_index,
1581                                    last_prompt.as_ref().filter(|p| {
1582                                        self::player_index(&p.deciding_player_id) == *player_index
1583                                    }),
1584                                    ProtocolErrorCode::StalePrompt,
1585                                    format!("response for prompt {prompt_id} is not open"),
1586                                );
1587                                continue;
1588                            };
1589                            if self::player_index(&prompt.deciding_player_id) != *player_index {
1590                                reject_response(
1591                                    &remote_prompt_tx,
1592                                    *player_index,
1593                                    None,
1594                                    ProtocolErrorCode::WrongPlayer,
1595                                    format!(
1596                                        "prompt {prompt_id} is for {}",
1597                                        prompt.deciding_player_id
1598                                    ),
1599                                );
1600                                continue;
1601                            }
1602                            match prompt.input.validate_response(&action) {
1603                                Ok(()) => {}
1604                                Err(ResponseViolation::WrongPromptType) => {
1605                                    reject_response(
1606                                        &remote_prompt_tx,
1607                                        *player_index,
1608                                        Some(prompt),
1609                                        ProtocolErrorCode::WrongPromptType,
1610                                        "response output does not match the prompt type"
1611                                            .to_string(),
1612                                    );
1613                                    continue;
1614                                }
1615                                Err(ResponseViolation::UnknownActionId(id)) => {
1616                                    reject_response(
1617                                        &remote_prompt_tx,
1618                                        *player_index,
1619                                        Some(prompt),
1620                                        ProtocolErrorCode::UnknownActionId,
1621                                        format!(
1622                                            "action id {id:?} was not advertised by the prompt"
1623                                        ),
1624                                    );
1625                                    continue;
1626                                }
1627                                Err(ResponseViolation::CancelNotAllowed) => {
1628                                    reject_response(
1629                                        &remote_prompt_tx,
1630                                        *player_index,
1631                                        Some(prompt),
1632                                        ProtocolErrorCode::CancelNotAllowed,
1633                                        "this prompt is not cancellable".to_string(),
1634                                    );
1635                                    continue;
1636                                }
1637                            }
1638                        }
1639                        decision_received = Some(Instant::now());
1640                        let action_json = serde_json::to_string(&action).map_err(|err| {
1641                            format!(
1642                                "failed to serialize prompt output for player {player_index}: {err}"
1643                            )
1644                        })?;
1645                        debug!(player_index, %action_json, "submitting remote response to java");
1646                        engine.submit_action(&session_id, &action_json)?;
1647                        decision_submitted = Some(Instant::now());
1648                        last_activity = Instant::now();
1649                    }
1650                    Ok(ClientToServerMessage::Directive {
1651                        directive: DirectiveInput::Concede,
1652                    }) => {
1653                        // A directive can arrive while another player's prompt
1654                        // is open (this loop drains every seat), so it names
1655                        // its seat.
1656                        let directive_json = directive_concede_json(*player_index);
1657                        debug!(player_index, %directive_json, "submitting concede directive to java");
1658                        engine.submit_action(&session_id, &directive_json)?;
1659                        last_activity = Instant::now();
1660                    }
1661                    Err(TryRecvError::Empty) => break,
1662                    Err(TryRecvError::Disconnected) => {
1663                        debug!(player_index, "java-forge response channel disconnected");
1664                        break;
1665                    }
1666                }
1667            }
1668        }
1669
1670        if let Some(prompt_json) = engine.get_prompt(&session_id, 0)? {
1671            let prompt: AgentPrompt = serde_json::from_str(&prompt_json)
1672                .map_err(|err| format!("failed to parse java prompt: {err}"))?;
1673            if last_prompt.as_ref().map(|p| p.prompt_id) != Some(prompt.prompt_id) {
1674                last_activity = Instant::now();
1675                if let Some(started) = decision_submitted.take() {
1676                    crate::metrics::record_forge_decision_stage("next_prompt", started.elapsed());
1677                }
1678                if let Some(started) = decision_received.take() {
1679                    let elapsed = started.elapsed();
1680                    crate::metrics::record_forge_decision_stage("decision_total", elapsed);
1681                    crate::metrics::record_forge_decision(player_names.len(), elapsed);
1682                    // The metric alone cannot say which game was slow, and these
1683                    // are rare enough that finding one afterwards meant replaying
1684                    // captures by hand. Named so a log query can list them.
1685                    if elapsed >= SLOW_DECISION {
1686                        warn!(
1687                            game_id,
1688                            session_id,
1689                            seats = player_names.len(),
1690                            bots = ai_player_indices.len(),
1691                            elapsed_ms = elapsed.as_millis() as u64,
1692                            prompt = prompt_input_name(&prompt.input),
1693                            "slow engine decision"
1694                        );
1695                    }
1696                    report_engine_gc(&engine);
1697                }
1698                last_prompt = Some(prompt.clone());
1699                let player = player_index(&prompt.deciding_player_id);
1700                debug!(player, "forwarding java prompt to remote");
1701                if matches!(prompt.input, PromptInput::DiceRolled(_)) {
1702                    let snapshots_started = Instant::now();
1703                    let prompt_msg = AgentMessage::Prompt(prompt);
1704                    for &agent_index in remote_response_rxs.keys() {
1705                        let state = AgentMessage::State(state_via_handle(
1706                            &engine,
1707                            &session_id,
1708                            Some(agent_index),
1709                        )?);
1710                        let _ = remote_prompt_tx.send((agent_index, state));
1711                        let _ = remote_prompt_tx.send((agent_index, prompt_msg.clone()));
1712                    }
1713                    send_observer_state(&engine, &session_id, &remote_prompt_tx);
1714                    pending_roll_acks = remote_response_rxs.len();
1715                    if pending_roll_acks == 0 {
1716                        let ack = serde_json::to_string(&PromptOutput::DiceRolled(
1717                            DiceRolledOutput::DiceRolledAcknowledged,
1718                        ))
1719                        .map_err(|err| format!("failed to serialize roll ack: {err}"))?;
1720                        engine.submit_action(&session_id, &ack)?;
1721                    }
1722                    crate::metrics::record_forge_decision_stage(
1723                        "snapshots",
1724                        snapshots_started.elapsed(),
1725                    );
1726                } else if Some(player) == local_player_index {
1727                    if let Some(output) = auto_action(&prompt) {
1728                        let action_json = serde_json::to_string(&output)
1729                            .map_err(|err| format!("failed to serialize auto action: {err}"))?;
1730                        engine.submit_action(&session_id, &action_json)?;
1731                    }
1732                } else {
1733                    let snapshots_started = Instant::now();
1734                    for &agent_index in remote_response_rxs.keys() {
1735                        let state = AgentMessage::State(state_via_handle(
1736                            &engine,
1737                            &session_id,
1738                            Some(agent_index),
1739                        )?);
1740                        if remote_prompt_tx.send((agent_index, state)).is_err() {
1741                            return Ok(());
1742                        }
1743                    }
1744                    send_observer_state(&engine, &session_id, &remote_prompt_tx);
1745                    let prompt_msg = AgentMessage::Prompt(prompt);
1746                    if remote_prompt_tx.send((player, prompt_msg)).is_err() {
1747                        return Ok(());
1748                    }
1749                    crate::metrics::record_forge_decision_stage(
1750                        "snapshots",
1751                        snapshots_started.elapsed(),
1752                    );
1753                }
1754            }
1755        }
1756
1757        if engine.is_game_over(&session_id)? {
1758            info!("hosted java-forge session reached game over");
1759            let mut final_messages = Vec::new();
1760            for &agent_index in remote_response_rxs.keys() {
1761                match state_via_handle(&engine, &session_id, Some(agent_index)) {
1762                    Ok(state_update) => {
1763                        final_messages.push((agent_index, AgentMessage::State(state_update)));
1764                    }
1765                    Err(error) => {
1766                        warn!(%error, agent_index, "game over: final snapshot unavailable; sending game-over prompt only");
1767                    }
1768                }
1769            }
1770            if let Ok(state_update) = state_via_handle(&engine, &session_id, None) {
1771                final_messages.push((
1772                    crate::host::OBSERVER_SEAT,
1773                    AgentMessage::State(state_update),
1774                ));
1775            }
1776            let game_over = AgentMessage::Prompt(game_over_prompt());
1777            for &agent_index in remote_response_rxs.keys() {
1778                final_messages.push((agent_index, game_over.clone()));
1779            }
1780            let _ = game_over_tx.send(HostedGameOver {
1781                game_id: game_id.clone(),
1782                messages: final_messages,
1783            });
1784            engine.end_game(&session_id)?;
1785            guard.armed.set(false);
1786            return Ok(());
1787        }
1788
1789        std::thread::sleep(if last_activity.elapsed() < POLL_HOT_FOR {
1790            POLL_HOT
1791        } else {
1792            POLL_IDLE
1793        });
1794    }
1795}
1796
1797#[cfg(feature = "java-forge")]
1798fn wait_for_prompt<B: JavaBridge>(
1799    session: &mut JavaForgeSession<B>,
1800    max_polls: usize,
1801) -> Result<Option<String>, String> {
1802    for _ in 0..max_polls {
1803        if let Some(prompt) = session.get_prompt(0)? {
1804            return Ok(Some(prompt));
1805        }
1806        std::thread::sleep(Duration::from_millis(50));
1807    }
1808    Ok(None)
1809}
1810
1811#[cfg(forge_backend)]
1812fn send_observer_state(
1813    engine: &ForgeEngine,
1814    session_id: &str,
1815    remote_prompt_tx: &std_mpsc::Sender<(usize, AgentMessage)>,
1816) {
1817    match state_via_handle(engine, session_id, None) {
1818        Ok(state_update) => {
1819            let _ = remote_prompt_tx.send((
1820                crate::host::OBSERVER_SEAT,
1821                AgentMessage::State(state_update),
1822            ));
1823        }
1824        Err(error) => warn!(%error, "observer snapshot unavailable"),
1825    }
1826}
1827
1828#[cfg(forge_backend)]
1829fn player_index(deciding_player_id: &str) -> usize {
1830    deciding_player_id
1831        .strip_prefix("player-")
1832        .and_then(|n| n.parse().ok())
1833        .unwrap_or(0)
1834}
1835
1836#[cfg(forge_backend)]
1837fn reject_response(
1838    remote_prompt_tx: &std_mpsc::Sender<(usize, AgentMessage)>,
1839    seat: usize,
1840    reopen_prompt: Option<&AgentPrompt>,
1841    code: ProtocolErrorCode,
1842    message: String,
1843) {
1844    let _ = remote_prompt_tx.send((
1845        seat,
1846        AgentMessage::Error(ProtocolError {
1847            code,
1848            message,
1849            prompt_id: reopen_prompt.map(|p| p.prompt_id),
1850        }),
1851    ));
1852    if let Some(prompt) = reopen_prompt {
1853        let _ = remote_prompt_tx.send((seat, AgentMessage::Prompt(prompt.clone())));
1854    }
1855}
1856
1857#[cfg(forge_backend)]
1858fn auto_action(prompt: &AgentPrompt) -> Option<PromptOutput> {
1859    match prompt.input {
1860        PromptInput::ChooseAction(_) => {
1861            Some(PromptOutput::ChooseAction(ChooseActionOutput::Pass {
1862                until: None,
1863                exhaust_stack: false,
1864            }))
1865        }
1866        _ => None,
1867    }
1868}
1869
1870#[cfg(forge_backend)]
1871fn game_over_prompt() -> AgentPrompt {
1872    AgentPrompt {
1873        prompt_id: u32::MAX,
1874        deciding_player_id: "player-0".to_string(),
1875        source_card: None,
1876        source_ability_text: None,
1877        input: PromptInput::GameOver(GameOverInput {}),
1878    }
1879}
1880
1881#[cfg(forge_backend)]
1882fn state_via_handle(
1883    engine: &ForgeEngine,
1884    session_id: &str,
1885    viewer: Option<usize>,
1886) -> Result<StateUpdate, String> {
1887    let game_view: GameViewDto = serde_json::from_str(&engine.get_snapshot(session_id, viewer)?)
1888        .map_err(|err| format!("failed to parse java snapshot: {err}"))?;
1889    Ok(StateUpdate { game_view })
1890}
1891
1892#[cfg(forge_backend)]
1893fn deck_card_identities(deck: &Deck) -> Vec<DeckCardIdentity> {
1894    deck.cards
1895        .iter()
1896        .chain(deck.commanders.iter().flatten())
1897        .map(|card| card.identity.clone())
1898        .collect()
1899}
1900
1901#[cfg(forge_backend)]
1902fn commander_names_for_java(deck: &Deck, fallback: Option<&str>) -> Vec<String> {
1903    let names: Vec<String> = deck
1904        .commanders
1905        .iter()
1906        .flatten()
1907        .map(|card| java_card_name(&card.identity.name))
1908        .collect();
1909    if !names.is_empty() {
1910        return names;
1911    }
1912    fallback
1913        .filter(|name| !name.is_empty())
1914        .map(|name| vec![java_card_name(name)])
1915        .unwrap_or_default()
1916}
1917
1918#[cfg(forge_backend)]
1919fn smoke_deck(land_name: &str, spell_name: &str) -> Vec<DeckCardIdentity> {
1920    (0..24)
1921        .map(|_| DeckCardIdentity {
1922            name: land_name.to_string(),
1923            ..Default::default()
1924        })
1925        .chain((0..36).map(|_| DeckCardIdentity {
1926            name: spell_name.to_string(),
1927            ..Default::default()
1928        }))
1929        .collect()
1930}
1931
1932#[cfg(feature = "java-forge")]
1933fn scenario_deck(land_name: &str) -> Vec<DeckCardIdentity> {
1934    (0..60)
1935        .map(|_| DeckCardIdentity {
1936            name: land_name.to_string(),
1937            ..Default::default()
1938        })
1939        .collect()
1940}
1941
1942#[cfg(feature = "java-forge")]
1943enum JavaScenario {
1944    KeepAndPlayLand {
1945        played_land: bool,
1946    },
1947    MulliganOncePlayLand {
1948        mulliganed: bool,
1949        kept_second_hand: bool,
1950        put_back_done: bool,
1951        played_land: bool,
1952    },
1953}
1954
1955#[cfg(feature = "java-forge")]
1956impl JavaScenario {
1957    fn from_name(name: &str) -> Result<Self, String> {
1958        match name {
1959            "keep-and-play-land" => Ok(Self::KeepAndPlayLand { played_land: false }),
1960            "mulligan-once-play-land" => Ok(Self::MulliganOncePlayLand {
1961                mulliganed: false,
1962                kept_second_hand: false,
1963                put_back_done: false,
1964                played_land: false,
1965            }),
1966            _ => Err(format!(
1967                "unknown java-forge scenario '{name}'. Supported scenarios: keep-and-play-land, mulligan-once-play-land"
1968            )),
1969        }
1970    }
1971
1972    fn name(&self) -> &'static str {
1973        match self {
1974            Self::KeepAndPlayLand { .. } => "keep-and-play-land",
1975            Self::MulliganOncePlayLand { .. } => "mulligan-once-play-land",
1976        }
1977    }
1978
1979    fn next_action(
1980        &mut self,
1981        prompt: &Value,
1982        game_view: &GameViewDto,
1983    ) -> Result<Option<PromptOutput>, String> {
1984        match self {
1985            Self::KeepAndPlayLand { played_land } => {
1986                if *played_land && battlefield_contains(game_view, "Swamp") {
1987                    return Ok(None);
1988                }
1989                match prompt_type(prompt) {
1990                    Some("mulligan") => Ok(Some(PromptOutput::Mulligan(
1991                        MulliganOutput::MulliganDecision { keep: true },
1992                    ))),
1993                    Some("chooseAction") => {
1994                        if let Some(action) = play_first_card_action(prompt, "Swamp")? {
1995                            *played_land = true;
1996                            Ok(Some(action))
1997                        } else {
1998                            Ok(Some(PromptOutput::ChooseAction(ChooseActionOutput::Pass {
1999                                until: None,
2000                                exhaust_stack: false,
2001                            })))
2002                        }
2003                    }
2004                    other => Err(format!(
2005                        "scenario '{}' expected mulligan or chooseAction, got {:?}",
2006                        self.name(),
2007                        other
2008                    )),
2009                }
2010            }
2011            Self::MulliganOncePlayLand {
2012                mulliganed,
2013                kept_second_hand,
2014                put_back_done,
2015                played_land,
2016            } => {
2017                if *played_land && battlefield_contains(game_view, "Swamp") {
2018                    return Ok(None);
2019                }
2020                match prompt_type(prompt) {
2021                    Some("mulligan") if !*mulliganed => {
2022                        *mulliganed = true;
2023                        Ok(Some(PromptOutput::Mulligan(MulliganOutput::MulliganDecision { keep: false })))
2024                    }
2025                    Some("mulligan") if !*kept_second_hand => {
2026                        *kept_second_hand = true;
2027                        Ok(Some(PromptOutput::Mulligan(MulliganOutput::MulliganDecision { keep: true })))
2028                    }
2029                    Some("mulliganPutBack") if !*put_back_done => {
2030                        let count = prompt
2031                            .get("input")
2032                            .and_then(|input| input.get("count"))
2033                            .and_then(Value::as_u64)
2034                            .unwrap_or(1) as usize;
2035                        let card_ids = prompt_card_ids(prompt, "handCardIds", count)?;
2036                        *put_back_done = true;
2037                        Ok(Some(PromptOutput::MulliganPutBack(MulliganPutBackOutput::MulliganPutBackDecision { card_ids })))
2038                    }
2039                    Some("chooseAction") => {
2040                        if let Some(action) = play_first_card_action(prompt, "Swamp")? {
2041                            *played_land = true;
2042                            Ok(Some(action))
2043                        } else {
2044                            Ok(Some(PromptOutput::ChooseAction(ChooseActionOutput::Pass { until: None, exhaust_stack: false })))
2045                        }
2046                    }
2047                    other => Err(format!(
2048                        "scenario '{}' expected mulligan, mulliganPutBack, or chooseAction, got {:?}",
2049                        self.name(),
2050                        other
2051                    )),
2052                }
2053            }
2054        }
2055    }
2056}
2057
2058#[cfg(feature = "java-forge")]
2059fn run_scenario_loop<B: JavaBridge>(
2060    session: &mut JavaForgeSession<B>,
2061    mut scenario: JavaScenario,
2062    max_prompts: usize,
2063) -> Result<(), String> {
2064    let mut prompts_seen = 0usize;
2065    let mut last_prompt_json: Option<String> = None;
2066    while prompts_seen < max_prompts {
2067        let Some(prompt_json) = wait_for_prompt(session, 600)? else {
2068            return Err(format!(
2069                "timed out waiting for java-forge scenario '{}' prompt",
2070                scenario.name()
2071            ));
2072        };
2073        if last_prompt_json.as_deref() == Some(prompt_json.as_str()) {
2074            std::thread::sleep(Duration::from_millis(50));
2075            continue;
2076        }
2077        last_prompt_json = Some(prompt_json.clone());
2078        prompts_seen += 1;
2079
2080        let prompt: AgentPrompt = serde_json::from_str(&prompt_json)
2081            .map_err(|err| format!("failed to parse java scenario prompt: {err}"))?;
2082        let player = player_index(&prompt.deciding_player_id);
2083        if player != 0 {
2084            if let Some(output) = auto_action(&prompt) {
2085                session.submit_action(
2086                    &serde_json::to_string(&output).map_err(|err| err.to_string())?,
2087                )?;
2088            }
2089            continue;
2090        }
2091
2092        let game_view: GameViewDto = serde_json::from_str(&session.get_snapshot(Some(0))?)
2093            .map_err(|err| format!("failed to parse java scenario snapshot: {err}"))?;
2094        let normalized_prompt = serde_json::to_value(&prompt).map_err(|err| err.to_string())?;
2095        info!(
2096            scenario = scenario.name(),
2097            prompts_seen,
2098            prompt_type = prompt_type(&normalized_prompt).unwrap_or("<missing>"),
2099            "java-forge scenario prompt"
2100        );
2101        let Some(action) = scenario.next_action(&normalized_prompt, &game_view)? else {
2102            info!(
2103                scenario = scenario.name(),
2104                prompts_seen, "java-forge scenario assertions satisfied"
2105            );
2106            return Ok(());
2107        };
2108        submit_player_action(session, &action)?;
2109    }
2110    Err(format!(
2111        "java-forge scenario '{}' did not complete within {max_prompts} prompts",
2112        scenario.name()
2113    ))
2114}
2115
2116#[cfg(feature = "java-forge")]
2117pub fn run_concede_smoke() -> Result<(), String> {
2118    let config = JavaRuntimeConfig::from_env();
2119    let assets_dir = config.assets_dir.to_string_lossy().to_string();
2120    let bridge = SubprocessBridge::spawn(&config)?;
2121    let mut session = JavaForgeSession::new(bridge);
2122    session.initialize(&assets_dir)?;
2123    run_concede_game(&mut session, 3, 2, 12)?;
2124    run_concede_game(&mut session, 2, 1, 8)?;
2125    Ok(())
2126}
2127
2128#[cfg(not(feature = "java-forge"))]
2129pub fn run_concede_smoke() -> Result<(), String> {
2130    Err(unsupported_message().to_string())
2131}
2132
2133#[cfg(forge_backend)]
2134fn directive_concede_json(player: usize) -> String {
2135    format!(r#"{{"type":"directive","directive":{{"type":"concede"}},"player":{player}}}"#)
2136}
2137
2138#[cfg(feature = "java-forge")]
2139fn run_concede_game<B: JavaBridge>(
2140    session: &mut JavaForgeSession<B>,
2141    seats: usize,
2142    conceder: usize,
2143    concede_after: usize,
2144) -> Result<(), String> {
2145    const POST_CONCEDE_DECISIONS: usize = 12;
2146    const STALL_REPEATS: usize = 300;
2147
2148    let cards: Vec<manabrew_protocol::deck_dto::DeckCard> = (0..60)
2149        .map(|_| manabrew_protocol::deck_dto::DeckCard {
2150            identity: DeckCardIdentity {
2151                name: "Mountain".to_string(),
2152                set_code: "M20".to_string(),
2153                ..Default::default()
2154            },
2155            ..Default::default()
2156        })
2157        .collect();
2158    let deck = Deck {
2159        name: "concede-smoke".to_string(),
2160        cards,
2161        ..Default::default()
2162    };
2163    let identities = deck_card_identities(&deck);
2164    let players: Vec<PlayerConfig> = (0..seats)
2165        .map(|i| PlayerConfig::new(format!("Concede {}", i + 1), &identities, Vec::new()))
2166        .collect();
2167    let request = StartGameRequest::new(
2168        format!("concede-smoke-{seats}p"),
2169        String::new(),
2170        20,
2171        7,
2172        players,
2173    );
2174    let session_id = session.start_game(&request)?;
2175    info!(
2176        session_id,
2177        seats, conceder, concede_after, "concede smoke game started"
2178    );
2179
2180    let mut bots: HashMap<usize, SimpleAi> = HashMap::new();
2181    let mut last_prompt_json: Option<String> = None;
2182    let mut acted = 0usize;
2183    let mut acted_after_concede = 0usize;
2184    let mut conceded = false;
2185    let mut repeat_count = 0usize;
2186
2187    for _ in 0..40_000 {
2188        if session.is_game_over()? {
2189            if !conceded {
2190                return Err("concede smoke: game ended before the concede fired".to_string());
2191            }
2192            break;
2193        }
2194        let Some(prompt_json) = session.get_prompt(0)? else {
2195            std::thread::sleep(Duration::from_millis(10));
2196            continue;
2197        };
2198        if last_prompt_json.as_deref() == Some(prompt_json.as_str()) {
2199            repeat_count += 1;
2200            if repeat_count > STALL_REPEATS {
2201                return Err(format!(
2202                    "concede smoke stalled on the same prompt (seats={seats} acted={acted} conceded={conceded} after={acted_after_concede}): {prompt_json}"
2203                ));
2204            }
2205            std::thread::sleep(Duration::from_millis(10));
2206            continue;
2207        }
2208        repeat_count = 0;
2209
2210        let prompt: AgentPrompt = serde_json::from_str(&prompt_json)
2211            .map_err(|err| format!("concede smoke: bad prompt: {err}"))?;
2212        let player = player_index(&prompt.deciding_player_id);
2213
2214        if !conceded && acted >= concede_after {
2215            info!(conceder, acted, "concede smoke: injecting concede");
2216            session.submit_action(&directive_concede_json(conceder))?;
2217            conceded = true;
2218            last_prompt_json = None;
2219            continue;
2220        }
2221        if conceded && player == conceder {
2222            session.submit_action(&directive_concede_json(conceder))?;
2223            last_prompt_json = Some(prompt_json);
2224            continue;
2225        }
2226
2227        if let Some(action) = bots.entry(player).or_default().decide(prompt) {
2228            submit_player_action(session, &action)?;
2229            acted += 1;
2230            if conceded {
2231                acted_after_concede += 1;
2232            }
2233        }
2234        last_prompt_json = Some(prompt_json);
2235        if conceded && seats > 2 && acted_after_concede >= POST_CONCEDE_DECISIONS {
2236            break;
2237        }
2238    }
2239
2240    if !conceded {
2241        return Err("concede smoke: never reached the injection point".to_string());
2242    }
2243    if seats == 2 {
2244        if !session.is_game_over()? {
2245            return Err("concede smoke: 2p concession did not end the game".to_string());
2246        }
2247        info!(acted, "concede smoke: 2p concession ended the game");
2248    } else {
2249        if acted_after_concede < POST_CONCEDE_DECISIONS && !session.is_game_over()? {
2250            return Err(format!(
2251                "concede smoke: game did not progress after the concession (only {acted_after_concede} decisions)"
2252            ));
2253        }
2254        let snapshot = parse_snapshot(session)?;
2255        let status = snapshot
2256            .pointer(&format!("/players/{conceder}/status"))
2257            .and_then(Value::as_str)
2258            .unwrap_or("");
2259        if status != "conceded" {
2260            return Err(format!(
2261                "concede smoke: seat {conceder} has status '{status}', expected 'conceded'"
2262            ));
2263        }
2264        info!(
2265            acted,
2266            acted_after_concede, "concede smoke: game continued past the concession"
2267        );
2268    }
2269    session.end_game()?;
2270    Ok(())
2271}
2272
2273#[cfg(feature = "java-forge")]
2274fn run_self_play_loop<B: JavaBridge>(
2275    session: &mut JavaForgeSession<B>,
2276    max_prompts: usize,
2277) -> Result<(), String> {
2278    const STALL_REPEATS: usize = 100;
2279
2280    let mut bots: HashMap<usize, SimpleAi> = HashMap::new();
2281    let mut last_prompt_json: Option<String> = None;
2282    let mut acted = 0usize;
2283    let mut repeat_count = 0usize;
2284    let mut seen_prompt = false;
2285    let max_iterations = max_prompts.saturating_mul(200).max(2_000);
2286
2287    for _ in 0..max_iterations {
2288        if let Some(prompt_json) = session.get_prompt(0)? {
2289            seen_prompt = true;
2290            if last_prompt_json.as_deref() == Some(prompt_json.as_str()) {
2291                if session.is_game_over()? {
2292                    info!(acted, "java-forge self-play reached game over");
2293                    return Ok(());
2294                }
2295                repeat_count += 1;
2296                if repeat_count > STALL_REPEATS {
2297                    let raw_value: Value =
2298                        serde_json::from_str(&prompt_json).unwrap_or(Value::Null);
2299                    dump_stuck(
2300                        "java re-emitted the same prompt after the bot acted (stall)",
2301                        &raw_value,
2302                        None,
2303                        session,
2304                    );
2305                    return Err(
2306                        "self-play stalled: java re-emitted the same prompt after the bot's action"
2307                            .to_string(),
2308                    );
2309                }
2310                std::thread::sleep(Duration::from_millis(20));
2311                continue;
2312            }
2313            repeat_count = 0;
2314
2315            let prompt: AgentPrompt = serde_json::from_str(&prompt_json)
2316                .map_err(|err| format!("failed to parse java self-play prompt: {err}"))?;
2317            let player = player_index(&prompt.deciding_player_id);
2318            let raw_value: Value = serde_json::from_str(&prompt_json).unwrap_or(Value::Null);
2319            let normalized = raw_value.clone();
2320
2321            match bots.entry(player).or_default().decide(prompt) {
2322                Some(action) => {
2323                    if let Err(err) = submit_player_action(session, &action) {
2324                        dump_stuck(
2325                            "java rejected the bot action",
2326                            &raw_value,
2327                            Some(&normalized),
2328                            session,
2329                        );
2330                        return Err(format!(
2331                            "self-play: java rejected action for player {player}: {err}"
2332                        ));
2333                    }
2334                    acted += 1;
2335                    if acted >= max_prompts {
2336                        dump_stuck(
2337                            "did not reach game over within max prompts",
2338                            &raw_value,
2339                            Some(&normalized),
2340                            session,
2341                        );
2342                        return Err(format!(
2343                            "self-play did not reach game over within {max_prompts} decisions"
2344                        ));
2345                    }
2346                }
2347                None => debug!(
2348                    player,
2349                    prompt_type = prompt_type(&normalized).unwrap_or("<missing>"),
2350                    "self-play: no action for prompt (display-only)"
2351                ),
2352            }
2353            last_prompt_json = Some(prompt_json);
2354            continue;
2355        }
2356
2357        if seen_prompt && session.is_game_over()? {
2358            info!(acted, "java-forge self-play reached game over");
2359            return Ok(());
2360        }
2361        std::thread::sleep(Duration::from_millis(20));
2362    }
2363
2364    dump_stuck(
2365        "self-play exceeded its iteration cap without game over",
2366        &Value::Null,
2367        None,
2368        session,
2369    );
2370    Err("self-play exceeded its iteration cap without reaching game over".to_string())
2371}
2372
2373#[cfg(feature = "java-forge")]
2374fn parse_snapshot<B: JavaBridge>(session: &mut JavaForgeSession<B>) -> Result<Value, String> {
2375    let snapshot_json = session.get_snapshot(Some(0))?;
2376    serde_json::from_str(&snapshot_json)
2377        .map_err(|err| format!("failed to parse java self-play snapshot: {err}"))
2378}
2379
2380#[cfg(feature = "java-forge")]
2381fn dump_stuck<B: JavaBridge>(
2382    reason: &str,
2383    prompt: &Value,
2384    normalized: Option<&Value>,
2385    session: &mut JavaForgeSession<B>,
2386) {
2387    let snapshot = parse_snapshot(session).unwrap_or(Value::Null);
2388    let artifact = json!({
2389        "reason": reason,
2390        "rawPrompt": prompt,
2391        "normalizedPrompt": normalized,
2392        "snapshot": snapshot,
2393    });
2394    let ts = std::time::SystemTime::now()
2395        .duration_since(std::time::UNIX_EPOCH)
2396        .map(|d| d.as_millis())
2397        .unwrap_or(0);
2398    let path = workspace_root().join(format!("target/self-play-stuck-{ts}.json"));
2399    match serde_json::to_string_pretty(&artifact) {
2400        Ok(body) => {
2401            if let Err(error) = std::fs::write(&path, body) {
2402                warn!(%error, reason, "self-play stuck; failed to write artifact");
2403            } else {
2404                warn!(path = %path.display(), reason, "self-play stuck; wrote artifact");
2405            }
2406        }
2407        Err(error) => warn!(%error, reason, "self-play stuck; failed to serialize artifact"),
2408    }
2409}
2410
2411#[cfg(feature = "java-forge")]
2412fn submit_player_action<B: JavaBridge>(
2413    session: &mut JavaForgeSession<B>,
2414    action: &PromptOutput,
2415) -> Result<(), String> {
2416    let action_json = serde_json::to_string(action)
2417        .map_err(|err| format!("failed to serialize scenario action: {err}"))?;
2418    session.submit_action(&action_json)?;
2419    Ok(())
2420}
2421
2422#[cfg(feature = "java-forge")]
2423fn prompt_type(prompt: &Value) -> Option<&str> {
2424    prompt
2425        .get("input")
2426        .and_then(|input| input.get("type"))
2427        .and_then(Value::as_str)
2428}
2429
2430#[cfg(feature = "java-forge")]
2431fn play_first_card_action(prompt: &Value, card_name: &str) -> Result<Option<PromptOutput>, String> {
2432    let Some(action) = prompt
2433        .get("input")
2434        .and_then(|input| input.get("actions"))
2435        .and_then(Value::as_array)
2436        .and_then(|actions| {
2437            actions.iter().find(|action| {
2438                action
2439                    .get("modeLabel")
2440                    .and_then(Value::as_str)
2441                    .is_some_and(|label| label.contains(card_name))
2442            })
2443        })
2444    else {
2445        return Ok(None);
2446    };
2447    let action_id = action
2448        .get("id")
2449        .and_then(Value::as_str)
2450        .ok_or_else(|| format!("playable action for '{card_name}' is missing id"))?;
2451    Ok(Some(PromptOutput::ChooseAction(ChooseActionOutput::Act {
2452        action_id: action_id.to_string(),
2453    })))
2454}
2455
2456#[cfg(feature = "java-forge")]
2457fn prompt_card_ids(prompt: &Value, field: &str, count: usize) -> Result<Vec<String>, String> {
2458    let card_ids = prompt
2459        .get("input")
2460        .and_then(|input| input.get(field))
2461        .and_then(Value::as_array)
2462        .ok_or_else(|| format!("prompt is missing {field}"))?;
2463    if card_ids.len() < count {
2464        return Err(format!(
2465            "prompt field {field} has {} cards, need {count}",
2466            card_ids.len()
2467        ));
2468    }
2469    Ok(card_ids
2470        .iter()
2471        .take(count)
2472        .filter_map(Value::as_str)
2473        .map(str::to_string)
2474        .collect())
2475}
2476
2477#[cfg(feature = "java-forge")]
2478fn battlefield_contains(game_view: &GameViewDto, card_name: &str) -> bool {
2479    use manabrew_agent_interface::game_view_dto::{CardView, ZoneKind};
2480    game_view.zones.iter().any(|zone| {
2481        zone.zone == ZoneKind::Battlefield
2482            && zone.owner_id == "player-0"
2483            && zone.cards.iter().any(|card| match card {
2484                CardView::Visible(dto) => dto.identity.name == card_name,
2485                CardView::Hidden { .. } => false,
2486            })
2487    })
2488}
2489
2490pub trait JavaBridge {
2491    fn initialize(&mut self, assets_dir: &str) -> Result<(), String>;
2492    fn start_game_json(&mut self, request_json: &str) -> Result<String, String>;
2493    fn submit_action(&mut self, session_id: &str, action_json: &str) -> Result<String, String>;
2494    fn get_prompt(
2495        &mut self,
2496        session_id: &str,
2497        player_index: usize,
2498    ) -> Result<Option<String>, String>;
2499    fn get_snapshot(&mut self, session_id: &str, viewer: Option<usize>) -> Result<String, String>;
2500    fn is_game_over(&mut self, session_id: &str) -> Result<bool, String>;
2501    fn end_game(&mut self, session_id: &str) -> Result<(), String>;
2502    fn abort_game(&mut self, session_id: &str) -> Result<(), String>;
2503}
2504
2505pub struct JavaForgeSession<B> {
2506    bridge: B,
2507    session_id: Option<String>,
2508}
2509
2510impl<B: JavaBridge> JavaForgeSession<B> {
2511    pub fn new(bridge: B) -> Self {
2512        Self {
2513            bridge,
2514            session_id: None,
2515        }
2516    }
2517
2518    pub fn initialize(&mut self, assets_dir: &str) -> Result<(), String> {
2519        self.bridge.initialize(assets_dir)
2520    }
2521
2522    pub fn start_game(&mut self, request: &StartGameRequest) -> Result<String, String> {
2523        let request_json = request.to_json().map_err(|err| err.to_string())?;
2524        let response_json = self.bridge.start_game_json(&request_json)?;
2525        let response: StartGameResponse =
2526            serde_json::from_str(&response_json).map_err(|err| err.to_string())?;
2527        self.session_id = Some(response.session_id.clone());
2528        Ok(response.session_id)
2529    }
2530
2531    pub fn submit_action(&mut self, action_json: &str) -> Result<String, String> {
2532        let session_id = self.require_session_id()?.to_string();
2533        self.bridge.submit_action(&session_id, action_json)
2534    }
2535
2536    pub fn get_prompt(&mut self, player_index: usize) -> Result<Option<String>, String> {
2537        let session_id = self.require_session_id()?.to_string();
2538        self.bridge.get_prompt(&session_id, player_index)
2539    }
2540
2541    pub fn get_snapshot(&mut self, viewer: Option<usize>) -> Result<String, String> {
2542        let session_id = self.require_session_id()?.to_string();
2543        self.bridge.get_snapshot(&session_id, viewer)
2544    }
2545
2546    pub fn is_game_over(&mut self) -> Result<bool, String> {
2547        let session_id = self.require_session_id()?.to_string();
2548        self.bridge.is_game_over(&session_id)
2549    }
2550
2551    pub fn end_game(&mut self) -> Result<(), String> {
2552        let Some(session_id) = self.session_id.take() else {
2553            return Ok(());
2554        };
2555        self.bridge.end_game(&session_id)
2556    }
2557
2558    fn require_session_id(&self) -> Result<&str, String> {
2559        self.session_id
2560            .as_deref()
2561            .ok_or_else(|| "java-forge session has not started".to_string())
2562    }
2563}
2564
2565pub struct UnavailableJavaBridge;
2566
2567impl JavaBridge for UnavailableJavaBridge {
2568    fn initialize(&mut self, _assets_dir: &str) -> Result<(), String> {
2569        Err(unsupported_message().to_string())
2570    }
2571
2572    fn start_game_json(&mut self, _request_json: &str) -> Result<String, String> {
2573        Err(unsupported_message().to_string())
2574    }
2575
2576    fn submit_action(&mut self, _session_id: &str, _action_json: &str) -> Result<String, String> {
2577        Err(unsupported_message().to_string())
2578    }
2579
2580    fn get_prompt(
2581        &mut self,
2582        _session_id: &str,
2583        _player_index: usize,
2584    ) -> Result<Option<String>, String> {
2585        Err(unsupported_message().to_string())
2586    }
2587
2588    fn get_snapshot(
2589        &mut self,
2590        _session_id: &str,
2591        _viewer: Option<usize>,
2592    ) -> Result<String, String> {
2593        Err(unsupported_message().to_string())
2594    }
2595
2596    fn is_game_over(&mut self, _session_id: &str) -> Result<bool, String> {
2597        Err(unsupported_message().to_string())
2598    }
2599
2600    fn end_game(&mut self, _session_id: &str) -> Result<(), String> {
2601        Err(unsupported_message().to_string())
2602    }
2603
2604    fn abort_game(&mut self, _session_id: &str) -> Result<(), String> {
2605        Err(unsupported_message().to_string())
2606    }
2607}
2608
2609#[cfg(feature = "java-forge")]
2610#[derive(serde::Deserialize)]
2611struct SubprocessReply {
2612    ok: bool,
2613    #[serde(default)]
2614    result: String,
2615    #[serde(default)]
2616    error: Option<String>,
2617}
2618
2619#[cfg(feature = "java-forge")]
2620const CALL_TIMEOUT: Duration = Duration::from_secs(60);
2621#[cfg(feature = "java-forge")]
2622const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
2623
2624#[cfg(feature = "java-forge")]
2625pub struct SubprocessBridge {
2626    child: Child,
2627    stdin: BufWriter<ChildStdin>,
2628    stdout_rx: std_mpsc::Receiver<String>,
2629    stdout_handle: Option<std::thread::JoinHandle<()>>,
2630    stderr_handle: Option<std::thread::JoinHandle<()>>,
2631}
2632
2633#[cfg(feature = "java-forge")]
2634impl SubprocessBridge {
2635    fn spawn(config: &JavaRuntimeConfig) -> Result<Self, String> {
2636        config.validate()?;
2637
2638        let java_bin = resolve_java_bin(config);
2639        let jvm_args = config.jvm_args();
2640        info!(target: "self_hosted_node::java", args = %jvm_args.join(" "), "spawning java engine");
2641        let mut cmd = Command::new(&java_bin);
2642        // The JVM applies JAVA_TOOL_OPTIONS before anything on the command line,
2643        // so whatever is set on the host silently governs every flag we do not
2644        // name. Production ran -Xmx512m -XX:+UseSerialGC that way for months
2645        // with nothing in the repo to show it. Use SELF_HOSTED_NODE_JAVA_OPTS
2646        // instead: it is passed here, logged above, and lives in config.
2647        cmd.env_remove("JAVA_TOOL_OPTIONS");
2648        cmd.args(&jvm_args);
2649        cmd.arg("-jar").arg(&config.harness_jar);
2650        cmd.arg("--interactive-server");
2651        cmd.arg("--forge-home")
2652            .arg(format!("{}/", config.assets_dir.display()));
2653
2654        let mut child = cmd
2655            .stdin(Stdio::piped())
2656            .stdout(Stdio::piped())
2657            .stderr(Stdio::piped())
2658            .spawn()
2659            .map_err(|err| format!("failed to spawn java subprocess: {err}"))?;
2660
2661        let stdin = child
2662            .stdin
2663            .take()
2664            .ok_or_else(|| "java subprocess has no stdin".to_string())?;
2665        let stdout = child
2666            .stdout
2667            .take()
2668            .ok_or_else(|| "java subprocess has no stdout".to_string())?;
2669        let stderr = child.stderr.take();
2670
2671        // Bounded so a chatty Java side can't grow the queue without bound.
2672        // Cap is generous — protocol replies are one line per request.
2673        let (stdout_tx, stdout_rx) = std_mpsc::sync_channel::<String>(1024);
2674        let stdout_handle = std::thread::spawn(move || {
2675            let reader = BufReader::new(stdout);
2676            for line in reader.lines().map_while(Result::ok) {
2677                if stdout_tx.send(line).is_err() {
2678                    break;
2679                }
2680            }
2681        });
2682
2683        let stderr_handle = std::thread::spawn(move || {
2684            if let Some(stderr) = stderr {
2685                let reader = BufReader::new(stderr);
2686                for line in reader.lines().map_while(Result::ok) {
2687                    if let Some(pause) = parse_gc_pause(&line) {
2688                        crate::metrics::record_jvm_gc(
2689                            pause.kind,
2690                            Duration::from_secs_f64(pause.millis / 1000.0),
2691                            pause.heap_after_mb,
2692                        );
2693                    }
2694                    if line.contains("Exception") || line.contains("ERROR") {
2695                        warn!(target: "self_hosted_node::java", "[java] {line}");
2696                    } else if line.contains("][gc") {
2697                        // -Xlog:gc*:stderr is opt-in, and debug would drop it before Loki.
2698                        info!(target: "self_hosted_node::java", "[java] {line}");
2699                    } else {
2700                        debug!(target: "self_hosted_node::java", "[java] {line}");
2701                    }
2702                }
2703            }
2704        });
2705
2706        Ok(Self {
2707            child,
2708            stdin: BufWriter::new(stdin),
2709            stdout_rx,
2710            stdout_handle: Some(stdout_handle),
2711            stderr_handle: Some(stderr_handle),
2712        })
2713    }
2714
2715    fn call(&mut self, request_json: &str) -> Result<String, String> {
2716        // Drain anything still queued from a prior request — a previous call()
2717        // that timed out may have left its reply in the channel, and consuming
2718        // it now would shift every subsequent call off-by-one.
2719        loop {
2720            match self.stdout_rx.try_recv() {
2721                Ok(stale) => {
2722                    debug!(target: "self_hosted_node::java", line = %stale, "discarding stale stdout line");
2723                }
2724                Err(TryRecvError::Empty) => break,
2725                Err(TryRecvError::Disconnected) => {
2726                    return Err("java subprocess closed stdout (crashed?)".to_string());
2727                }
2728            }
2729        }
2730
2731        self.stdin
2732            .write_all(request_json.as_bytes())
2733            .map_err(|err| format!("failed to write subprocess stdin: {err}"))?;
2734        self.stdin
2735            .write_all(b"\n")
2736            .map_err(|err| format!("failed to write subprocess newline: {err}"))?;
2737        self.stdin
2738            .flush()
2739            .map_err(|err| format!("failed to flush subprocess stdin: {err}"))?;
2740
2741        let deadline = Instant::now() + CALL_TIMEOUT;
2742        loop {
2743            let remaining = deadline.saturating_duration_since(Instant::now());
2744            if remaining.is_zero() {
2745                return Err(format!(
2746                    "java subprocess timed out after {}s",
2747                    CALL_TIMEOUT.as_secs()
2748                ));
2749            }
2750            match self.stdout_rx.recv_timeout(remaining) {
2751                Ok(line) => {
2752                    let trimmed = line.trim();
2753                    if trimmed.is_empty() {
2754                        continue;
2755                    }
2756                    match serde_json::from_str::<SubprocessReply>(trimmed) {
2757                        Ok(reply) if reply.ok => return Ok(reply.result),
2758                        Ok(reply) => {
2759                            return Err(reply.error.unwrap_or_else(|| "unknown java error".into()));
2760                        }
2761                        Err(_) => {
2762                            debug!(target: "self_hosted_node::java", line = trimmed, "non-protocol stdout line");
2763                        }
2764                    }
2765                }
2766                Err(RecvTimeoutError::Timeout) => {
2767                    return Err(format!(
2768                        "java subprocess timed out after {}s",
2769                        CALL_TIMEOUT.as_secs()
2770                    ));
2771                }
2772                Err(RecvTimeoutError::Disconnected) => {
2773                    return Err("java subprocess closed stdout (crashed?)".to_string());
2774                }
2775            }
2776        }
2777    }
2778
2779    fn reset(&mut self) -> Result<(), String> {
2780        self.call("{\"command\":\"reset\"}").map(|_| ())
2781    }
2782
2783    fn is_alive(&mut self) -> bool {
2784        matches!(self.child.try_wait(), Ok(None))
2785    }
2786
2787    fn shutdown(mut self) {
2788        let _ = self.stdin.write_all(b"{\"command\":\"quit\"}\n");
2789        let _ = self.stdin.flush();
2790        let deadline = Instant::now() + SHUTDOWN_GRACE;
2791        loop {
2792            match self.child.try_wait() {
2793                Ok(Some(_)) => break,
2794                Ok(None) if Instant::now() >= deadline => {
2795                    let _ = self.child.kill();
2796                    let _ = self.child.wait();
2797                    break;
2798                }
2799                Ok(None) => std::thread::sleep(Duration::from_millis(100)),
2800                Err(_) => break,
2801            }
2802        }
2803        if let Some(handle) = self.stdout_handle.take() {
2804            let _ = handle.join();
2805        }
2806        if let Some(handle) = self.stderr_handle.take() {
2807            let _ = handle.join();
2808        }
2809    }
2810}
2811
2812#[cfg(feature = "java-forge")]
2813impl JavaBridge for SubprocessBridge {
2814    fn initialize(&mut self, _assets_dir: &str) -> Result<(), String> {
2815        Ok(())
2816    }
2817
2818    fn start_game_json(&mut self, request_json: &str) -> Result<String, String> {
2819        let body = json!({ "command": "startGame", "payload": request_json });
2820        self.call(&body.to_string())
2821    }
2822
2823    fn submit_action(&mut self, session_id: &str, action_json: &str) -> Result<String, String> {
2824        let body = json!({
2825            "command": "submitAction",
2826            "sessionId": session_id,
2827            "payload": action_json,
2828        });
2829        self.call(&body.to_string())
2830    }
2831
2832    fn get_prompt(
2833        &mut self,
2834        session_id: &str,
2835        player_index: usize,
2836    ) -> Result<Option<String>, String> {
2837        let body = json!({
2838            "command": "getPrompt",
2839            "sessionId": session_id,
2840            "playerIndex": player_index,
2841        });
2842        let prompt = self.call(&body.to_string())?;
2843        Ok((!prompt.is_empty()).then_some(prompt))
2844    }
2845
2846    fn get_snapshot(&mut self, session_id: &str, viewer: Option<usize>) -> Result<String, String> {
2847        let mut body = json!({ "command": "getSnapshot", "sessionId": session_id });
2848        if let Some(viewer) = viewer {
2849            body["viewer"] = json!(viewer);
2850        }
2851        self.call(&body.to_string())
2852    }
2853
2854    fn is_game_over(&mut self, session_id: &str) -> Result<bool, String> {
2855        let body = json!({ "command": "getGameOver", "sessionId": session_id });
2856        let value = self.call(&body.to_string())?;
2857        Ok(value.trim() == "true")
2858    }
2859
2860    fn end_game(&mut self, session_id: &str) -> Result<(), String> {
2861        let body = json!({ "command": "endGame", "sessionId": session_id });
2862        self.call(&body.to_string()).map(|_| ())
2863    }
2864
2865    fn abort_game(&mut self, session_id: &str) -> Result<(), String> {
2866        let body = json!({ "command": "abortGame", "sessionId": session_id });
2867        self.call(&body.to_string()).map(|_| ())
2868    }
2869}
2870
2871#[cfg(feature = "java-forge")]
2872impl Drop for SubprocessBridge {
2873    fn drop(&mut self) {
2874        let _ = self.child.kill();
2875        let _ = self.child.wait();
2876        if let Some(handle) = self.stdout_handle.take() {
2877            let _ = handle.join();
2878        }
2879        if let Some(handle) = self.stderr_handle.take() {
2880            let _ = handle.join();
2881        }
2882    }
2883}
2884
2885#[cfg(feature = "java-forge")]
2886fn resolve_java_bin(config: &JavaRuntimeConfig) -> String {
2887    if let Some(home) = &config.java_home {
2888        let bin = home.join("bin").join("java");
2889        if bin.is_file() {
2890            return bin.to_string_lossy().to_string();
2891        }
2892    }
2893    if let Ok(home) = env::var("JAVA_HOME") {
2894        let bin = PathBuf::from(home).join("bin").join("java");
2895        if bin.is_file() {
2896            return bin.to_string_lossy().to_string();
2897        }
2898    }
2899    "java".to_string()
2900}
2901
2902#[derive(Debug, Clone, Serialize)]
2903#[serde(rename_all = "camelCase")]
2904pub struct StartGameRequest {
2905    game_id: String,
2906    variant: String,
2907    starting_life: i32,
2908    seed: u64,
2909    players: Vec<PlayerConfig>,
2910}
2911
2912#[derive(Debug, Clone, Serialize)]
2913#[serde(rename_all = "camelCase")]
2914pub struct PlayerConfig {
2915    name: String,
2916    deck: Vec<CardIdentityForJava>,
2917    commander_names: Vec<String>,
2918    ai: bool,
2919}
2920
2921#[derive(Debug, Clone, Serialize)]
2922#[serde(rename_all = "camelCase")]
2923pub struct CardIdentityForJava {
2924    name: String,
2925    set_code: Option<String>,
2926    collector_number: Option<String>,
2927    foil: bool,
2928}
2929
2930#[derive(Debug, serde::Deserialize)]
2931#[serde(rename_all = "camelCase")]
2932struct StartGameResponse {
2933    session_id: String,
2934    #[allow(dead_code)]
2935    player_indexes: Vec<usize>,
2936}
2937
2938impl StartGameRequest {
2939    pub fn new(
2940        game_id: String,
2941        variant: String,
2942        starting_life: i32,
2943        seed: u64,
2944        players: Vec<PlayerConfig>,
2945    ) -> Self {
2946        Self {
2947            game_id,
2948            variant,
2949            starting_life,
2950            seed,
2951            players,
2952        }
2953    }
2954
2955    pub fn to_json(&self) -> Result<String, serde_json::Error> {
2956        serde_json::to_string(self)
2957    }
2958}
2959
2960impl PlayerConfig {
2961    pub fn new(name: String, deck: &[DeckCardIdentity], commander_names: Vec<String>) -> Self {
2962        Self {
2963            name,
2964            deck: deck.iter().map(CardIdentityForJava::from).collect(),
2965            commander_names,
2966            ai: false,
2967        }
2968    }
2969}
2970
2971impl From<&DeckCardIdentity> for CardIdentityForJava {
2972    fn from(identity: &DeckCardIdentity) -> Self {
2973        Self {
2974            name: java_card_name(&identity.name),
2975            set_code: (!identity.set_code.is_empty()).then(|| identity.set_code.clone()),
2976            collector_number: (!identity.card_number.is_empty())
2977                .then(|| identity.card_number.clone()),
2978            foil: identity.foil.unwrap_or(false),
2979        }
2980    }
2981}
2982
2983fn java_card_name(name: &str) -> String {
2984    name.split_once(" // ")
2985        .map(|(front, _)| front.to_string())
2986        .unwrap_or_else(|| name.to_string())
2987}
2988
2989fn env_path(key: &str) -> Option<PathBuf> {
2990    env::var_os(key)
2991        .filter(|value| !value.is_empty())
2992        .map(PathBuf::from)
2993}
2994
2995fn env_sizing(key: &str, default: u64) -> Option<u64> {
2996    let value = match env::var(key) {
2997        Ok(raw) if !raw.trim().is_empty() => match raw.trim().parse::<u64>() {
2998            Ok(parsed) => parsed,
2999            Err(_) => {
3000                tracing::warn!(target: "self_hosted_node::java", key, raw, "ignoring unparseable jvm sizing");
3001                default
3002            }
3003        },
3004        _ => default,
3005    };
3006    (value > 0).then_some(value)
3007}
3008
3009fn env_classpath(key: &str) -> Vec<PathBuf> {
3010    let Some(value) = env::var_os(key) else {
3011        return Vec::new();
3012    };
3013    env::split_paths(&value).collect()
3014}
3015
3016fn require_dir(path: &Path, label: &str) -> Result<(), String> {
3017    if path.is_dir() {
3018        Ok(())
3019    } else {
3020        Err(format!("{label} does not exist: {}", path.display()))
3021    }
3022}
3023
3024fn require_file(path: &Path, label: &str) -> Result<(), String> {
3025    if path.is_file() {
3026        Ok(())
3027    } else {
3028        Err(format!("{label} does not exist: {}", path.display()))
3029    }
3030}
3031
3032/// One `Pause` line from the JVM's unified GC log.
3033struct GcPause {
3034    kind: &'static str,
3035    millis: f64,
3036    heap_after_mb: Option<u64>,
3037}
3038
3039/// Parse a unified-log GC pause, e.g.
3040/// `[12.3s][info][gc] GC(7) Pause Full (Allocation Failure) 240M->171M(247M) 226.856ms`
3041fn parse_gc_pause(line: &str) -> Option<GcPause> {
3042    let kind = if line.contains("Pause Full") {
3043        "full"
3044    } else if line.contains("Pause Young") {
3045        "young"
3046    } else {
3047        return None;
3048    };
3049    let millis = line
3050        .rsplit(' ')
3051        .find_map(|token| token.strip_suffix("ms")?.parse::<f64>().ok())?;
3052    // "240M->171M(247M)": the figure after the arrow is what survived.
3053    let heap_after_mb = line.split_once("->").and_then(|(_, rest)| {
3054        let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
3055        digits.parse::<u64>().ok()
3056    });
3057    Some(GcPause {
3058        kind,
3059        millis,
3060        heap_after_mb,
3061    })
3062}
3063
3064#[cfg(test)]
3065mod gc_log_tests {
3066    use super::{parse_gc_pause, PlayerConfig, StartGameRequest};
3067    use manabrew_protocol::deck_dto::DeckCardIdentity;
3068
3069    #[test]
3070    fn reads_a_full_collection() {
3071        let pause = parse_gc_pause(
3072            "[117.6s][info][gc] GC(21) Pause Full (Allocation Failure) 240M->171M(247M) 226.856ms",
3073        )
3074        .expect("parsed");
3075        assert_eq!(pause.kind, "full");
3076        assert_eq!(pause.heap_after_mb, Some(171));
3077        assert!((pause.millis - 226.856).abs() < f64::EPSILON);
3078    }
3079
3080    #[test]
3081    fn reads_a_young_collection() {
3082        let pause = parse_gc_pause(
3083            "[3.1s][info][gc] GC(2) Pause Young (Allocation Failure) 216M->91M(494M) 17.254ms",
3084        )
3085        .expect("parsed");
3086        assert_eq!(pause.kind, "young");
3087        assert_eq!(pause.heap_after_mb, Some(91));
3088    }
3089
3090    /// The node asks for `-Xlog:gc*:stderr:time,uptime,level,tags`, which is a
3091    /// richer line than plain `-Xlog:gc`. Real production output.
3092    #[test]
3093    fn reads_the_format_the_node_actually_requests() {
3094        let pause = parse_gc_pause(
3095            "[2026-08-18T20:41:42.907+0000][120025.819s][info][gc] GC(1202) Pause Full (Allocation Failure) 494M->487M(494M) 545.344ms",
3096        )
3097        .expect("parsed");
3098        assert_eq!(pause.kind, "full");
3099        assert_eq!(pause.heap_after_mb, Some(487));
3100        assert!((pause.millis - 545.344).abs() < f64::EPSILON);
3101    }
3102
3103    /// `gc*` also emits region and metaspace lines that carry `->`; they must
3104    /// not be mistaken for pauses.
3105    #[test]
3106    fn ignores_the_extra_lines_gc_star_adds() {
3107        assert!(
3108            parse_gc_pause("[120025.819s][info][gc,heap] GC(1202) Eden regions: 10->0(12)")
3109                .is_none()
3110        );
3111        assert!(
3112            parse_gc_pause("[120025.819s][info][gc,metaspace] Metaspace: 48M->48M(1088M)")
3113                .is_none()
3114        );
3115    }
3116
3117    #[test]
3118    fn ignores_other_output() {
3119        assert!(parse_gc_pause("[java] LOGGER ERROR: something").is_none());
3120        assert!(parse_gc_pause("[1.0s][info][gc,init] CardTable entry size: 512").is_none());
3121    }
3122
3123    #[test]
3124    fn serializes_exact_printing_identity_for_java() {
3125        let card = DeckCardIdentity {
3126            name: "Forest".to_string(),
3127            set_code: "EOE".to_string(),
3128            card_number: "266".to_string(),
3129            foil: Some(true),
3130            ..Default::default()
3131        };
3132        let request = StartGameRequest::new(
3133            "printing-test".to_string(),
3134            "Constructed".to_string(),
3135            20,
3136            42,
3137            vec![PlayerConfig::new("Player".to_string(), &[card], Vec::new())],
3138        );
3139        let json = request.to_json().expect("serialized");
3140
3141        assert!(json.contains(
3142            r#""deck":[{"name":"Forest","setCode":"EOE","collectorNumber":"266","foil":true}]"#
3143        ));
3144    }
3145}