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