Skip to main content

self_hosted_node/engine_backend/
java_backend.rs

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