Skip to content

Commit 4e885f7

Browse files
committed
feat: persist resignation and draw agreement controls
1 parent a68eba6 commit 4e885f7

16 files changed

Lines changed: 354 additions & 12 deletions

File tree

‎crates/adapters/gfa-http/src/lib.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ fn build_router(
8989
)
9090
.route("/v1/matches", post(create).get(history))
9191
.route("/v1/matches/{id}", get(metadata))
92+
.route("/v1/matches/{id}/resign", post(resign))
93+
.route("/v1/matches/{id}/offer-draw", post(offer_draw))
9294
.route("/v1/matches/{id}/info", get(info::match_info))
9395
.route("/v1/matches/{id}/state", get(state))
9496
.route("/v1/matches/{id}/legal-actions", get(legal_actions))
@@ -355,3 +357,35 @@ async fn metadata(
355357
) -> Result<Json<gfa_api_types::MatchMetadata>, HttpError> {
356358
Ok(Json(service.get_match(&id(path)?).await?))
357359
}
360+
361+
#[utoipa::path(
362+
post, path = "/v1/matches/{id}/resign", tag = "Matches",
363+
params(("id" = String, Path, description = "Match identifier")),
364+
request_body = gfa_api_types::ControlRequest,
365+
responses((status = 200, description = "Resign a one- or two-seat match.", body = MatchState),
366+
(status = "default", description = "Control or turn error", body = gfa_api_types::ErrorResponse))
367+
)]
368+
async fn resign(
369+
State(service): State<Arc<GameService>>,
370+
path: Id,
371+
body: Result<Json<gfa_api_types::ControlRequest>, JsonRejection>,
372+
) -> Result<Json<MatchState>, HttpError> {
373+
let Json(request) = body?;
374+
Ok(Json(service.resign(&id(path)?, request).await?))
375+
}
376+
377+
#[utoipa::path(
378+
post, path = "/v1/matches/{id}/offer-draw", tag = "Matches",
379+
params(("id" = String, Path, description = "Match identifier")),
380+
request_body = gfa_api_types::ControlRequest,
381+
responses((status = 200, description = "Offer a draw or accept the other seat’s pending offer. A move expires the offer.", body = MatchState),
382+
(status = "default", description = "Control or turn error", body = gfa_api_types::ErrorResponse))
383+
)]
384+
async fn offer_draw(
385+
State(service): State<Arc<GameService>>,
386+
path: Id,
387+
body: Result<Json<gfa_api_types::ControlRequest>, JsonRejection>,
388+
) -> Result<Json<MatchState>, HttpError> {
389+
let Json(request) = body?;
390+
Ok(Json(service.offer_draw(&id(path)?, request).await?))
391+
}

‎crates/adapters/gfa-http/src/openapi.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use utoipa::OpenApi;
66
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."),
77
servers((url = "/", description = "This local host")),
88
paths(
9-
super::history, super::metadata, super::health, super::games, super::game, super::create, super::state,
9+
super::resign, super::offer_draw, super::history, super::metadata, super::health, super::games, super::game, super::create, super::state,
1010
super::legal_actions, super::make_move, super::replay, super::validate_position,
1111
super::info::game_info, super::info::match_info, super::stream::upgrade, document
1212
),

