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