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