Skip to content

Commit c96579b

Browse files
authored
Merge pull request #23 from CurtisTechSolutions/feat/match-controls
feat: add durable resignation and draw controls
2 parents ff4785a + 9782449 commit c96579b

16 files changed

Lines changed: 513 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
@@ -98,6 +98,8 @@ fn build_router(
9898
)
9999
.route("/v1/matches", post(create).get(history))
100100
.route("/v1/matches/{id}", get(metadata))
101+
.route("/v1/matches/{id}/resign", post(resign))
102+
.route("/v1/matches/{id}/offer-draw", post(offer_draw))
101103
.route("/v1/matches/{id}/info", get(info::match_info))
102104
.route("/v1/matches/{id}/state", get(state))
103105
.route("/v1/matches/{id}/legal-actions", get(legal_actions))
@@ -364,3 +366,35 @@ async fn metadata(
364366
) -> Result<Json<gfa_api_types::MatchMetadata>, HttpError> {
365367
Ok(Json(service.get_match(&id(path)?).await?))
366368
}
369+
370+
#[utoipa::path(
371+
post, path = "/v1/matches/{id}/resign", tag = "Matches",
372+
params(("id" = String, Path, description = "Match identifier")),
373+
request_body = gfa_api_types::ControlRequest,
374+
responses((status = 200, description = "Resign a one- or two-seat match.", body = MatchState),
375+
(status = "default", description = "Control or turn error", body = gfa_api_types::ErrorResponse))
376+
)]
377+
async fn resign(
378+
State(service): State<Arc<GameService>>,
379+
path: Id,
380+
body: Result<Json<gfa_api_types::ControlRequest>, JsonRejection>,
381+
) -> Result<Json<MatchState>, HttpError> {
382+
let Json(request) = body?;
383+
Ok(Json(service.resign(&id(path)?, request).await?))
384+
}
385+
386+
#[utoipa::path(
387+
post, path = "/v1/matches/{id}/offer-draw", tag = "Matches",
388+
params(("id" = String, Path, description = "Match identifier")),
389+
request_body = gfa_api_types::ControlRequest,
390+
responses((status = 200, description = "Offer a draw or accept the other seat’s pending offer. A move expires the offer.", body = MatchState),
391+
(status = "default", description = "Control or turn error", body = gfa_api_types::ErrorResponse))
392+
)]
393+
async fn offer_draw(
394+
State(service): State<Arc<GameService>>,
395+
path: Id,
396+
body: Result<Json<gfa_api_types::ControlRequest>, JsonRejection>,
397+
) -> Result<Json<MatchState>, HttpError> {
398+
let Json(request) = body?;
399+
Ok(Json(service.offer_draw(&id(path)?, request).await?))
400+
}

