Skip to content

Commit 8780c3b

Browse files
committed
style(service): format replay bundle projection
1 parent a22caa5 commit 8780c3b

4 files changed

Lines changed: 199 additions & 91 deletions

File tree

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

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,17 +114,46 @@ async fn event_http_pages_round_trip_across_restart_and_redact_internal_fork_met
114114
}
115115

116116
#[tokio::test]
117-
async fn replay_bundle_matches_paged_events_without_exposing_private_inputs() -> Result<(), ServerError> {
117+
async fn replay_bundle_matches_paged_events_without_exposing_private_inputs(
118+
) -> Result<(), ServerError> {
118119
let directory = tempfile::tempdir()?;
119120
let app = fixture(&config(&directory)).await?;
120-
let (_, created) = call(&app.router,"POST","/v1/matches",json!({"game_id":"tictactoe","seed":42}),None).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?;
121129
let id = created["match_id"].as_str().ok_or("id")?;
122-
call(&app.router,"POST",&format!("/v1/matches/{id}/actions"),json!({"seat":0,"turn":0,"action":"r2c2","reasoning":"Center"}),None).await?;
123-
let (_, replay) = call(&app.router,"GET",&format!("/v1/matches/{id}/replay?seat=0"),Value::Null,None).await?;
124-
let (_, page) = call(&app.router,"GET",&format!("/v1/matches/{id}/events?seat=0"),Value::Null,None).await?;
125-
assert_eq!(replay["game_id"],"tictactoe");
126-
assert_eq!(replay["revision"],page["revision"]);
127-
assert_eq!(replay["events"],page["events"]);
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"]);
128157
assert!(replay["config"].is_object());
129158
assert!(replay.get("seed").is_none() && replay.get("initial_state").is_none());
130159
assert!(replay["events"][0].get("initial_state").is_none());

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

Lines changed: 91 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ impl GameService {
4444
let end = start
4545
.saturating_add(query.limit as usize)
4646
.min(record.events.len());
47-
let events = project(&record, &rebuilt, viewer, start..end, &mut ReplayBudget::default())?;
47+
let events = project(
48+
&record,
49+
&rebuilt,
50+
viewer,
51+
start..end,
52+
&mut ReplayBudget::default(),
53+
)?;
4854
Ok(EventPage {
4955
match_id: id.into(),
5056
revision: record.events.len() as u64,
@@ -99,85 +105,105 @@ fn project_action(
99105
}
100106

101107
/// Bound the full bundle while paginated events remain available for large histories.
102-
pub(crate) struct ReplayBudget { remaining: usize }
108+
pub(crate) struct ReplayBudget {
109+
remaining: usize,
110+
}
103111

104112
impl Default for ReplayBudget {
105-
fn default() -> Self { Self { remaining: 16 * 1024 * 1024 - 4096 } }
113+
fn default() -> Self {
114+
Self {
115+
remaining: 16 * 1024 * 1024 - 4096,
116+
}
117+
}
106118
}
107119

108120
impl ReplayBudget {
109121
pub(crate) fn include(&mut self, value: &impl serde::Serialize) -> Result<(), ApiError> {
110-
let size = serde_json::to_vec(value).map_err(|e| error::engine(e.into()))?.len().saturating_add(2);
111-
self.remaining = self.remaining.checked_sub(size).ok_or_else(|| ApiError::new(
112-
"REPLAY_TOO_LARGE", "Replay exceeds the 16 MiB response budget",
113-
"Read paginated /events to export this history in bounded chunks.",
114-
))?;
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+
})?;
115133
Ok(())
116134
}
117135
}
118136

119137
pub(crate) fn project(
120-
record: &MatchRecord, rebuilt: &replay::Reconstructed, viewer: Viewer,
121-
range: std::ops::Range<usize>, budget: &mut ReplayBudget,
138+
record: &MatchRecord,
139+
rebuilt: &replay::Reconstructed,
140+
viewer: Viewer,
141+
range: std::ops::Range<usize>,
142+
budget: &mut ReplayBudget,
122143
) -> Result<Vec<RecordedEvent>, ApiError> {
123144
let first = rebuilt.frames.first().ok_or_else(error::corrupt)?;
124145
// Same-turn controls only change Frame metadata. The engine state at index
125146
// zero remains the pristine start, so no second puzzle generation is needed.
126147
let initial = replay::Frame {
127-
state:first.state.clone(), turn:0, terminated:false, truncated:false,
128-
outcome:None, draw_offer:None,
129-
}.project(&record.id, rebuilt.game.as_ref(), viewer)?;
130-
let perfect = rebuilt.game.spec().information == Information::Perfect;
131-
let finished = rebuilt.current()?.ended();
132-
let mut events = Vec::new();
133-
for (offset, event) in record.events[range.clone()].iter().enumerate() {
134-
let event = match event {
135-
MatchEvent::MatchCreated(origin) => EventData::Created {
136-
game_id: origin.game_id.clone(),
137-
engine_version: origin.engine_version.clone(),
138-
config: rebuilt
139-
.game
140-
.normalize_config(&origin.config)
141-
.map_err(error::engine)?,
142-
seed: (viewer == Viewer::Omniscient).then_some(origin.seed),
143-
initial_state: (viewer == Viewer::Omniscient).then(|| first.state.clone()),
144-
seats: crate::seats::assignments(origin, initial.returns.len()),
145-
assists: origin.assists.clone(),
146-
initial: Box::new(initial.clone()),
147-
at_ms: origin.created_at_ms,
148-
},
149-
MatchEvent::Action(action) => project_action(action, viewer, perfect, finished)?,
150-
MatchEvent::ForkedFrom { source, .. } => EventData::ForkedFrom {
151-
source: source.clone(),
152-
},
153-
MatchEvent::Resigned { turn, seat, at_ms } => EventData::Resigned {
154-
turn: *turn,
155-
seat: *seat,
156-
at_ms: *at_ms,
157-
},
158-
MatchEvent::DrawOffered { turn, seat, at_ms } => EventData::DrawOffered {
159-
turn: *turn,
160-
seat: *seat,
161-
at_ms: *at_ms,
162-
},
163-
MatchEvent::MatchFinished {
164-
turn,
165-
returns,
166-
terminated,
167-
truncated,
168-
} => EventData::Finished {
169-
turn: *turn,
170-
returns: returns.clone(),
171-
terminated: *terminated,
172-
truncated: *truncated,
173-
},
174-
};
175-
let projected = RecordedEvent {
176-
sequence: (range.start + offset) as u64,
177-
event,
178-
};
179-
budget.include(&projected)?;
180-
events.push(projected);
181-
}
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+
}
182208
Ok(events)
183209
}

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

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -313,27 +313,46 @@ impl GameService {
313313
let record = self.record(id).await?;
314314
let replay = replay::reconstruct(&self.registry, &record)?;
315315
let origin = record.origin().ok_or_else(error::corrupt)?;
316-
let config = replay.game.normalize_config(&origin.config).map_err(error::engine)?;
316+
let config = replay
317+
.game
318+
.normalize_config(&origin.config)
319+
.map_err(error::engine)?;
317320
let mut budget = crate::events::ReplayBudget::default();
318321
budget.include(&config)?;
319322
let initial_state = if viewer == Viewer::Omniscient {
320-
let state = replay.frames.first().ok_or_else(error::corrupt)?.state.clone();
323+
let state = replay
324+
.frames
325+
.first()
326+
.ok_or_else(error::corrupt)?
327+
.state
328+
.clone();
321329
budget.include(&state)?;
322330
Some(state)
323-
} else { None };
331+
} else {
332+
None
333+
};
324334
let mut states = Vec::new();
325335
for frame in &replay.frames {
326336
let state = frame.project(id, replay.game.as_ref(), viewer)?;
327337
budget.include(&state)?;
328338
states.push(state);
329339
}
330-
let events = crate::events::project(&record, &replay, viewer, 0..record.events.len(), &mut budget)?;
340+
let events = crate::events::project(
341+
&record,
342+
&replay,
343+
viewer,
344+
0..record.events.len(),
345+
&mut budget,
346+
)?;
331347
let ancestors = self.fork_ancestors(&record).await?;
332348
budget.include(&ancestors)?;
333349
Ok(Replay {
334-
game_id:origin.game_id.clone(), config,
335-
seed:(viewer == Viewer::Omniscient).then_some(origin.seed),
336-
initial_state, revision:record.events.len() as u64, events,
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,
337356
match_id: id.into(),
338357
engine_version: replay.game.spec().engine_version,
339358
states,

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

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1609,33 +1609,67 @@ fn replay_bundle_contains_projected_events_and_privileged_reconstruction_inputs(
16091609
use gfa_api_types::EventData;
16101610
let (service, _) = fixture()?;
16111611
let initial = run(service.create_match(create("counter"), Viewer::Player(0)))?;
1612-
run(service.make_move(&initial.match_id, action(0,json!(1)),None))?;
1613-
run(service.make_move(&initial.match_id, action(1,json!(2)),None))?;
1612+
run(service.make_move(&initial.match_id, action(0, json!(1)), None))?;
1613+
run(service.make_move(&initial.match_id, action(1, json!(2)), None))?;
16141614
let public = run(service.get_replay(&initial.match_id, Viewer::Player(0)))?;
16151615
assert_eq!(public.game_id, "counter");
16161616
assert_eq!(public.config, json!({}));
16171617
assert_eq!(public.revision, 3);
16181618
assert_eq!(public.events.len(), 3);
16191619
assert!(public.seed.is_none() && public.initial_state.is_none());
1620-
assert!(matches!(public.events[0].event, EventData::Created { seed:None, initial_state:None, .. }));
1621-
assert!(matches!(public.events[2].event, EventData::Action { action:None, reasoning:None, .. }));
1620+
assert!(matches!(
1621+
public.events[0].event,
1622+
EventData::Created {
1623+
seed: None,
1624+
initial_state: None,
1625+
..
1626+
}
1627+
));
1628+
assert!(matches!(
1629+
public.events[2].event,
1630+
EventData::Action {
1631+
action: None,
1632+
reasoning: None,
1633+
..
1634+
}
1635+
));
16221636
let full = run(service.get_replay(&initial.match_id, Viewer::Omniscient))?;
16231637
assert_eq!(full.seed, Some(7));
16241638
let game = service.registry.get("counter")?;
16251639
let mut state = full.initial_state.clone().ok_or("initial engine state")?;
1626-
assert_eq!(game.observe(&state, Viewer::Omniscient)?, full.states[0].observation);
1640+
assert_eq!(
1641+
game.observe(&state, Viewer::Omniscient)?,
1642+
full.states[0].observation
1643+
);
16271644
for event in &full.events {
1628-
if let EventData::Action { seat, action:Some(action), .. } = &event.event {
1645+
if let EventData::Action {
1646+
seat,
1647+
action: Some(action),
1648+
..
1649+
} = &event.event
1650+
{
16291651
state = game.apply(&state, *seat, &action.json)?.0;
16301652
}
16311653
}
1632-
assert_eq!(game.observe(&state, Viewer::Omniscient)?, full.states.last().ok_or("last")?.observation);
1633-
let child = run(service.fork_match(&initial.match_id, fork_request(1,false), ForkAccess {
1634-
viewer:Viewer::Player(1), owns_parent:true, full_state:false,
1635-
}))?;
1654+
assert_eq!(
1655+
game.observe(&state, Viewer::Omniscient)?,
1656+
full.states.last().ok_or("last")?.observation
1657+
);
1658+
let child = run(service.fork_match(
1659+
&initial.match_id,
1660+
fork_request(1, false),
1661+
ForkAccess {
1662+
viewer: Viewer::Player(1),
1663+
owns_parent: true,
1664+
full_state: false,
1665+
},
1666+
))?;
16361667
let fork = run(service.get_replay(&child.state.match_id, Viewer::Omniscient))?;
16371668
assert!(matches!(fork.events[0].event, EventData::ForkedFrom { .. }));
1638-
assert_eq!(game.observe(&fork.initial_state.ok_or("fork state")?, Viewer::Omniscient)?, fork.states[0].observation);
1669+
assert_eq!(
1670+
game.observe(&fork.initial_state.ok_or("fork state")?, Viewer::Omniscient)?,
1671+
fork.states[0].observation
1672+
);
16391673
assert_eq!(fork.ancestors.len(), 1);
16401674
Ok(())
16411675
}

0 commit comments

Comments
 (0)