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