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