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 } ;
33use gfa_api_types:: { ApiError , EventData , EventPage , EventsQuery , RecordedEvent } ;
44use 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+ }
0 commit comments