mumble_server_runtime_shard/reply.rs
1//! What a flavor writes back while it observes an event.
2//!
3//! [`crate::build::ShardBuilder`] is the writing surface of the render, where a
4//! flavor states what **is**. This is the writing surface of an event, where it
5//! states what it **says**. That split is the whole rule, and it is what keeps
6//! [`crate::shard::ShardLogic`] at three methods rather than growing one per
7//! feature: a state belongs to the render, which restates it every turn until it
8//! stops being true; a word is said once, at a date, and no later render can
9//! restate it.
10//!
11//! This accumulates rather than sends. The shard drains it once `observe` has
12//! returned, which keeps a flavor free of any borrow on the shard and lets a
13//! test drive `observe` with a scratch `Reply` and read back what came out.
14//!
15//! Most of the verbs need nothing but the outbound queues the shard already
16//! owns. [`Reply::relay`] and [`Reply::announce`] need one thing more - the view,
17//! to turn an [`Audience`] into the connections that make it up - which is why
18//! they record what to deliver rather than to whom, and the shard expands them
19//! when it drains. [`Reply::switch`] is an orchestration between two shards that
20//! only a runtime can carry out, so it is recorded as an [`Effect`] and handed to
21//! whoever wired the shard up. A shard that belongs to no runtime says so out
22//! loud rather than pretending the move happened.
23
24use std::collections::BTreeMap;
25use std::sync::Arc;
26
27use crate::ids::{ChannelKey, ConnectionId, Occupant, ShardId};
28
29/// One thing a flavor said to one connection.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Word {
32 /// Plain speech, shown in the client's message log.
33 Say(String),
34 /// A refusal, shown wherever the client reports denials.
35 Refuse(String),
36}
37
38/// Who a message is for, in the flavor's own vocabulary.
39///
40/// Keys and occupants rather than wire identifiers, exactly like
41/// [`crate::shard::ActionTarget`]: the flavor names what it rendered, and the
42/// shard is what turns that into the sessions and channel ids a client holds.
43///
44/// Expanding an audience needs the view, which a [`Reply`] deliberately does not
45/// have, so the expansion happens when the shard drains what was said.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Audience {
48 /// Everyone shown in that channel.
49 Channel(ChannelKey),
50 /// Everyone shown in that channel or in any channel below it.
51 Tree(ChannelKey),
52 /// One occupant, privately.
53 User(Occupant),
54}
55
56/// One message a flavor asked to have delivered to an audience.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Spoken {
59 /// Who it is attributed to. `None` means the server itself, which is what
60 /// makes the client label it as coming from the server rather than a user.
61 pub from: Option<ConnectionId>,
62 pub to: Audience,
63 pub text: String,
64}
65
66/// Something a flavor asked for that its shard cannot carry out alone.
67///
68/// Deliberately a value rather than a call: the flavor states what it wants, the
69/// runtime decides how, and a shard running outside one can report the gap
70/// instead of silently doing nothing.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum Effect {
74 /// Hand a connection to another shard.
75 Move {
76 connection: ConnectionId,
77 to: ShardId,
78 },
79}
80
81/// Where a shard sends what it cannot do itself.
82///
83/// Called on the shard's task, so an implementation must not block and must not
84/// await: the gateway's own is a non-blocking send into the runtime's mailbox.
85pub type Effects = Arc<dyn Fn(Effect) + Send + Sync>;
86
87/// What a flavor said during one [`crate::shard::ShardLogic::observe`].
88#[derive(Debug, Default)]
89pub struct Reply {
90 words: BTreeMap<ConnectionId, Vec<Word>>,
91 spoken: Vec<Spoken>,
92 effects: Vec<Effect>,
93}
94
95impl Reply {
96 /// Tell a connection something, in the server's own name.
97 pub fn say(&mut self, to: ConnectionId, text: &str) {
98 self.words
99 .entry(to)
100 .or_default()
101 .push(Word::Say(text.to_owned()));
102 }
103
104 /// Refuse, visibly.
105 ///
106 /// The point is that the client learns something happened. A flavor that
107 /// stays silent leaves the user pressing a button that does nothing, which
108 /// is the state this type exists to end.
109 pub fn refuse(&mut self, to: ConnectionId, reason: &str) {
110 self.words
111 .entry(to)
112 .or_default()
113 .push(Word::Refuse(reason.to_owned()));
114 }
115
116 /// Deliver `text` to `to`, attributed to `from`.
117 ///
118 /// The answer to a [`crate::shard::VoiceEvent::Said`] a flavor is willing to
119 /// carry out, and the one place a client's own words reach other clients.
120 /// The flavor stays in charge of the audience: relaying somewhere other than
121 /// where the message was aimed is a rewrite, not a workaround.
122 ///
123 /// Two rules the shard applies when it expands this, both borrowed from
124 /// elsewhere in the model rather than invented here:
125 ///
126 /// - The sender never receives its own message.
127 /// - A recipient that cannot see `from` is skipped. That is the audio
128 /// coupling rule - a receiver must see the sender - applied to text, and
129 /// naming a session the recipient does not hold would break the view
130 /// invariants anyway. Use [`Reply::announce`] for something everyone
131 /// should read whoever said it.
132 pub fn relay(&mut self, from: ConnectionId, to: Audience, text: &str) {
133 self.spoken.push(Spoken {
134 from: Some(from),
135 to,
136 text: text.to_owned(),
137 });
138 }
139
140 /// Deliver `text` to `to`, in the server's own name.
141 ///
142 /// [`Reply::say`] addressed to a group: no actor, so no recipient is skipped
143 /// for not seeing one.
144 pub fn announce(&mut self, to: Audience, text: &str) {
145 self.spoken.push(Spoken {
146 from: None,
147 to,
148 text: text.to_owned(),
149 });
150 }
151
152 /// Hand a connection to another shard.
153 ///
154 /// Not a disconnect followed by a connect: the source hands over the view the
155 /// client still holds and the destination plans one transition from it. What
156 /// the flavor said in the same breath is delivered **first**, so a farewell
157 /// reaches the socket before the move is asked for.
158 pub fn switch(&mut self, connection: ConnectionId, to: ShardId) {
159 self.effects.push(Effect::Move { connection, to });
160 }
161
162 #[must_use]
163 pub fn is_empty(&self) -> bool {
164 self.words.is_empty() && self.spoken.is_empty() && self.effects.is_empty()
165 }
166
167 /// What was said, per connection, in the order it was said. Empties the
168 /// reply.
169 #[must_use]
170 pub fn drain(&mut self) -> BTreeMap<ConnectionId, Vec<Word>> {
171 std::mem::take(&mut self.words)
172 }
173
174 /// What was addressed to an audience, in the order it was said. Empties the
175 /// reply.
176 #[must_use]
177 pub fn drain_spoken(&mut self) -> Vec<Spoken> {
178 std::mem::take(&mut self.spoken)
179 }
180
181 /// What the flavor asked the runtime for, in the order it asked. Empties the
182 /// reply.
183 #[must_use]
184 pub fn drain_effects(&mut self) -> Vec<Effect> {
185 std::mem::take(&mut self.effects)
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::ids::ChannelKey;
193
194 #[test]
195 fn words_keep_their_order_within_a_connection() {
196 let mut reply = Reply::default();
197 reply.say(ConnectionId(1), "first");
198 reply.refuse(ConnectionId(2), "not here");
199 reply.say(ConnectionId(1), "second");
200
201 let drained = reply.drain();
202 assert_eq!(
203 drained.get(&ConnectionId(1)),
204 Some(&vec![
205 Word::Say("first".to_owned()),
206 Word::Say("second".to_owned())
207 ])
208 );
209 assert_eq!(
210 drained.get(&ConnectionId(2)),
211 Some(&vec![Word::Refuse("not here".to_owned())])
212 );
213 }
214
215 #[test]
216 fn an_audience_keeps_what_it_was_told_and_who_said_it() {
217 let mut reply = Reply::default();
218 reply.relay(
219 ConnectionId(1),
220 Audience::Channel(ChannelKey(7)),
221 "hello team",
222 );
223 reply.announce(Audience::Tree(ChannelKey(0)), "the round is over");
224
225 assert_eq!(
226 reply.drain_spoken(),
227 vec![
228 Spoken {
229 from: Some(ConnectionId(1)),
230 to: Audience::Channel(ChannelKey(7)),
231 text: "hello team".to_owned(),
232 },
233 Spoken {
234 from: None,
235 to: Audience::Tree(ChannelKey(0)),
236 text: "the round is over".to_owned(),
237 },
238 ]
239 );
240 assert!(reply.is_empty(), "draining must not leave a copy behind");
241 }
242
243 #[test]
244 fn draining_empties_the_reply() {
245 let mut reply = Reply::default();
246 assert!(reply.is_empty());
247 reply.say(ConnectionId(1), "something");
248 reply.switch(ConnectionId(1), ShardId(2));
249 assert!(!reply.is_empty());
250
251 let _drained = reply.drain();
252 assert!(!reply.is_empty(), "the effects are still pending");
253 assert_eq!(
254 reply.drain_effects(),
255 vec![Effect::Move {
256 connection: ConnectionId(1),
257 to: ShardId(2)
258 }]
259 );
260 assert!(
261 reply.is_empty(),
262 "a drained reply must not say the same thing twice"
263 );
264 }
265}