Skip to content

Commit c1be539

Browse files
authored
Merge pull request #50 from CurtisTechSolutions/fix/briefing-opponent-catalog
fix(info): report installed opponents and match assist permissions
2 parents 75bd92c + 4e19345 commit c1be539

5 files changed

Lines changed: 166 additions & 27 deletions

File tree

‎crates/apps/gfa-server/src/briefing_tests.rs‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,81 @@ async fn chess_briefings_fit_prompt_budgets_without_changing_live_state() -> Tes
427427
store.close().await;
428428
Ok(())
429429
}
430+
431+
#[tokio::test]
432+
async fn briefings_use_installed_catalog_and_match_assists() -> TestResult {
433+
let directory = tempfile::tempdir()?;
434+
let app = fixture(&config(&directory)).await?;
435+
for game in ["tictactoe", "connect4", "sudoku", "chess"] {
436+
let full =
437+
app.service
438+
.get_game_info(game, &json!({}), None, gfa_api_types::InfoDetail::Full)?;
439+
let expected = serde_json::to_value(app.service.list_opponents(game)?)?;
440+
assert_eq!(data(&full, "opponents")?["available"], expected);
441+
assert_eq!(data(&full, "opponents")?["analysis"], true);
442+
let compact = app.service.get_game_info(
443+
game,
444+
&json!({}),
445+
None,
446+
gfa_api_types::InfoDetail::Compact,
447+
)?;
448+
let ids = |value: &Value| -> Vec<String> {
449+
value
450+
.as_array()
451+
.into_iter()
452+
.flatten()
453+
.filter_map(|entry| entry["id"].as_str().map(str::to_owned))
454+
.collect()
455+
};
456+
assert_eq!(
457+
ids(&data(&compact, "opponents")?["available"]),
458+
ids(&expected)
459+
);
460+
assert!(
461+
compact.approx_tokens < 1500,
462+
"{game}: {}",
463+
compact.approx_tokens
464+
);
465+
}
466+
for allowed in [false, true] {
467+
let request = serde_json::from_value(json!({
468+
"game_id":"tictactoe","seed":4,
469+
"assists":{"allow_analysis":allowed,"allow_simulation":allowed}
470+
}))?;
471+
let created = app
472+
.service
473+
.create_match_with_info(request, gfa_core::Viewer::Player(0))
474+
.await?;
475+
let creation = created.info.ok_or("creation briefing")?;
476+
let read = app
477+
.service
478+
.get_match_info(
479+
&created.state.match_id,
480+
gfa_core::Viewer::Player(0),
481+
gfa_api_types::InfoDetail::Compact,
482+
)
483+
.await?;
484+
assert_eq!(creation, read);
485+
for info in [&creation, &read] {
486+
assert_eq!(data(info, "opponents")?["analysis_available"], true);
487+
assert_eq!(data(info, "opponents")?["analysis"], allowed);
488+
assert_eq!(data(info, "opponents")?["simulation"], allowed);
489+
}
490+
let (status, forked) = call(
491+
&app.router,
492+
"POST",
493+
&format!("/v1/matches/{}/fork?seat=0", created.state.match_id),
494+
json!({"turn":0}),
495+
None,
496+
)
497+
.await?;
498+
assert_eq!(status, StatusCode::CREATED);
499+
let fork_info: Briefing = serde_json::from_value(forked["info"].clone())?;
500+
assert_eq!(
501+
data(&fork_info, "opponents")?,
502+
data(&creation, "opponents")?
503+
);
504+
}
505+
app.store.close().await;
506+
Ok(())
507+
}

