Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions crates/apps/gfa-server/src/briefing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,3 +427,81 @@ async fn chess_briefings_fit_prompt_budgets_without_changing_live_state() -> Tes
store.close().await;
Ok(())
}

#[tokio::test]
async fn briefings_use_installed_catalog_and_match_assists() -> TestResult {
let directory = tempfile::tempdir()?;
let app = fixture(&config(&directory)).await?;
for game in ["tictactoe", "connect4", "sudoku", "chess"] {
let full =
app.service
.get_game_info(game, &json!({}), None, gfa_api_types::InfoDetail::Full)?;
let expected = serde_json::to_value(app.service.list_opponents(game)?)?;
assert_eq!(data(&full, "opponents")?["available"], expected);
assert_eq!(data(&full, "opponents")?["analysis"], true);
let compact = app.service.get_game_info(
game,
&json!({}),
None,
gfa_api_types::InfoDetail::Compact,
)?;
let ids = |value: &Value| -> Vec<String> {
value
.as_array()
.into_iter()
.flatten()
.filter_map(|entry| entry["id"].as_str().map(str::to_owned))
.collect()
};
assert_eq!(
ids(&data(&compact, "opponents")?["available"]),
ids(&expected)
);
assert!(
compact.approx_tokens < 1500,
"{game}: {}",
compact.approx_tokens
);
}
for allowed in [false, true] {
let request = serde_json::from_value(json!({
"game_id":"tictactoe","seed":4,
"assists":{"allow_analysis":allowed,"allow_simulation":allowed}
}))?;
let created = app
.service
.create_match_with_info(request, gfa_core::Viewer::Player(0))
.await?;
let creation = created.info.ok_or("creation briefing")?;
let read = app
.service
.get_match_info(
&created.state.match_id,
gfa_core::Viewer::Player(0),
gfa_api_types::InfoDetail::Compact,
)
.await?;
assert_eq!(creation, read);
for info in [&creation, &read] {
assert_eq!(data(info, "opponents")?["analysis_available"], true);
assert_eq!(data(info, "opponents")?["analysis"], allowed);
assert_eq!(data(info, "opponents")?["simulation"], allowed);
}
let (status, forked) = call(
&app.router,
"POST",
&format!("/v1/matches/{}/fork?seat=0", created.state.match_id),
json!({"turn":0}),
None,
)
.await?;
assert_eq!(status, StatusCode::CREATED);
let fork_info: Briefing = serde_json::from_value(forked["info"].clone())?;
assert_eq!(
data(&fork_info, "opponents")?,
data(&creation, "opponents")?
);
}
app.store.close().await;
Ok(())
}
4 changes: 2 additions & 2 deletions crates/domain/gfa-service/src/briefing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ fn game_info_with_viewer(
config_data["schema"] = spec.config_schema;
}
let mut result = Briefing {
info_version: format!("{}+briefing.2", spec.info_version),
info_version: format!("{}+briefing.3", spec.info_version),
approx_tokens: 0,
sections: vec![
section("identity", spec.summary, json!({
Expand Down Expand Up @@ -221,7 +221,7 @@ fn game_info_with_viewer(
section("time_controls", "Untimed play only; no running clocks or timeout penalties.", json!({"supported":["none"],"default":"none"})),
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})),
section("illegal_move_policy", "Reject without changing state; retry using the returned hint and current legal actions.", json!({"supported":["reject"],"default":"reject"})),
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!({
section("how_to_play", "Create a match, then submit moves for seats in to_act until terminated or truncated.", json!({
"create":{"method":"POST","path":"/v1/matches","body":{"game_id":spec.id,"config":config}},
"state":"GET /v1/matches/{id}/state?seat={seat}",
"move":"POST /v1/matches/{id}/actions with {seat,turn,action,reasoning?}",
Expand Down
23 changes: 12 additions & 11 deletions crates/domain/gfa-service/src/fork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,18 @@ impl GameService {
.await?;
let state = progress.frame.project(&child_id, game, access.viewer)?;
let info = if request.include_info {
Some(
crate::briefing::MatchBrief {
game,
origin: &child_origin,
initial: &child,
current: &progress.frame,
id: &child_id,
viewer: access.viewer,
}
.build(gfa_api_types::InfoDetail::Compact)?,
)
let detail = gfa_api_types::InfoDetail::Compact;
let mut info = crate::briefing::MatchBrief {
game,
origin: &child_origin,
initial: &child,
current: &progress.frame,
id: &child_id,
viewer: access.viewer,
}
.build(detail)?;
self.briefing_opponents(&mut info, game, detail, Some(&child_origin.assists))?;
Some(info)
} else {
None
};
Expand Down
50 changes: 50 additions & 0 deletions crates/domain/gfa-service/src/opponents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,3 +453,53 @@ mod analysis_validation_tests {
assert!(validate_analysis_choices(&[bad], &legal).is_err());
}
}

impl GameService {
pub(crate) fn briefing_opponents(
&self,
info: &mut gfa_api_types::Briefing,
game: &dyn DynGame,
detail: gfa_api_types::InfoDetail,
assists: Option<&gfa_api_types::Assists>,
) -> Result<(), ApiError> {
let catalog = self
.opponents
.as_ref()
.map_or_else(Vec::new, |(factory, _)| factory.catalog(game));
let analysis_available =
self.opponents.is_some() && game.spec().information == Information::Perfect;
let available = if detail == gfa_api_types::InfoDetail::Full {
serde_json::to_value(&catalog).map_err(|_| crate::briefing::internal())?
} else {
serde_json::json!(catalog
.iter()
.map(|opponent| serde_json::json!({
"id":opponent.id,
"levels":opponent.levels.iter().map(|level|level.level).collect::<Vec<_>>(),
"calibrated":opponent.calibrated,
"ratings":if opponent.levels.iter().any(|level|level.rating.is_some()) {
serde_json::json!(opponent.levels)
} else { Value::Null }
}))
.collect::<Vec<_>>())
};
let section = info
.sections
.iter_mut()
.find(|section| section.id == "opponents")
.ok_or_else(crate::briefing::internal)?;
section.text = if catalog.is_empty() {
"No opponent workers are installed on this host. External clients control the seats."
.into()
} else {
"Installed opponents support automatic play and permitted analysis. Null ratings are uncalibrated.".into()
};
section.data = serde_json::json!({
"available":available,"analysis_available":analysis_available,
"analysis":analysis_available && assists.is_none_or(|assists|assists.allow_analysis),
"simulation":assists.is_none_or(|assists|assists.allow_simulation)
});
info.estimate_tokens()
.map_err(|_| crate::briefing::internal())
}
}
38 changes: 24 additions & 14 deletions crates/domain/gfa-service/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ impl GameService {
detail: gfa_api_types::InfoDetail,
) -> Result<gfa_api_types::Briefing, ApiError> {
let game = self.registry.get(game_id).map_err(error::engine)?;
crate::briefing::game_info(game.as_ref(), config, seat, detail)
let mut info = crate::briefing::game_info(game.as_ref(), config, seat, detail)?;
self.briefing_opponents(&mut info, game.as_ref(), detail, None)?;
Ok(info)
}

/// Validate a standalone position and return canonical encodings without persistence.
Expand Down Expand Up @@ -174,17 +176,18 @@ impl GameService {
.await?;
let state = progress.frame.project(&id, game.as_ref(), viewer)?;
let info = if include_info {
Some(
crate::briefing::MatchBrief {
game: game.as_ref(),
origin: &origin,
initial: &frame,
current: &progress.frame,
id: &id,
viewer,
}
.build(gfa_api_types::InfoDetail::Compact)?,
)
let detail = gfa_api_types::InfoDetail::Compact;
let mut info = crate::briefing::MatchBrief {
game: game.as_ref(),
origin: &origin,
initial: &frame,
current: &progress.frame,
id: &id,
viewer,
}
.build(detail)?;
self.briefing_opponents(&mut info, game.as_ref(), detail, Some(&origin.assists))?;
Some(info)
} else {
None
};
Expand All @@ -205,15 +208,22 @@ impl GameService {
let Some(origin) = record.origin() else {
return Err(error::corrupt());
};
crate::briefing::MatchBrief {
let mut info = crate::briefing::MatchBrief {
game: replay.game.as_ref(),
origin,
initial: replay.frames.first().ok_or_else(error::corrupt)?,
current: replay.current()?,
id,
viewer,
}
.build(detail)
.build(detail)?;
self.briefing_opponents(
&mut info,
replay.game.as_ref(),
detail,
Some(&origin.assists),
)?;
Ok(info)
}

/// Read public metadata from one consistent event snapshot.
Expand Down
Loading