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
34 changes: 34 additions & 0 deletions crates/adapters/gfa-http/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ fn build_router(
)
.route("/v1/matches", post(create).get(history))
.route("/v1/matches/{id}", get(metadata))
.route("/v1/matches/{id}/resign", post(resign))
.route("/v1/matches/{id}/offer-draw", post(offer_draw))
.route("/v1/matches/{id}/info", get(info::match_info))
.route("/v1/matches/{id}/state", get(state))
.route("/v1/matches/{id}/legal-actions", get(legal_actions))
Expand Down Expand Up @@ -364,3 +366,35 @@ async fn metadata(
) -> Result<Json<gfa_api_types::MatchMetadata>, HttpError> {
Ok(Json(service.get_match(&id(path)?).await?))
}

#[utoipa::path(
post, path = "/v1/matches/{id}/resign", tag = "Matches",
params(("id" = String, Path, description = "Match identifier")),
request_body = gfa_api_types::ControlRequest,
responses((status = 200, description = "Resign a one- or two-seat match.", body = MatchState),
(status = "default", description = "Control or turn error", body = gfa_api_types::ErrorResponse))
)]
async fn resign(
State(service): State<Arc<GameService>>,
path: Id,
body: Result<Json<gfa_api_types::ControlRequest>, JsonRejection>,
) -> Result<Json<MatchState>, HttpError> {
let Json(request) = body?;
Ok(Json(service.resign(&id(path)?, request).await?))
}

