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