‎crates/domain/gfa-service/src/briefing.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ fn game_info_with_viewer(
193193
config_data["schema"] = spec.config_schema;
194194
}
195195
let mut result = Briefing {
196-
info_version: format!("{}+briefing.2", spec.info_version),
196+
info_version: format!("{}+briefing.3", spec.info_version),
197197
approx_tokens: 0,
198198
sections: vec![
199199
section("identity", spec.summary, json!({
@@ -221,7 +221,7 @@ fn game_info_with_viewer(
221221
section("time_controls", "Untimed play only; no running clocks or timeout penalties.", json!({"supported":["none"],"default":"none"})),
222222
section("opponents", "Local clients control the seats. Simulation is available when the match allows it. Built-in opponent scheduling and analysis are not installed.", json!({"available":[],"analysis":false,"simulation":true})),
223223
section("illegal_move_policy", "Reject without changing state; retry using the returned hint and current legal actions.", json!({"supported":["reject"],"default":"reject"})),
224-
section("how_to_play", "Create a match, then submit moves for seats in to_act until terminated or truncated. MCP is not available yet.", json!({
224+
section("how_to_play", "Create a match, then submit moves for seats in to_act until terminated or truncated.", json!({
225225
"create":{"method":"POST","path":"/v1/matches","body":{"game_id":spec.id,"config":config}},
226226
"state":"GET /v1/matches/{id}/state?seat={seat}",
227227
"move":"POST /v1/matches/{id}/actions with {seat,turn,action,reasoning?}",

‎crates/domain/gfa-service/src/fork.rs‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,17 +130,18 @@ impl GameService {
130130
.await?;
131131
let state = progress.frame.project(&child_id, game, access.viewer)?;
132132
let info = if request.include_info {
133-
Some(
134-
crate::briefing::MatchBrief {
135-
game,
136-
origin: &child_origin,
137-
initial: &child,
138-
current: &progress.frame,
139-
id: &child_id,
140-
viewer: access.viewer,
141-
}
142-
.build(gfa_api_types::InfoDetail::Compact)?,
143-
)
133+
let detail = gfa_api_types::InfoDetail::Compact;
134+
let mut info = crate::briefing::MatchBrief {
135+
game,
136+
origin: &child_origin,
137+
initial: &child,
138+
current: &progress.frame,
139+
id: &child_id,
140+
viewer: access.viewer,
141+
}
142+
.build(detail)?;
143+
self.briefing_opponents(&mut info, game, detail, Some(&child_origin.assists))?;
144+
Some(info)
144145
} else {
145146
None
146147
};

‎crates/domain/gfa-service/src/opponents.rs‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,3 +453,53 @@ mod analysis_validation_tests {
453453
assert!(validate_analysis_choices(&[bad], &legal).is_err());
454454
}
455455
}
456+
457+
impl GameService {
458+
pub(crate) fn briefing_opponents(
459+
&self,
460+
info: &mut gfa_api_types::Briefing,
461+
game: &dyn DynGame,
462+
detail: gfa_api_types::InfoDetail,
463+
assists: Option<&gfa_api_types::Assists>,
464+
) -> Result<(), ApiError> {
465+
let catalog = self
466+
.opponents
467+
.as_ref()
468+
.map_or_else(Vec::new, |(factory, _)| factory.catalog(game));
469+
let analysis_available =
470+
self.opponents.is_some() && game.spec().information == Information::Perfect;
471+
let available = if detail == gfa_api_types::InfoDetail::Full {
472+
serde_json::to_value(&catalog).map_err(|_| crate::briefing::internal())?
473+
} else {
474+
serde_json::json!(catalog
475+
.iter()
476+
.map(|opponent| serde_json::json!({
477+
"id":opponent.id,
478+
"levels":opponent.levels.iter().map(|level|level.level).collect::<Vec<_>>(),
479+
"calibrated":opponent.calibrated,
480+
"ratings":if opponent.levels.iter().any(|level|level.rating.is_some()) {
481+
serde_json::json!(opponent.levels)
482+
} else { Value::Null }
483+
}))
484+
.collect::<Vec<_>>())
485+
};
486+
let section = info
487+
.sections
488+
.iter_mut()
489+
.find(|section| section.id == "opponents")
490+
.ok_or_else(crate::briefing::internal)?;
491+
section.text = if catalog.is_empty() {
492+
"No opponent workers are installed on this host. External clients control the seats."
493+
.into()
494+
} else {
495+
"Installed opponents support automatic play and permitted analysis. Null ratings are uncalibrated.".into()
496+
};
497+
section.data = serde_json::json!({
498+
"available":available,"analysis_available":analysis_available,
499+
"analysis":analysis_available && assists.is_none_or(|assists|assists.allow_analysis),
500+
"simulation":assists.is_none_or(|assists|assists.allow_simulation)
501+
});
502+
info.estimate_tokens()
503+
.map_err(|_| crate::briefing::internal())
504+
}
505+
}

‎crates/domain/gfa-service/src/service.rs‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ impl GameService {
6666
detail: gfa_api_types::InfoDetail,
6767
) -> Result<gfa_api_types::Briefing, ApiError> {
6868
let game = self.registry.get(game_id).map_err(error::engine)?;
69-
crate::briefing::game_info(game.as_ref(), config, seat, detail)
69+
let mut info = crate::briefing::game_info(game.as_ref(), config, seat, detail)?;
70+
self.briefing_opponents(&mut info, game.as_ref(), detail, None)?;
71+
Ok(info)
7072
}
7173

7274
/// Validate a standalone position and return canonical encodings without persistence.
@@ -174,17 +176,18 @@ impl GameService {
174176
.await?;
175177
let state = progress.frame.project(&id, game.as_ref(), viewer)?;
176178
let info = if include_info {
177-
Some(
178-
crate::briefing::MatchBrief {
179-
game: game.as_ref(),
180-
origin: &origin,
181-
initial: &frame,
182-
current: &progress.frame,
183-
id: &id,
184-
viewer,
185-
}
186-
.build(gfa_api_types::InfoDetail::Compact)?,
187-
)
179+
let detail = gfa_api_types::InfoDetail::Compact;
180+
let mut info = crate::briefing::MatchBrief {
181+
game: game.as_ref(),
182+
origin: &origin,
183+
initial: &frame,
184+
current: &progress.frame,
185+
id: &id,
186+
viewer,
187+
}
188+
.build(detail)?;
189+
self.briefing_opponents(&mut info, game.as_ref(), detail, Some(&origin.assists))?;
190+
Some(info)
188191
} else {
189192
None
190193
};
@@ -205,15 +208,22 @@ impl GameService {
205208
let Some(origin) = record.origin() else {
206209
return Err(error::corrupt());
207210
};
208-
crate::briefing::MatchBrief {
211+
let mut info = crate::briefing::MatchBrief {
209212
game: replay.game.as_ref(),
210213
origin,
211214
initial: replay.frames.first().ok_or_else(error::corrupt)?,
212215
current: replay.current()?,
213216
id,
214217
viewer,
215218
}
216-
.build(detail)
219+
.build(detail)?;
220+
self.briefing_opponents(
221+
&mut info,
222+
replay.game.as_ref(),
223+
detail,
224+
Some(&origin.assists),
225+
)?;
226+
Ok(info)
217227
}
218228

219229
/// Read public metadata from one consistent event snapshot.

0 commit comments

Comments
 (0)