#[utoipa::path(
post, path = "/v1/matches/{id}/offer-draw", tag = "Matches",
params(("id" = String, Path, description = "Match identifier")),
request_body = gfa_api_types::ControlRequest,
responses((status = 200, description = "Offer a draw or accept the other seat’s pending offer. A move expires the offer.", body = MatchState),
(status = "default", description = "Control or turn error", body = gfa_api_types::ErrorResponse))
)]
async fn offer_draw(
State(service): State<Arc<GameService>>,
path: Id,
body: Result<Json<gfa_api_types::ControlRequest>, JsonRejection>,
) -> Result<Json<MatchState>, HttpError> {
let Json(request) = body?;
Ok(Json(service.offer_draw(&id(path)?, request).await?))
}
2 changes: 1 addition & 1 deletion crates/adapters/gfa-http/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use utoipa::OpenApi;
info(title = "GamesForAI local API", description = "Single-user local play. Requests require a loopback peer and matching Host and optional Origin. Any local caller can control either seat. Bodies are limited to 64 KiB. Only implemented endpoints are listed."),
servers((url = "/", description = "This local host")),
paths(
super::history, super::metadata, super::health, super::games, super::game, super::create, super::state,
super::resign, super::offer_draw, super::history, super::metadata, super::health, super::games, super::game, super::create, super::state,
super::legal_actions, super::make_move, super::replay, super::validate_position,
super::info::game_info, super::info::match_info, super::stream::upgrade, document
),
Expand Down
14 changes: 6 additions & 8 deletions crates/adapters/gfa-http/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ impl Subscription {
if *self.shutdown.borrow() {
return;
}
let mut turn = initial.turn;
let mut last = initial.clone();
if !send_state(&mut socket, &initial).await {
return;
}
Expand Down Expand Up @@ -186,16 +186,14 @@ impl Subscription {
};
match replay {
Ok(replay) => {
let previous_turn = turn;
for state in replay
.states
.iter()
.filter(|state| state.turn > previous_turn)
{
for state in &replay.states {
if state.turn < last.turn || (state.turn == last.turn && state == &last) {
continue;
}
if *self.shutdown.borrow() || !send_state(&mut socket, state).await {
return;
}
turn = state.turn;
last = state.clone();
if state.terminated || state.truncated {
let _ = send(&mut socket, Message::Close(None)).await;
return;
Expand Down
2 changes: 2 additions & 0 deletions crates/adapters/gfa-store/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ fn command(key: &str) -> StoredCommand {
returns: vec![0.0, 0.0],
terminated: false,
truncated: false,
outcome: None,
draw_offer: None,
},
},
}
Expand Down
4 changes: 3 additions & 1 deletion crates/apps/gfa-server/src/openapi_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ async fn served_openapi_matches_real_requests_responses_and_conditional_formats(
("/v1/matches", "post"),
("/v1/matches", "get"),
("/v1/matches/{id}", "get"),
("/v1/matches/{id}/resign", "post"),
("/v1/matches/{id}/offer-draw", "post"),
("/v1/matches/{id}/info", "get"),
("/v1/matches/{id}/state", "get"),
("/v1/matches/{id}/actions", "post"),
Expand All @@ -75,7 +77,7 @@ async fn served_openapi_matches_real_requests_responses_and_conditional_formats(
"missing {method} {path}"
);
}
assert_eq!(doc["paths"].as_object().map(serde_json::Map::len), Some(14));
assert_eq!(doc["paths"].as_object().map(serde_json::Map::len), Some(16));
let game_info = &doc["paths"]["/v1/games/{game_id}/info"]["get"];
assert!(game_info["responses"]["200"]["content"]["text/markdown"].is_object());
assert!(game_info["responses"]["304"].get("content").is_none());
Expand Down
44 changes: 44 additions & 0 deletions crates/apps/gfa-server/src/stream_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,47 @@ async fn terminal_state_is_delivered_before_the_stream_closes() -> TestResult {
tokio::time::timeout(Duration::from_secs(5), task).await???;
Ok(())
}

#[tokio::test]
async fn control_updates_are_delivered_even_when_turn_does_not_change() -> TestResult {
use gfa_api_types::ControlRequest;
let directory = tempfile::tempdir()?;
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let address = listener.local_addr()?;
let app = application(
&Config {
sqlite: directory.path().join("controls.sqlite"),
port: 0,
},
address,
)
.await?;
let service = app.service.clone();
let id = create(&service).await?;
let (stop, stopped) = oneshot::channel();
let task = tokio::spawn(serve_application(listener, app, async {
let _ = stopped.await;
}));
let (mut client, _) = connect_async(format!("ws://{address}/v1/matches/{id}/stream")).await?;
assert_eq!(next_state(&mut client).await?["turn"], 0);
service
.offer_draw(&id, ControlRequest { seat: 0, turn: 0 })
.await?;
let offered = next_state(&mut client).await?;
assert_eq!(offered["turn"], 0);
assert_eq!(offered["draw_offer"], 0);
service
.offer_draw(&id, ControlRequest { seat: 1, turn: 0 })
.await?;
let ended = next_state(&mut client).await?;
assert_eq!(ended["turn"], 0);
assert_eq!(ended["terminated"], true);
assert_eq!(ended["outcome"]["reason"], "agreed_draw");
assert!(matches!(
tokio::time::timeout(Duration::from_secs(3), client.next()).await?,
Some(Ok(Message::Close(_)))
));
stop.send(()).map_err(|_| "shutdown receiver closed")?;
tokio::time::timeout(Duration::from_secs(5), task).await???;
Ok(())
}
171 changes: 171 additions & 0 deletions crates/apps/gfa-server/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,3 +463,174 @@ async fn metadata_and_history_paginate_filter_and_survive_restart() -> TestResul
app.store.close().await;
Ok(())
}

#[tokio::test]
async fn resignation_and_draw_agreements_replay_after_restart() -> TestResult {
let directory = tempfile::tempdir()?;
let settings = config(&directory);
let app = fixture(&settings).await?;
let (_, initial) = call(
&app.router,
"POST",
"/v1/matches",
json!({"game_id":"tictactoe"}),
None,
)
.await?;
let id = match_id(&initial)?;
let (status, resigned) = call(
&app.router,
"POST",
&format!("/v1/matches/{id}/resign"),
json!({"seat":1,"turn":0}),
None,
)
.await?;
assert_eq!(status, StatusCode::OK);
assert_eq!(resigned["turn"], 0);
assert_eq!(resigned["observation"], initial["observation"]);
assert_eq!(resigned["returns"], json!([1.0, -1.0]));
assert_eq!(resigned["outcome"], json!({"reason":"resigned","seat":1}));
assert_eq!(resigned["terminated"], true);
assert_eq!(resigned["legal_actions"], json!([]));
let (status, _) = call(
&app.router,
"POST",
&format!("/v1/matches/{id}/actions"),
json!({"seat":0,"turn":0,"action":"r1c1"}),
None,
)
.await?;
assert_eq!(status, StatusCode::CONFLICT);
let (_, created) = call(
&app.router,
"POST",
"/v1/matches",
json!({"game_id":"connect4"}),
None,
)
.await?;
let drawn_id = match_id(&created)?;
let offer_url = format!("/v1/matches/{drawn_id}/offer-draw");
let (_, offered) = call(
&app.router,
"POST",
&offer_url,
json!({"seat":0,"turn":0}),
None,
)
.await?;
assert_eq!(offered["draw_offer"], 0);
assert_eq!(offered["terminated"], false);
let (_, duplicate) = call(
&app.router,
"POST",
&offer_url,
json!({"seat":0,"turn":0}),
None,
)
.await?;
assert_eq!(offered, duplicate);
let (_, drawn) = call(
&app.router,
"POST",
&offer_url,
json!({"seat":1,"turn":0}),
None,
)
.await?;
assert_eq!(drawn["outcome"], json!({"reason":"agreed_draw"}));
assert_eq!(drawn["returns"], json!([0.0, 0.0]));
assert!(drawn.get("draw_offer").is_none());
let (_, doc) = call(&app.router, "GET", "/v1/openapi.json", Value::Null, None).await?;
assert!(doc["components"]["schemas"]["MatchOutcome"].is_object());
app.store.close().await;
let app = fixture(&settings).await?;
for (id, expected) in [(id, resigned), (drawn_id, drawn)] {
let (_, replay) = call(
&app.router,
"GET",
&format!("/v1/matches/{id}/replay?seat=1"),
Value::Null,
None,
)
.await?;
assert_eq!(replay["states"], json!([expected]));
let (_, metadata) = call(
&app.router,
"GET",
&format!("/v1/matches/{id}"),
Value::Null,
None,
)
.await?;
assert_eq!(metadata["status"], "finished");
assert_eq!(metadata["outcome"], expected["outcome"]);
}
app.store.close().await;
Ok(())
}

#[tokio::test]
async fn controls_validate_turns_expire_offers_and_commit_once_under_races() -> TestResult {
use gfa_api_types::{ControlRequest, MoveRequest};
let directory = tempfile::tempdir()?;
let app = fixture(&config(&directory)).await?;
let (_, initial) = call(
&app.router,
"POST",
"/v1/matches",
json!({"game_id":"tictactoe"}),
None,
)
.await?;
let id = match_id(&initial)?;
for (request, expected) in [
(json!({"seat":0,"turn":1}), StatusCode::CONFLICT),
(json!({"seat":9,"turn":0}), StatusCode::UNPROCESSABLE_ENTITY),
(json!({"seat":0}), StatusCode::UNPROCESSABLE_ENTITY),
] {
let (status, _) = call(
&app.router,
"POST",
&format!("/v1/matches/{id}/resign"),
request,
None,
)
.await?;
assert_eq!(status, expected);
}
app.service
.offer_draw(&id, ControlRequest { seat: 0, turn: 0 })
.await?;
app.service
.make_move(
&id,
MoveRequest {
seat: 0,
turn: 0,
action: json!("r2c2"),
reasoning: None,
},
None,
)
.await?;
let offer = app
.service
.offer_draw(&id, ControlRequest { seat: 1, turn: 1 })
.await?;
assert!(!offer.terminated);
assert_eq!(offer.draw_offer, Some(1));
let (a, b) = tokio::join!(
app.service.resign(&id, ControlRequest { seat: 0, turn: 1 }),
app.service.resign(&id, ControlRequest { seat: 1, turn: 1 }),
);
assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1);
assert!(a
.err()
.into_iter()
.chain(b.err())
.all(|error| error.code == "STALE_TURN" || error.code == "MATCH_FINISHED"));
app.store.close().await;
Ok(())
}
30 changes: 30 additions & 0 deletions crates/domain/gfa-api-types/src/controls.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//! Match controls and outcomes separate from engine moves.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// A seat-scoped control command against an expected turn.
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ControlRequest {
/// Seat sending this command.
#[cfg_attr(feature = "openapi", schema(minimum = 0, maximum = 255))]
pub seat: u8,
/// Expected accepted-action count.
#[cfg_attr(feature = "openapi", schema(minimum = 0))]
pub turn: u64,
}

/// An explicit ending caused by a match control.
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "reason", rename_all = "snake_case")]
pub enum MatchOutcome {
/// One seat resigned; the engine position remains unchanged.
Resigned {
/// Resigning seat.
seat: u8,
},
/// Both seats agreed to draw.
AgreedDraw,
}
6 changes: 6 additions & 0 deletions crates/domain/gfa-api-types/src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ pub struct MatchMetadata {
pub terminated: bool,
/// A length or external cap ended play.
pub truncated: bool,
/// Explicit control ending, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<crate::MatchOutcome>,
/// Seat with a pending draw offer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draw_offer: Option<u8>,
/// Creation timestamp in Unix milliseconds.
pub created_at_ms: u64,
}
Expand Down
9 changes: 9 additions & 0 deletions crates/domain/gfa-api-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ pub struct MatchState {
pub terminated: bool,
/// A length or other external cap ended play.
pub truncated: bool,
/// Explicit control ending, absent for ordinary engine results.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<MatchOutcome>,
/// Seat with a pending draw offer; a move expires the offer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draw_offer: Option<u8>,
}

/// One accepted action and the resulting view.
Expand Down Expand Up @@ -255,3 +261,6 @@ pub enum StreamMessage {

mod history;
pub use history::{MatchHistory, MatchHistoryQuery, MatchMetadata, MatchStatus};

mod controls;
pub use controls::{ControlRequest, MatchOutcome};
Loading
Loading