‎crates/adapters/gfa-http/src/stream.rs‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ impl Subscription {
145145
if *self.shutdown.borrow() {
146146
return;
147147
}
148-
let mut turn = initial.turn;
148+
let mut last = initial.clone();
149149
if !send_state(&mut socket, &initial).await {
150150
return;
151151
}
@@ -186,16 +186,14 @@ impl Subscription {
186186
};
187187
match replay {
188188
Ok(replay) => {
189-
let previous_turn = turn;
190-
for state in replay
191-
.states
192-
.iter()
193-
.filter(|state| state.turn > previous_turn)
194-
{
189+
for state in &replay.states {
190+
if state.turn < last.turn || (state.turn == last.turn && state == &last) {
191+
continue;
192+
}
195193
if *self.shutdown.borrow() || !send_state(&mut socket, state).await {
196194
return;
197195
}
198-
turn = state.turn;
196+
last = state.clone();
199197
if state.terminated || state.truncated {
200198
let _ = send(&mut socket, Message::Close(None)).await;
201199
return;

‎crates/adapters/gfa-store/src/tests.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ fn command(key: &str) -> StoredCommand {
5050
returns: vec![0.0, 0.0],
5151
terminated: false,
5252
truncated: false,
53+
outcome: None,
54+
draw_offer: None,
5355
},
5456
},
5557
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ async fn served_openapi_matches_real_requests_responses_and_conditional_formats(
6161
("/v1/matches", "post"),
6262
("/v1/matches", "get"),
6363
("/v1/matches/{id}", "get"),
64+
("/v1/matches/{id}/resign", "post"),
65+
("/v1/matches/{id}/offer-draw", "post"),
6466
("/v1/matches/{id}/info", "get"),
6567
("/v1/matches/{id}/state", "get"),
6668
("/v1/matches/{id}/actions", "post"),
@@ -75,7 +77,7 @@ async fn served_openapi_matches_real_requests_responses_and_conditional_formats(
7577
"missing {method} {path}"
7678
);
7779
}
78-
assert_eq!(doc["paths"].as_object().map(serde_json::Map::len), Some(14));
80+
assert_eq!(doc["paths"].as_object().map(serde_json::Map::len), Some(16));
7981
let game_info = &doc["paths"]["/v1/games/{game_id}/info"]["get"];
8082
assert!(game_info["responses"]["200"]["content"]["text/markdown"].is_object());
8183
assert!(game_info["responses"]["304"].get("content").is_none());

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,34 @@ async fn terminal_state_is_delivered_before_the_stream_closes() -> TestResult {
270270
tokio::time::timeout(Duration::from_secs(5), task).await???;
271271
Ok(())
272272
}
273+
274+
#[tokio::test]
275+
async fn control_updates_are_delivered_even_when_turn_does_not_change() -> TestResult {
276+
use gfa_api_types::ControlRequest;
277+
let directory = tempfile::tempdir()?;
278+
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
279+
let address = listener.local_addr()?;
280+
let app = application(&Config { sqlite: directory.path().join("controls.sqlite"), port: 0 }, address).await?;
281+
let service = app.service.clone();
282+
let id = create(&service).await?;
283+
let (stop, stopped) = oneshot::channel();
284+
let task = tokio::spawn(serve_application(listener, app, async { let _ = stopped.await; }));
285+
let (mut client, _) = connect_async(format!("ws://{address}/v1/matches/{id}/stream")).await?;
286+
assert_eq!(next_state(&mut client).await?["turn"], 0);
287+
service.offer_draw(&id, ControlRequest { seat: 0, turn: 0 }).await?;
288+
let offered = next_state(&mut client).await?;
289+
assert_eq!(offered["turn"], 0);
290+
assert_eq!(offered["draw_offer"], 0);
291+
service.offer_draw(&id, ControlRequest { seat: 1, turn: 0 }).await?;
292+
let ended = next_state(&mut client).await?;
293+
assert_eq!(ended["turn"], 0);
294+
assert_eq!(ended["terminated"], true);
295+
assert_eq!(ended["outcome"]["reason"], "agreed_draw");
296+
assert!(matches!(
297+
tokio::time::timeout(Duration::from_secs(3), client.next()).await?,
298+
Some(Ok(Message::Close(_)))
299+
));
300+
stop.send(()).map_err(|_| "shutdown receiver closed")?;
301+
tokio::time::timeout(Duration::from_secs(5), task).await???;
302+
Ok(())
303+
}

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,3 +463,82 @@ async fn metadata_and_history_paginate_filter_and_survive_restart() -> TestResul
463463
app.store.close().await;
464464
Ok(())
465465
}
466+
467+
#[tokio::test]
468+
async fn resignation_and_draw_agreements_replay_after_restart() -> TestResult {
469+
let directory = tempfile::tempdir()?;
470+
let settings = config(&directory);
471+
let app = fixture(&settings).await?;
472+
let (_, initial) = call(&app.router, "POST", "/v1/matches", json!({"game_id":"tictactoe"}), None).await?;
473+
let id = match_id(&initial)?;
474+
let (status, resigned) = call(&app.router, "POST", &format!("/v1/matches/{id}/resign"),
475+
json!({"seat":1,"turn":0}), None).await?;
476+
assert_eq!(status, StatusCode::OK);
477+
assert_eq!(resigned["turn"], 0);
478+
assert_eq!(resigned["observation"], initial["observation"]);
479+
assert_eq!(resigned["returns"], json!([1.0,-1.0]));
480+
assert_eq!(resigned["outcome"], json!({"reason":"resigned","seat":1}));
481+
assert_eq!(resigned["terminated"], true);
482+
assert_eq!(resigned["legal_actions"], json!([]));
483+
let (status, _) = call(&app.router, "POST", &format!("/v1/matches/{id}/actions"),
484+
json!({"seat":0,"turn":0,"action":"r1c1"}), None).await?;
485+
assert_eq!(status, StatusCode::CONFLICT);
486+
let (_, created) = call(&app.router, "POST", "/v1/matches", json!({"game_id":"connect4"}), None).await?;
487+
let drawn_id = match_id(&created)?;
488+
let offer_url = format!("/v1/matches/{drawn_id}/offer-draw");
489+
let (_, offered) = call(&app.router, "POST", &offer_url, json!({"seat":0,"turn":0}), None).await?;
490+
assert_eq!(offered["draw_offer"], 0);
491+
assert_eq!(offered["terminated"], false);
492+
let (_, duplicate) = call(&app.router, "POST", &offer_url, json!({"seat":0,"turn":0}), None).await?;
493+
assert_eq!(offered, duplicate);
494+
let (_, drawn) = call(&app.router, "POST", &offer_url, json!({"seat":1,"turn":0}), None).await?;
495+
assert_eq!(drawn["outcome"], json!({"reason":"agreed_draw"}));
496+
assert_eq!(drawn["returns"], json!([0.0,0.0]));
497+
assert!(drawn.get("draw_offer").is_none());
498+
let (_, doc) = call(&app.router, "GET", "/v1/openapi.json", Value::Null, None).await?;
499+
assert!(doc["components"]["schemas"]["MatchOutcome"].is_object());
500+
app.store.close().await;
501+
let app = fixture(&settings).await?;
502+
for (id, expected) in [(id, resigned), (drawn_id, drawn)] {
503+
let (_, replay) = call(&app.router, "GET", &format!("/v1/matches/{id}/replay?seat=1"), Value::Null, None).await?;
504+
assert_eq!(replay["states"], json!([expected]));
505+
let (_, metadata) = call(&app.router, "GET", &format!("/v1/matches/{id}"), Value::Null, None).await?;
506+
assert_eq!(metadata["status"], "finished");
507+
assert_eq!(metadata["outcome"], expected["outcome"]);
508+
}
509+
app.store.close().await;
510+
Ok(())
511+
}
512+
513+
#[tokio::test]
514+
async fn controls_validate_turns_expire_offers_and_commit_once_under_races() -> TestResult {
515+
use gfa_api_types::{ControlRequest, MoveRequest};
516+
let directory = tempfile::tempdir()?;
517+
let app = fixture(&config(&directory)).await?;
518+
let (_, initial) = call(&app.router, "POST", "/v1/matches", json!({"game_id":"tictactoe"}), None).await?;
519+
let id = match_id(&initial)?;
520+
for (request, expected) in [
521+
(json!({"seat":0,"turn":1}), StatusCode::CONFLICT),
522+
(json!({"seat":9,"turn":0}), StatusCode::UNPROCESSABLE_ENTITY),
523+
(json!({"seat":0}), StatusCode::UNPROCESSABLE_ENTITY),
524+
] {
525+
let (status, _) = call(&app.router, "POST", &format!("/v1/matches/{id}/resign"), request, None).await?;
526+
assert_eq!(status, expected);
527+
}
528+
app.service.offer_draw(&id, ControlRequest { seat: 0, turn: 0 }).await?;
529+
app.service.make_move(&id, MoveRequest {
530+
seat: 0, turn: 0, action: json!("r2c2"), reasoning: None,
531+
}, None).await?;
532+
let offer = app.service.offer_draw(&id, ControlRequest { seat: 1, turn: 1 }).await?;
533+
assert!(!offer.terminated);
534+
assert_eq!(offer.draw_offer, Some(1));
535+
let (a, b) = tokio::join!(
536+
app.service.resign(&id, ControlRequest { seat: 0, turn: 1 }),
537+
app.service.resign(&id, ControlRequest { seat: 1, turn: 1 }),
538+
);
539+
assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1);
540+
assert!(a.err().into_iter().chain(b.err()).all(|error|
541+
error.code == "STALE_TURN" || error.code == "MATCH_FINISHED"));
542+
app.store.close().await;
543+
Ok(())
544+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//! Match controls and outcomes separate from engine moves.
2+
use schemars::JsonSchema;
3+
use serde::{Deserialize, Serialize};
4+
5+
/// A seat-scoped control command against an expected turn.
6+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
7+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8+
#[serde(deny_unknown_fields)]
9+
pub struct ControlRequest {
10+
/// Seat sending this command.
11+
#[cfg_attr(feature = "openapi", schema(minimum = 0, maximum = 255))]
12+
pub seat: u8,
13+
/// Expected accepted-action count.
14+
#[cfg_attr(feature = "openapi", schema(minimum = 0))]
15+
pub turn: u64,
16+
}
17+
18+
/// An explicit ending caused by a match control.
19+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
20+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
21+
#[serde(tag = "reason", rename_all = "snake_case")]
22+
pub enum MatchOutcome {
23+
/// One seat resigned; the engine position remains unchanged.
24+
Resigned {
25+
/// Resigning seat.
26+
seat: u8,
27+
},
28+
/// Both seats agreed to draw.
29+
AgreedDraw,
30+
}

‎crates/domain/gfa-api-types/src/history.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ pub struct MatchMetadata {
3535
pub terminated: bool,
3636
/// A length or external cap ended play.
3737
pub truncated: bool,
38+
/// Explicit control ending, if any.
39+
#[serde(default, skip_serializing_if = "Option::is_none")]
40+
pub outcome: Option<crate::MatchOutcome>,
41+
/// Seat with a pending draw offer.
42+
#[serde(default, skip_serializing_if = "Option::is_none")]
43+
pub draw_offer: Option<u8>,
3844
/// Creation timestamp in Unix milliseconds.
3945
pub created_at_ms: u64,
4046
}

‎crates/domain/gfa-api-types/src/lib.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ pub struct MatchState {
9292
pub terminated: bool,
9393
/// A length or other external cap ended play.
9494
pub truncated: bool,
95+
/// Explicit control ending, absent for ordinary engine results.
96+
#[serde(default, skip_serializing_if = "Option::is_none")]
97+
pub outcome: Option<MatchOutcome>,
98+
/// Seat with a pending draw offer; a move expires the offer.
99+
#[serde(default, skip_serializing_if = "Option::is_none")]
100+
pub draw_offer: Option<u8>,
95101
}
96102

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

256262
mod history;
257263
pub use history::{MatchHistory, MatchHistoryQuery, MatchMetadata, MatchStatus};
264+
265+
mod controls;
266+
pub use controls::{ControlRequest, MatchOutcome};

0 commit comments

Comments
 (0)