Skip to content

Commit 12985e7

Browse files
authored
Merge pull request #38 from CurtisTechSolutions/feat/complete-replay-bundles
feat: include events and authorized reconstruction inputs in replay bundles
2 parents f85dde6 + 8780c3b commit 12985e7

7 files changed

Lines changed: 296 additions & 62 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,10 @@ event to the authorized player or spectator: private actions, reasoning, chance
7777
events, match seeds, and internal fork permissions are omitted when unauthorized.
7878
Own reasoning is available immediately; other reasoning in perfect-information
7979
games becomes available after completion.
80+
81+
Full replay bundles include the game/version, normalized config, event revision,
82+
projected events (including reasoning and controls), and reconstructed per-turn
83+
observations. Authorized in-process omniscient exports also include the initial
84+
engine state and RNG seed, so custom starts and forked histories can be rebuilt
85+
exactly. The ordinary REST views omit those private inputs. Full responses have
86+
a 16 MiB budget; larger histories remain available through paginated events.

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,51 @@ async fn event_http_pages_round_trip_across_restart_and_redact_internal_fork_met
112112
reopened.store.close().await;
113113
Ok(())
114114
}
115+
116+
#[tokio::test]
117+
async fn replay_bundle_matches_paged_events_without_exposing_private_inputs(
118+
) -> Result<(), ServerError> {
119+
let directory = tempfile::tempdir()?;
120+
let app = fixture(&config(&directory)).await?;
121+
let (_, created) = call(
122+
&app.router,
123+
"POST",
124+
"/v1/matches",
125+
json!({"game_id":"tictactoe","seed":42}),
126+
None,
127+
)
128+
.await?;
129+
let id = created["match_id"].as_str().ok_or("id")?;
130+
call(
131+
&app.router,
132+
"POST",
133+
&format!("/v1/matches/{id}/actions"),
134+
json!({"seat":0,"turn":0,"action":"r2c2","reasoning":"Center"}),
135+
None,
136+
)
137+
.await?;
138+
let (_, replay) = call(
139+
&app.router,
140+
"GET",
141+
&format!("/v1/matches/{id}/replay?seat=0"),
142+
Value::Null,
143+
None,
144+
)
145+
.await?;
146+
let (_, page) = call(
147+
&app.router,
148+
"GET",
149+
&format!("/v1/matches/{id}/events?seat=0"),
150+
Value::Null,
151+
None,
152+
)
153+
.await?;
154+
assert_eq!(replay["game_id"], "tictactoe");
155+
assert_eq!(replay["revision"], page["revision"]);
156+
assert_eq!(replay["events"], page["events"]);
157+
assert!(replay["config"].is_object());
158+
assert!(replay.get("seed").is_none() && replay.get("initial_state").is_none());
159+
assert!(replay["events"][0].get("initial_state").is_none());
160+
app.store.close().await;
161+
Ok(())
162+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ pub enum EventData {
5151
/// RNG seed, disclosed only to an explicitly authorized omniscient caller.
5252
#[serde(skip_serializing_if = "Option::is_none")]
5353
seed: Option<u64>,
54+
/// Complete starting state, disclosed only to an authorized omniscient caller.
55+
#[serde(skip_serializing_if = "Option::is_none")]
56+
initial_state: Option<Value>,
5457
/// Player assignments.
5558
seats: Vec<Seat>,
5659
/// Recorded assistance policy.

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ pub struct MoveResult {
123123
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
124124
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
125125
pub struct Replay {
126+
/// Registered game identifier needed to load this replay.
127+
pub game_id: String,
128+
/// Canonical public engine configuration.
129+
pub config: Value,
130+
/// Initial game RNG seed, available only to authorized omniscient callers.
131+
#[serde(default, skip_serializing_if = "Option::is_none")]
132+
pub seed: Option<u64>,
133+
/// Complete initial engine state, including a fork's local starting position.
134+
/// Only authorized omniscient callers receive this field.
135+
#[serde(default, skip_serializing_if = "Option::is_none")]
136+
pub initial_state: Option<Value>,
137+
/// Event count in this consistent replay snapshot.
138+
pub revision: u64,
139+
/// Viewer-scoped recorded facts, including controls and reasoning.
140+
pub events: Vec<RecordedEvent>,
126141
/// Stable match identifier.
127142
pub match_id: String,
128143
/// Engine version used to reconstruct this replay.

‎crates/domain/gfa-service/src/events.rs‎

Lines changed: 112 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//! Event projection is a domain operation shared by every transport.
2-
use crate::{error, replay, AppliedAction, GameService, MatchEvent};
2+
use crate::{error, replay, AppliedAction, GameService, MatchEvent, MatchRecord};
33
use gfa_api_types::{ApiError, EventData, EventPage, EventsQuery, RecordedEvent};
44
use gfa_core::{Information, Viewer};
55

@@ -30,12 +30,6 @@ impl GameService {
3030
}
3131
let record = self.record(id).await?;
3232
let rebuilt = replay::reconstruct(&self.registry, &record)?;
33-
let origin = record.origin().ok_or_else(error::corrupt)?;
34-
let initial = replay::initial(rebuilt.game.as_ref(), origin)?.project(
35-
id,
36-
rebuilt.game.as_ref(),
37-
viewer,
38-
)?;
3933
let start = match query.since {
4034
Some(sequence) if sequence < record.events.len() as u64 => sequence as usize + 1,
4135
Some(_) => {
@@ -50,55 +44,13 @@ impl GameService {
5044
let end = start
5145
.saturating_add(query.limit as usize)
5246
.min(record.events.len());
53-
let perfect = rebuilt.game.spec().information == Information::Perfect;
54-
let finished = rebuilt.current()?.ended();
55-
let mut events = Vec::new();
56-
for (offset, event) in record.events[start..end].iter().enumerate() {
57-
let event = match event {
58-
MatchEvent::MatchCreated(origin) => EventData::Created {
59-
game_id: origin.game_id.clone(),
60-
engine_version: origin.engine_version.clone(),
61-
config: rebuilt
62-
.game
63-
.normalize_config(&origin.config)
64-
.map_err(error::engine)?,
65-
seed: (viewer == Viewer::Omniscient).then_some(origin.seed),
66-
seats: crate::seats::assignments(origin, initial.returns.len()),
67-
assists: origin.assists.clone(),
68-
initial: Box::new(initial.clone()),
69-
at_ms: origin.created_at_ms,
70-
},
71-
MatchEvent::Action(action) => project_action(action, viewer, perfect, finished)?,
72-
MatchEvent::ForkedFrom { source, .. } => EventData::ForkedFrom {
73-
source: source.clone(),
74-
},
75-
MatchEvent::Resigned { turn, seat, at_ms } => EventData::Resigned {
76-
turn: *turn,
77-
seat: *seat,
78-
at_ms: *at_ms,
79-
},
80-
MatchEvent::DrawOffered { turn, seat, at_ms } => EventData::DrawOffered {
81-
turn: *turn,
82-
seat: *seat,
83-
at_ms: *at_ms,
84-
},
85-
MatchEvent::MatchFinished {
86-
turn,
87-
returns,
88-
terminated,
89-
truncated,
90-
} => EventData::Finished {
91-
turn: *turn,
92-
returns: returns.clone(),
93-
terminated: *terminated,
94-
truncated: *truncated,
95-
},
96-
};
97-
events.push(RecordedEvent {
98-
sequence: (start + offset) as u64,
99-
event,
100-
});
101-
}
47+
let events = project(
48+
&record,
49+
&rebuilt,
50+
viewer,
51+
start..end,
52+
&mut ReplayBudget::default(),
53+
)?;
10254
Ok(EventPage {
10355
match_id: id.into(),
10456
revision: record.events.len() as u64,
@@ -151,3 +103,107 @@ fn project_action(
151103
at_ms: action.accepted_at_ms,
152104
})
153105
}
106+
107+
/// Bound the full bundle while paginated events remain available for large histories.
108+
pub(crate) struct ReplayBudget {
109+
remaining: usize,
110+
}
111+
112+
impl Default for ReplayBudget {
113+
fn default() -> Self {
114+
Self {
115+
remaining: 16 * 1024 * 1024 - 4096,
116+
}
117+
}
118+
}
119+
120+
impl ReplayBudget {
121+
pub(crate) fn include(&mut self, value: &impl serde::Serialize) -> Result<(), ApiError> {
122+
let size = serde_json::to_vec(value)
123+
.map_err(|e| error::engine(e.into()))?
124+
.len()
125+
.saturating_add(2);
126+
self.remaining = self.remaining.checked_sub(size).ok_or_else(|| {
127+
ApiError::new(
128+
"REPLAY_TOO_LARGE",
129+
"Replay exceeds the 16 MiB response budget",
130+
"Read paginated /events to export this history in bounded chunks.",
131+
)
132+
})?;
133+
Ok(())
134+
}
135+
}
136+
137+
pub(crate) fn project(
138+
record: &MatchRecord,
139+
rebuilt: &replay::Reconstructed,
140+
viewer: Viewer,
141+
range: std::ops::Range<usize>,
142+
budget: &mut ReplayBudget,
143+
) -> Result<Vec<RecordedEvent>, ApiError> {
144+
let first = rebuilt.frames.first().ok_or_else(error::corrupt)?;
145+
// Same-turn controls only change Frame metadata. The engine state at index
146+
// zero remains the pristine start, so no second puzzle generation is needed.
147+
let initial = replay::Frame {
148+
state: first.state.clone(),
149+
turn: 0,
150+
terminated: false,
151+
truncated: false,
152+
outcome: None,
153+
draw_offer: None,
154+
}
155+
.project(&record.id, rebuilt.game.as_ref(), viewer)?;
156+
let perfect = rebuilt.game.spec().information == Information::Perfect;
157+
let finished = rebuilt.current()?.ended();
158+
let mut events = Vec::new();
159+
for (offset, event) in record.events[range.clone()].iter().enumerate() {
160+
let event = match event {
161+
MatchEvent::MatchCreated(origin) => EventData::Created {
162+
game_id: origin.game_id.clone(),
163+
engine_version: origin.engine_version.clone(),
164+
config: rebuilt
165+
.game
166+
.normalize_config(&origin.config)
167+
.map_err(error::engine)?,
168+
seed: (viewer == Viewer::Omniscient).then_some(origin.seed),
169+
initial_state: (viewer == Viewer::Omniscient).then(|| first.state.clone()),
170+
seats: crate::seats::assignments(origin, initial.returns.len()),
171+
assists: origin.assists.clone(),
172+
initial: Box::new(initial.clone()),
173+
at_ms: origin.created_at_ms,
174+
},
175+
MatchEvent::Action(action) => project_action(action, viewer, perfect, finished)?,
176+
MatchEvent::ForkedFrom { source, .. } => EventData::ForkedFrom {
177+
source: source.clone(),
178+
},
179+
MatchEvent::Resigned { turn, seat, at_ms } => EventData::Resigned {
180+
turn: *turn,
181+
seat: *seat,
182+
at_ms: *at_ms,
183+
},
184+
MatchEvent::DrawOffered { turn, seat, at_ms } => EventData::DrawOffered {
185+
turn: *turn,
186+
seat: *seat,
187+
at_ms: *at_ms,
188+
},
189+
MatchEvent::MatchFinished {
190+
turn,
191+
returns,
192+
terminated,
193+
truncated,
194+
} => EventData::Finished {
195+
turn: *turn,
196+
returns: returns.clone(),
197+
terminated: *terminated,
198+
truncated: *truncated,
199+
},
200+
};
201+
let projected = RecordedEvent {
202+
sequence: (range.start + offset) as u64,
203+
event,
204+
};
205+
budget.include(&projected)?;
206+
events.push(projected);
207+
}
208+
Ok(events)
209+
}

‎crates/domain/gfa-service/src/service.rs‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -312,16 +312,51 @@ impl GameService {
312312
pub async fn get_replay(&self, id: &str, viewer: Viewer) -> Result<Replay, ApiError> {
313313
let record = self.record(id).await?;
314314
let replay = replay::reconstruct(&self.registry, &record)?;
315-
let states = replay
316-
.frames
317-
.iter()
318-
.map(|frame| frame.project(id, replay.game.as_ref(), viewer))
319-
.collect::<Result<Vec<_>, _>>()?;
315+
let origin = record.origin().ok_or_else(error::corrupt)?;
316+
let config = replay
317+
.game
318+
.normalize_config(&origin.config)
319+
.map_err(error::engine)?;
320+
let mut budget = crate::events::ReplayBudget::default();
321+
budget.include(&config)?;
322+
let initial_state = if viewer == Viewer::Omniscient {
323+
let state = replay
324+
.frames
325+
.first()
326+
.ok_or_else(error::corrupt)?
327+
.state
328+
.clone();
329+
budget.include(&state)?;
330+
Some(state)
331+
} else {
332+
None
333+
};
334+
let mut states = Vec::new();
335+
for frame in &replay.frames {
336+
let state = frame.project(id, replay.game.as_ref(), viewer)?;
337+
budget.include(&state)?;
338+
states.push(state);
339+
}
340+
let events = crate::events::project(
341+
&record,
342+
&replay,
343+
viewer,
344+
0..record.events.len(),
345+
&mut budget,
346+
)?;
347+
let ancestors = self.fork_ancestors(&record).await?;
348+
budget.include(&ancestors)?;
320349
Ok(Replay {
350+
game_id: origin.game_id.clone(),
351+
config,
352+
seed: (viewer == Viewer::Omniscient).then_some(origin.seed),
353+
initial_state,
354+
revision: record.events.len() as u64,
355+
events,
321356
match_id: id.into(),
322357
engine_version: replay.game.spec().engine_version,
323358
states,
324-
ancestors: self.fork_ancestors(&record).await?,
359+
ancestors,
325360
})
326361
}
327362

0 commit comments

Comments
 (0)