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