Skip to content

Commit da84e99

Browse files
authored
feat: add event-sourced match lifecycle (#9)
feat: implement match lifecycle with deterministic replay
2 parents 9e08dea + d864f3c commit da84e99

5 files changed

Lines changed: 1106 additions & 3 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use crate::StoreError;
2+
use gfa_api_types::ApiError;
3+
use gfa_core::{ErrorCode, GameError};
4+
5+
pub(crate) fn engine(error: GameError) -> ApiError {
6+
let code = match error.code {
7+
ErrorCode::UnknownGame => "UNKNOWN_GAME",
8+
ErrorCode::InvalidConfig => "INVALID_CONFIG",
9+
ErrorCode::InvalidPosition => "INVALID_POSITION",
10+
ErrorCode::NotYourTurn => "NOT_YOUR_TURN",
11+
ErrorCode::IllegalAction => "ILLEGAL_ACTION",
12+
ErrorCode::UnparseableAction => "UNPARSEABLE_ACTION",
13+
ErrorCode::MatchFinished => "MATCH_FINISHED",
14+
ErrorCode::Serialization => "INVALID_POSITION",
15+
};
16+
ApiError::new(code, error.message, &error.hint)
17+
}
18+
19+
pub(crate) fn store(error: StoreError) -> ApiError {
20+
match error {
21+
StoreError::Conflict => ApiError::new(
22+
"STALE_TURN",
23+
"Another command changed this match",
24+
"Read the current state and retry with its turn.",
25+
),
26+
StoreError::NotFound => missing(),
27+
StoreError::DuplicateId => ApiError::new(
28+
"ID_CONFLICT",
29+
"Match identifier collision",
30+
"Retry creating the match.",
31+
),
32+
StoreError::IdempotencyConflict => ApiError::new(
33+
"IDEMPOTENCY_CONFLICT",
34+
"This idempotency key was used for different input",
35+
"Retry the original request or use a new key.",
36+
),
37+
StoreError::Unavailable(_) => ApiError::new(
38+
"STORAGE_UNAVAILABLE",
39+
"Match storage is unavailable",
40+
"Retry later using the same idempotency key.",
41+
),
42+
}
43+
}
44+
45+
pub(crate) fn missing() -> ApiError {
46+
ApiError::new(
47+
"MATCH_NOT_FOUND",
48+
"Match does not exist",
49+
"Check the match identifier.",
50+
)
51+
}
52+
53+
pub(crate) fn corrupt() -> ApiError {
54+
ApiError::new(
55+
"INVALID_EVENT_LOG",
56+
"Stored events cannot be replayed consistently",
57+
"Use the engine version recorded with this match and check the event log.",
58+
)
59+
}
Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
//! Transport-independent match events and persistence ports.
1+
//! Transport-independent event-sourced match lifecycle.
22
//!
3-
//! Hosts must authorize seats and viewers before accepting network requests.
4-
//! Storage adapters must honor the atomicity and idempotency contract in MatchStore.
3+
//! This library is a trusted-host boundary: callers select authorized viewers and
4+
//! seats. No public transport is exposed here. Authentication and visibility policy
5+
//! must be integrated before exposing these operations to untrusted network callers.
6+
mod error;
57
mod model;
68
mod ports;
9+
mod replay;
10+
mod service;
711

812
pub use model::*;
913
pub use ports::*;
14+
pub use service::GameService;
15+
16+
#[cfg(test)]
17+
mod tests;
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
use crate::{error, MatchEvent, MatchOrigin, MatchRecord};
2+
use gfa_api_types::{ApiError, MatchState, Start};
3+
use gfa_core::{DynGame, GameRegistry, StepEvents, Viewer};
4+
use serde_json::Value;
5+
use std::sync::Arc;
6+
7+
pub(crate) struct Frame {
8+
pub state: Value,
9+
pub turn: u64,
10+
pub terminated: bool,
11+
pub truncated: bool,
12+
}
13+
14+
impl Frame {
15+
pub fn ended(&self) -> bool {
16+
self.terminated || self.truncated
17+
}
18+
19+
pub fn project(
20+
&self,
21+
id: &str,
22+
game: &dyn DynGame,
23+
viewer: Viewer,
24+
) -> Result<MatchState, ApiError> {
25+
let (legal_actions, action_mask) = match viewer {
26+
Viewer::Player(seat) if !self.ended() => (
27+
game.legal_actions(&self.state, seat)
28+
.map_err(error::engine)?,
29+
game.action_mask(&self.state, seat).map_err(error::engine)?,
30+
),
31+
Viewer::Player(_) => (vec![], vec![false; game.spec().action_space_size as usize]),
32+
_ => (vec![], vec![]),
33+
};
34+
Ok(MatchState {
35+
match_id: id.into(),
36+
turn: self.turn,
37+
to_act: if self.ended() {
38+
vec![]
39+
} else {
40+
game.current_players(&self.state).map_err(error::engine)?
41+
},
42+
observation: game.observe(&self.state, viewer).map_err(error::engine)?,
43+
legal_actions,
44+
action_mask,
45+
returns: game.returns(&self.state).map_err(error::engine)?,
46+
terminated: self.terminated,
47+
truncated: self.truncated,
48+
})
49+
}
50+
}
51+
52+
pub(crate) struct Reconstructed {
53+
pub game: Arc<dyn DynGame>,
54+
pub frames: Vec<Frame>,
55+
}
56+
57+
impl Reconstructed {
58+
pub fn current(&self) -> Result<&Frame, ApiError> {
59+
self.frames.last().ok_or_else(error::corrupt)
60+
}
61+
}
62+
63+
pub(crate) fn initial(game: &dyn DynGame, origin: &MatchOrigin) -> Result<Frame, ApiError> {
64+
// Validate configuration even when a custom state bypasses initial construction.
65+
let default = game
66+
.initial_state(&origin.config, origin.seed)
67+
.map_err(error::engine)?;
68+
let state = match &origin.start {
69+
None => default,
70+
Some(Start::Position { position }) => game
71+
.state_from_notation(&origin.config, position)
72+
.map_err(error::engine)?,
73+
Some(Start::State { state }) => {
74+
game.validate_state(state).map_err(error::engine)?;
75+
state.clone()
76+
}
77+
};
78+
if game.is_terminal(&state).map_err(error::engine)?
79+
|| game
80+
.current_players(&state)
81+
.map_err(error::engine)?
82+
.is_empty()
83+
{
84+
return Err(ApiError::new(
85+
"INVALID_POSITION",
86+
"Starting position must be playable",
87+
"Choose a nonterminal position with an active seat.",
88+
));
89+
}
90+
if game.spec().max_game_length == 0 {
91+
return Err(ApiError::new(
92+
"INVALID_CONFIG",
93+
"Engine declares a zero game length",
94+
"Choose a playable engine.",
95+
));
96+
}
97+
Ok(Frame {
98+
state,
99+
turn: 0,
100+
terminated: false,
101+
truncated: false,
102+
})
103+
}
104+
105+
pub(crate) fn advance(
106+
game: &dyn DynGame,
107+
state: Value,
108+
turn: u64,
109+
events: &StepEvents,
110+
) -> Result<Frame, ApiError> {
111+
let terminated = game.is_terminal(&state).map_err(error::engine)?;
112+
let truncated =
113+
events.truncated || (!terminated && turn >= u64::from(game.spec().max_game_length));
114+
Ok(Frame {
115+
state,
116+
turn,
117+
terminated,
118+
truncated,
119+
})
120+
}
121+
122+
pub(crate) fn reconstruct(
123+
registry: &GameRegistry,
124+
record: &MatchRecord,
125+
) -> Result<Reconstructed, ApiError> {
126+
let Some(MatchEvent::MatchCreated(origin)) = record.events.first() else {
127+
return Err(error::corrupt());
128+
};
129+
let game = registry.get(&origin.game_id).map_err(|_| {
130+
ApiError::new(
131+
"ENGINE_UNAVAILABLE",
132+
"The recorded engine is not installed",
133+
"Install the game and version recorded with this match.",
134+
)
135+
})?;
136+
if game.spec().engine_version != origin.engine_version {
137+
return Err(ApiError::new(
138+
"ENGINE_UNAVAILABLE",
139+
"The recorded engine version is unavailable",
140+
"Replay with the original engine version.",
141+
));
142+
}
143+
if record.events.len() as u64 > u64::from(game.spec().max_game_length) + 2 {
144+
return Err(error::corrupt());
145+
}
146+
let mut result = Reconstructed {
147+
frames: vec![initial(game.as_ref(), origin).map_err(|_| error::corrupt())?],
148+
game,
149+
};
150+
let mut finished = false;
151+
for event in record.events.iter().skip(1) {
152+
let current = result.current()?;
153+
if finished {
154+
return Err(error::corrupt());
155+
}
156+
match event {
157+
MatchEvent::MatchCreated(_) => return Err(error::corrupt()),
158+
MatchEvent::Action(action) => {
159+
if current.ended() || action.turn != current.turn {
160+
return Err(error::corrupt());
161+
}
162+
let legal = result
163+
.game
164+
.legal_actions(&current.state, action.seat)
165+
.map_err(|_| error::corrupt())?;
166+
if !legal.contains(&action.action) {
167+
return Err(error::corrupt());
168+
}
169+
let (state, events) = result
170+
.game
171+
.apply(&current.state, action.seat, &action.action.json)
172+
.map_err(|_| error::corrupt())?;
173+
if events != action.engine_events {
174+
return Err(error::corrupt());
175+
}
176+
let frame = advance(result.game.as_ref(), state, current.turn + 1, &events)
177+
.map_err(|_| error::corrupt())?;
178+
result.frames.push(frame);
179+
}
180+
MatchEvent::MatchFinished {
181+
turn,
182+
returns,
183+
terminated,
184+
truncated,
185+
} => {
186+
if !current.ended()
187+
|| *turn != current.turn
188+
|| *terminated != current.terminated
189+
|| *truncated != current.truncated
190+
|| *returns
191+
!= result
192+
.game
193+
.returns(&current.state)
194+
.map_err(|_| error::corrupt())?
195+
{
196+
return Err(error::corrupt());
197+
}
198+
finished = true;
199+
}
200+
}
201+
}
202+
if result.current()?.ended() != finished {
203+
return Err(error::corrupt());
204+
}
205+
Ok(result)
206+
}

0 commit comments

Comments
 (0)