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