‎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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,47 @@ 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(
281+
&Config {
282+
sqlite: directory.path().join("controls.sqlite"),
283+
port: 0,
284+
},
285+
address,
286+
)
287+
.await?;
288+
let service = app.service.clone();
289+
let id = create(&service).await?;
290+
let (stop, stopped) = oneshot::channel();
291+
let task = tokio::spawn(serve_application(listener, app, async {
292+
let _ = stopped.await;
293+
}));
294+
let (mut client, _) = connect_async(format!("ws://{address}/v1/matches/{id}/stream")).await?;
295+
assert_eq!(next_state(&mut client).await?["turn"], 0);
296+
service
297+
.offer_draw(&id, ControlRequest { seat: 0, turn: 0 })
298+
.await?;
299+
let offered = next_state(&mut client).await?;
300+
assert_eq!(offered["turn"], 0);
301+
assert_eq!(offered["draw_offer"], 0);
302+
service
303+
.offer_draw(&id, ControlRequest { seat: 1, turn: 0 })
304+
.await?;
305+
let ended = next_state(&mut client).await?;
306+
assert_eq!(ended["turn"], 0);
307+
assert_eq!(ended["terminated"], true);
308+
assert_eq!(ended["outcome"]["reason"], "agreed_draw");
309+
assert!(matches!(
310+
tokio::time::timeout(Duration::from_secs(3), client.next()).await?,
311+
Some(Ok(Message::Close(_)))
312+
));
313+
stop.send(()).map_err(|_| "shutdown receiver closed")?;
314+
tokio::time::timeout(Duration::from_secs(5), task).await???;
315+
Ok(())
316+
}

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

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,3 +463,174 @@ 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(
473+
&app.router,
474+
"POST",
475+
"/v1/matches",
476+
json!({"game_id":"tictactoe"}),
477+
None,
478+
)
479+
.await?;
480+
let id = match_id(&initial)?;
481+
let (status, resigned) = call(
482+
&app.router,
483+
"POST",
484+
&format!("/v1/matches/{id}/resign"),
485+
json!({"seat":1,"turn":0}),
486+
None,
487+
)
488+
.await?;
489+
assert_eq!(status, StatusCode::OK);
490+
assert_eq!(resigned["turn"], 0);
491+
assert_eq!(resigned["observation"], initial["observation"]);
492+
assert_eq!(resigned["returns"], json!([1.0, -1.0]));
493+
assert_eq!(resigned["outcome"], json!({"reason":"resigned","seat":1}));
494+
assert_eq!(resigned["terminated"], true);
495+
assert_eq!(resigned["legal_actions"], json!([]));
496+
let (status, _) = call(
497+
&app.router,
498+
"POST",
499+
&format!("/v1/matches/{id}/actions"),
500+
json!({"seat":0,"turn":0,"action":"r1c1"}),
501+
None,
502+
)
503+
.await?;
504+
assert_eq!(status, StatusCode::CONFLICT);
505+
let (_, created) = call(
506+
&app.router,
507+
"POST",
508+
"/v1/matches",
509+
json!({"game_id":"connect4"}),
510+
None,
511+
)
512+
.await?;
513+
let drawn_id = match_id(&created)?;
514+
let offer_url = format!("/v1/matches/{drawn_id}/offer-draw");
515+
let (_, offered) = call(
516+
&app.router,
517+
"POST",
518+
&offer_url,
519+
json!({"seat":0,"turn":0}),
520+
None,
521+
)
522+
.await?;
523+
assert_eq!(offered["draw_offer"], 0);
524+
assert_eq!(offered["terminated"], false);
525+
let (_, duplicate) = call(
526+
&app.router,
527+
"POST",
528+
&offer_url,
529+
json!({"seat":0,"turn":0}),
530+
None,
531+
)
532+
.await?;
533+
assert_eq!(offered, duplicate);
534+
let (_, drawn) = call(
535+
&app.router,
536+
"POST",
537+
&offer_url,
538+
json!({"seat":1,"turn":0}),
539+
None,
540+
)
541+
.await?;
542+
assert_eq!(drawn["outcome"], json!({"reason":"agreed_draw"}));
543+
assert_eq!(drawn["returns"], json!([0.0, 0.0]));
544+
assert!(drawn.get("draw_offer").is_none());
545+
let (_, doc) = call(&app.router, "GET", "/v1/openapi.json", Value::Null, None).await?;
546+
assert!(doc["components"]["schemas"]["MatchOutcome"].is_object());
547+
app.store.close().await;
548+
let app = fixture(&settings).await?;
549+
for (id, expected) in [(id, resigned), (drawn_id, drawn)] {
550+
let (_, replay) = call(
551+
&app.router,
552+
"GET",
553+
&format!("/v1/matches/{id}/replay?seat=1"),
554+
Value::Null,
555+
None,
556+
)
557+
.await?;
558+
assert_eq!(replay["states"], json!([expected]));
559+
let (_, metadata) = call(
560+
&app.router,
561+
"GET",
562+
&format!("/v1/matches/{id}"),
563+
Value::Null,
564+
None,
565+
)
566+
.await?;
567+
assert_eq!(metadata["status"], "finished");
568+
assert_eq!(metadata["outcome"], expected["outcome"]);
569+
}
570+
app.store.close().await;
571+
Ok(())
572+
}
573+
574+
#[tokio::test]
575+
async fn controls_validate_turns_expire_offers_and_commit_once_under_races() -> TestResult {
576+
use gfa_api_types::{ControlRequest, MoveRequest};
577+
let directory = tempfile::tempdir()?;
578+
let app = fixture(&config(&directory)).await?;
579+
let (_, initial) = call(
580+
&app.router,
581+
"POST",
582+
"/v1/matches",
583+
json!({"game_id":"tictactoe"}),
584+
None,
585+
)
586+
.await?;
587+
let id = match_id(&initial)?;
588+
for (request, expected) in [
589+
(json!({"seat":0,"turn":1}), StatusCode::CONFLICT),
590+
(json!({"seat":9,"turn":0}), StatusCode::UNPROCESSABLE_ENTITY),
591+
(json!({"seat":0}), StatusCode::UNPROCESSABLE_ENTITY),
592+
] {
593+
let (status, _) = call(
594+
&app.router,
595+
"POST",
596+
&format!("/v1/matches/{id}/resign"),
597+
request,
598+
None,
599+
)
600+
.await?;
601+
assert_eq!(status, expected);
602+
}
603+
app.service
604+
.offer_draw(&id, ControlRequest { seat: 0, turn: 0 })
605+
.await?;
606+
app.service
607+
.make_move(
608+
&id,
609+
MoveRequest {
610+
seat: 0,
611+
turn: 0,
612+
action: json!("r2c2"),
613+
reasoning: None,
614+
},
615+
None,
616+
)
617+
.await?;
618+
let offer = app
619+
.service
620+
.offer_draw(&id, ControlRequest { seat: 1, turn: 1 })
621+
.await?;
622+
assert!(!offer.terminated);
623+
assert_eq!(offer.draw_offer, Some(1));
624+
let (a, b) = tokio::join!(
625+
app.service.resign(&id, ControlRequest { seat: 0, turn: 1 }),
626+
app.service.resign(&id, ControlRequest { seat: 1, turn: 1 }),
627+
);
628+
assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1);
629+
assert!(a
630+
.err()
631+
.into_iter()
632+
.chain(b.err())
633+
.all(|error| error.code == "STALE_TURN" || error.code == "MATCH_FINISHED"));
634+
app.store.close().await;
635+
Ok(())
636+
}
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)