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}