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