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