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