1use std::collections::VecDeque;
2
3use bevy_app::{App, Plugin};
4use bevy_ecs::prelude::*;
5use bevy_ecs::schedule::ScheduleLabel;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9#[derive(Clone, Debug, Hash, PartialEq, Eq, ScheduleLabel)]
10pub struct RuntimeSchedule;
11
12#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct RuntimeCommand {
15 pub id: String,
16 pub name: String,
17 #[serde(default)]
18 pub payload: Value,
19}
20
21impl RuntimeCommand {
22 pub fn new(id: impl Into<String>, name: impl Into<String>, payload: Value) -> Self {
23 Self {
24 id: id.into(),
25 name: name.into(),
26 payload,
27 }
28 }
29}
30
31#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct RuntimeEvent {
34 pub sequence: u64,
35 pub name: String,
36 #[serde(default)]
37 pub payload: Value,
38}
39
40#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct EffectRequest {
43 pub id: String,
44 pub kind: String,
45 #[serde(default)]
46 pub payload: Value,
47}
48
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct EffectResult {
52 pub effect_id: String,
53 pub succeeded: bool,
54 #[serde(default)]
55 pub output: Value,
56}
57
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct RuntimeSnapshot {
61 pub revision: u64,
62 #[serde(default)]
63 pub state: Value,
64}
65
66impl Default for RuntimeSnapshot {
67 fn default() -> Self {
68 Self {
69 revision: 0,
70 state: Value::Object(Default::default()),
71 }
72 }
73}
74
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct RuntimeAdvance {
78 pub snapshot: RuntimeSnapshot,
79 pub events: Vec<RuntimeEvent>,
80 pub effects: Vec<EffectRequest>,
81}
82
83#[derive(Resource, Default)]
84pub struct RuntimeCommandQueue {
85 commands: VecDeque<RuntimeCommand>,
86}
87
88impl RuntimeCommandQueue {
89 pub fn push(&mut self, command: RuntimeCommand) {
90 self.commands.push_back(command);
91 }
92
93 pub fn pop_front(&mut self) -> Option<RuntimeCommand> {
94 self.commands.pop_front()
95 }
96
97 pub fn drain(&mut self) -> impl Iterator<Item = RuntimeCommand> + '_ {
98 self.commands.drain(..)
99 }
100
101 pub fn is_empty(&self) -> bool {
102 self.commands.is_empty()
103 }
104}
105
106#[derive(Resource, Default)]
107pub struct RuntimeEventQueue {
108 next_sequence: u64,
109 events: Vec<RuntimeEvent>,
110}
111
112impl RuntimeEventQueue {
113 pub fn emit(&mut self, name: impl Into<String>, payload: Value) -> u64 {
114 let sequence = self.next_sequence;
115 self.next_sequence = self.next_sequence.saturating_add(1);
116 self.events.push(RuntimeEvent {
117 sequence,
118 name: name.into(),
119 payload,
120 });
121 sequence
122 }
123
124 pub fn drain(&mut self) -> impl Iterator<Item = RuntimeEvent> + '_ {
125 self.events.drain(..)
126 }
127}
128
129#[derive(Resource, Default)]
130pub struct EffectRequestQueue {
131 next_id: u64,
132 effects: Vec<EffectRequest>,
133}
134
135impl EffectRequestQueue {
136 pub fn request(&mut self, kind: impl Into<String>, payload: Value) -> String {
137 let id = format!("effect-{}", self.next_id);
138 self.next_id = self.next_id.saturating_add(1);
139 self.effects.push(EffectRequest {
140 id: id.clone(),
141 kind: kind.into(),
142 payload,
143 });
144 id
145 }
146
147 pub fn drain(&mut self) -> impl Iterator<Item = EffectRequest> + '_ {
148 self.effects.drain(..)
149 }
150}
151
152#[derive(Resource, Default)]
153pub struct EffectResultQueue {
154 results: VecDeque<EffectResult>,
155}
156
157impl EffectResultQueue {
158 pub fn push(&mut self, result: EffectResult) {
159 self.results.push_back(result);
160 }
161
162 pub fn pop_front(&mut self) -> Option<EffectResult> {
163 self.results.pop_front()
164 }
165
166 pub fn drain(&mut self) -> impl Iterator<Item = EffectResult> + '_ {
167 self.results.drain(..)
168 }
169}
170
171#[derive(Resource, Clone, Debug)]
172pub struct RuntimeState {
173 pub revision: u64,
174 pub value: Value,
175}
176
177impl Default for RuntimeState {
178 fn default() -> Self {
179 Self {
180 revision: 0,
181 value: Value::Object(Default::default()),
182 }
183 }
184}
185
186impl RuntimeState {
187 pub fn snapshot(&self) -> RuntimeSnapshot {
188 RuntimeSnapshot {
189 revision: self.revision,
190 state: self.value.clone(),
191 }
192 }
193}
194
195#[derive(Clone, Copy, Debug, Default)]
196pub struct VifuRuntimePlugin;
197
198impl Plugin for VifuRuntimePlugin {
199 fn build(&self, app: &mut App) {
200 app.init_schedule(RuntimeSchedule)
201 .init_resource::<RuntimeCommandQueue>()
202 .init_resource::<RuntimeEventQueue>()
203 .init_resource::<EffectRequestQueue>()
204 .init_resource::<EffectResultQueue>()
205 .init_resource::<RuntimeState>();
206 }
207}
208
209pub struct HeadlessRuntime {
210 app: App,
211}
212
213impl Default for HeadlessRuntime {
214 fn default() -> Self {
215 Self::new()
216 }
217}
218
219impl HeadlessRuntime {
220 pub fn new() -> Self {
221 let mut app = App::empty();
222 app.add_plugins(VifuRuntimePlugin);
223 Self { app }
224 }
225
226 pub fn restore(snapshot: RuntimeSnapshot) -> Self {
227 let mut runtime = Self::new();
228 runtime.app.insert_resource(RuntimeState {
229 revision: snapshot.revision,
230 value: snapshot.state,
231 });
232 runtime
233 }
234
235 pub fn app(&self) -> &App {
236 &self.app
237 }
238
239 pub fn app_mut(&mut self) -> &mut App {
240 &mut self.app
241 }
242
243 pub fn snapshot(&self) -> RuntimeSnapshot {
244 self.app.world().resource::<RuntimeState>().snapshot()
245 }
246
247 pub fn complete_effect(&mut self, result: EffectResult) {
248 self.app
249 .world_mut()
250 .resource_mut::<EffectResultQueue>()
251 .push(result);
252 }
253
254 pub fn enqueue_command(&mut self, command: RuntimeCommand) {
255 self.app
256 .world_mut()
257 .resource_mut::<RuntimeCommandQueue>()
258 .push(command);
259 }
260
261 pub fn run_schedule(&mut self, schedule: impl ScheduleLabel) -> RuntimeAdvance {
262 self.app.world_mut().run_schedule(schedule);
263 self.take_advance()
264 }
265
266 pub fn dispatch(&mut self, command: RuntimeCommand) -> RuntimeAdvance {
267 self.enqueue_command(command);
268 self.app.world_mut().run_schedule(RuntimeSchedule);
269
270 {
271 let mut state = self.app.world_mut().resource_mut::<RuntimeState>();
272 state.revision = state.revision.saturating_add(1);
273 }
274
275 self.take_advance()
276 }
277
278 fn take_advance(&mut self) -> RuntimeAdvance {
279 let snapshot = self.snapshot();
280 let events = self
281 .app
282 .world_mut()
283 .resource_mut::<RuntimeEventQueue>()
284 .drain()
285 .collect();
286 let effects = self
287 .app
288 .world_mut()
289 .resource_mut::<EffectRequestQueue>()
290 .drain()
291 .collect();
292
293 RuntimeAdvance {
294 snapshot,
295 events,
296 effects,
297 }
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use bevy_ecs::prelude::{ResMut, Resource};
304 use serde_json::json;
305
306 use super::*;
307
308 #[derive(Resource, Default)]
309 struct ProcessedCommands(u64);
310
311 fn echo_commands(
312 mut commands: ResMut<RuntimeCommandQueue>,
313 mut events: ResMut<RuntimeEventQueue>,
314 mut effects: ResMut<EffectRequestQueue>,
315 mut state: ResMut<RuntimeState>,
316 mut processed: ResMut<ProcessedCommands>,
317 ) {
318 for command in commands.drain() {
319 processed.0 += 1;
320 state.value["lastCommand"] = Value::String(command.name.clone());
321 events.emit("command.processed", json!({ "commandId": command.id }));
322 effects.request("agent.invoke", command.payload);
323 }
324 }
325
326 #[test]
327 fn plugins_define_runtime_behavior() {
328 let mut runtime = HeadlessRuntime::new();
329 runtime
330 .app_mut()
331 .init_resource::<ProcessedCommands>()
332 .add_systems(RuntimeSchedule, echo_commands);
333
334 let advance = runtime.dispatch(RuntimeCommand::new(
335 "command-1",
336 "player.message",
337 json!({ "text": "Hello" }),
338 ));
339
340 assert_eq!(advance.snapshot.revision, 1);
341 assert_eq!(advance.snapshot.state["lastCommand"], "player.message");
342 assert_eq!(advance.events[0].name, "command.processed");
343 assert_eq!(advance.effects[0].kind, "agent.invoke");
344 }
345
346 #[test]
347 fn snapshots_can_be_restored() {
348 let runtime = HeadlessRuntime::restore(RuntimeSnapshot {
349 revision: 7,
350 state: json!({ "scene": "platform" }),
351 });
352
353 assert_eq!(runtime.snapshot().revision, 7);
354 assert_eq!(runtime.snapshot().state["scene"], "platform");
355 }
356
357 #[derive(Clone, Debug, Hash, PartialEq, Eq, ScheduleLabel)]
358 struct ExtensionSchedule;
359
360 fn run_extension(
361 mut commands: ResMut<RuntimeCommandQueue>,
362 mut events: ResMut<RuntimeEventQueue>,
363 mut state: ResMut<RuntimeState>,
364 ) {
365 let command = commands.pop_front().expect("extension command");
366 state.revision = 9;
367 state.value = command.payload;
368 events.emit("extension.completed", json!({ "commandId": command.id }));
369 }
370
371 #[test]
372 fn extensions_run_on_the_shared_runtime_host() {
373 let mut runtime = HeadlessRuntime::new();
374 runtime
375 .app_mut()
376 .init_schedule(ExtensionSchedule)
377 .add_systems(ExtensionSchedule, run_extension);
378 runtime.enqueue_command(RuntimeCommand::new(
379 "command-9",
380 "extension.run",
381 json!({ "result": "ok" }),
382 ));
383
384 let advance = runtime.run_schedule(ExtensionSchedule);
385
386 assert_eq!(advance.snapshot.revision, 9);
387 assert_eq!(advance.snapshot.state["result"], "ok");
388 assert_eq!(advance.events[0].name, "extension.completed");
389 }
390}