Skip to main content

vectis_crdt/
document.rs

1use crate::types::*;
2use crate::rga::{RgaArray, RgaItem, ItemState, StrokeId};
3
4/// Maximum number of undo steps retained per session.
5/// Prevents unbounded memory growth in long drawing sessions.
6const MAX_UNDO_DEPTH: usize = 200;
7
8// ─── Safety limits ─────────────────────────────────────────────────────────
9// These are enforced on both local inserts and remote decoding to prevent
10// resource exhaustion from malformed or malicious data.
11
12/// Maximum points per stroke. At 240 Hz for 3 minutes = ~43k points.
13/// 50k is generous enough for any realistic session segment.
14pub const MAX_POINTS_PER_STROKE: usize = 50_000;
15
16/// Maximum total strokes (active + tombstone) per document.
17/// 100k strokes ≈ ~8 MB RGA memory — reasonable upper bound.
18pub const MAX_STROKES: usize = 100_000;
19
20/// Maximum distinct actors tracked in a VectorClock.
21/// Prevents unbounded BTreeMap growth from spoofed actor IDs.
22pub const MAX_ACTORS: usize = 10_000;
23use crate::stroke::*;
24use std::collections::HashMap;
25
26// ─── LWW Map ──────────────────────────────────────────────────────────────────
27
28/// Generic LWW-Map for metadata (viewport, grid config, etc.).
29pub struct LwwMap<K: Eq + std::hash::Hash, V: Clone> {
30    entries: HashMap<K, LwwRegister<Option<V>>>,
31}
32
33impl<K: Eq + std::hash::Hash, V: Clone> LwwMap<K, V> {
34    pub fn new() -> Self {
35        Self { entries: HashMap::new() }
36    }
37
38    pub fn set(&mut self, key: K, value: V, ts: OpId) {
39        self.entries
40            .entry(key)
41            .and_modify(|reg| { reg.apply(Some(value.clone()), ts); })
42            .or_insert_with(|| LwwRegister::new(Some(value), ts));
43    }
44
45    pub fn delete(&mut self, key: K, ts: OpId) {
46        self.entries
47            .entry(key)
48            .and_modify(|reg| { reg.apply(None, ts); })
49            .or_insert_with(|| LwwRegister::new(None, ts));
50    }
51
52    pub fn get(&self, key: &K) -> Option<&V> {
53        self.entries.get(key)?.value.as_ref()
54    }
55
56    pub fn iter(&self) -> impl Iterator<Item = (&K, &Option<V>)> {
57        self.entries.iter().map(|(k, reg)| (k, &reg.value))
58    }
59}
60
61impl<K: Eq + std::hash::Hash, V: Clone> Default for LwwMap<K, V> {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67// ─── Metadata ─────────────────────────────────────────────────────────────────
68
69/// Well-known keys for the metadata LWW-Map.
70#[derive(Debug, Clone, PartialEq, Eq, Hash)]
71pub enum MetadataKey {
72    ViewportX,
73    ViewportY,
74    ViewportZoom,
75    BackgroundColor,
76    GridEnabled,
77    GridSpacing,
78    Custom(String),
79}
80
81/// Typed value for metadata.
82#[derive(Debug, Clone)]
83pub enum MetadataValue {
84    F64(f64),
85    Bool(bool),
86    U32(u32),
87    String(String),
88}
89
90// ─── Operations ───────────────────────────────────────────────────────────────
91
92/// A serializable operation. This is what travels over the network
93/// and is stored in the op-log.
94#[derive(Debug, Clone)]
95pub enum Operation {
96    InsertStroke {
97        id: OpId,
98        origin_left: OpId,
99        origin_right: OpId,
100        data: StrokeData,
101        properties: StrokeProperties,
102    },
103    DeleteStroke {
104        /// OpId of this delete operation.
105        id: OpId,
106        /// The stroke to delete.
107        target: StrokeId,
108    },
109    UpdateProperty {
110        id: OpId,
111        target: StrokeId,
112        update: PropertyUpdate,
113    },
114    UpdateMetadata {
115        id: OpId,
116        key: MetadataKey,
117        /// None = delete the key.
118        value: Option<MetadataValue>,
119    },
120}
121
122#[derive(Debug, Clone)]
123pub enum PropertyUpdate {
124    Color(u32),
125    StrokeWidth(f32),
126    Opacity(f32),
127    Transform(Transform2D),
128}
129
130impl Operation {
131    /// Extract the primary OpId of this operation.
132    pub fn id(&self) -> OpId {
133        match self {
134            Operation::InsertStroke { id, .. } => *id,
135            Operation::DeleteStroke { id, .. } => *id,
136            Operation::UpdateProperty { id, .. } => *id,
137            Operation::UpdateMetadata { id, .. } => *id,
138        }
139    }
140
141    /// Extract the affected stroke ID (if any).
142    pub fn target_stroke(&self) -> Option<StrokeId> {
143        match self {
144            Operation::InsertStroke { id, .. } => Some(*id),
145            Operation::DeleteStroke { target, .. } => Some(*target),
146            Operation::UpdateProperty { target, .. } => Some(*target),
147            Operation::UpdateMetadata { .. } => None,
148        }
149    }
150}
151
152// ─── Document ─────────────────────────────────────────────────────────────────
153
154/// The document root. Contains the full CRDT state.
155pub struct Document {
156    // — Identity —
157    /// Actor ID of this client. Assigned by server on first connect.
158    pub local_actor: ActorId,
159    /// Lamport clock. `pub(crate)` — must only advance via `next_op_id`.
160    pub(crate) clock: LamportTs,
161
162    // — Causal Tracking —
163    /// Vector clock: tracks what we've seen from each peer.
164    /// `pub` — read by callers for delta-sync state vector encoding.
165    pub version: VectorClock,
166
167    // — CRDT Structures —
168    /// Ordered list of strokes (z-ordering). RGA.
169    /// `pub(crate)` — external mutation would corrupt the index.
170    pub(crate) stroke_order: RgaArray,
171    /// Stroke data + properties store.
172    /// `pub(crate)` — external mutation bypasses CRDT invariants.
173    pub(crate) stroke_store: StrokeStore,
174    /// Canvas metadata (viewport, grid, etc.). LWW-Map.
175    pub(crate) metadata: LwwMap<MetadataKey, MetadataValue>,
176
177    // — Op Log —
178    /// Pending operations to send to the server.
179    /// `pub(crate)` — drain via `take_pending_ops()` from outside the crate.
180    pub(crate) pending_ops: Vec<Operation>,
181
182    // — GC —
183    /// Minimum Version Vector: the version that ALL known peers have
184    /// confirmed. Operations ≤ MVV are "causally stable" and GC-eligible.
185    /// `pub(crate)` — must only be set via `update_min_version()` to
186    /// prevent premature GC that would corrupt the document.
187    pub(crate) min_version: VectorClock,
188    /// GC run counter for statistics.
189    pub(crate) gc_generation: u64,
190
191    // — Local UX —
192    /// Session-local undo stack. Not persisted; not serialized to wire.
193    undo_stack: Vec<StrokeId>,
194
195    /// RDP epsilon applied automatically to every inserted stroke.
196    /// Set to `0.0` to disable auto-simplification.
197    /// Default: `0.5` (sub-pixel accuracy on high-DPI displays).
198    pub simplify_epsilon: f32,
199}
200
201impl Document {
202    pub fn new(actor: ActorId) -> Self {
203        Self {
204            local_actor: actor,
205            clock: LamportTs(0),
206            version: VectorClock::default(),
207            stroke_order: RgaArray::new(),
208            stroke_store: StrokeStore::new(),
209            metadata: LwwMap::new(),
210            pending_ops: Vec::new(),
211            min_version: VectorClock::default(),
212            gc_generation: 0,
213            undo_stack: Vec::new(),
214            simplify_epsilon: 0.5,
215        }
216    }
217
218    /// Drain and return all pending operations accumulated since the last flush.
219    /// Call this to get the ops to encode and send to the server.
220    pub fn take_pending_ops(&mut self) -> Vec<Operation> {
221        std::mem::take(&mut self.pending_ops)
222    }
223
224    /// Generate the next OpId for a local operation.
225    pub(crate) fn next_op_id(&mut self) -> OpId {
226        let ts = self.clock.tick();
227        let id = OpId { lamport: ts, actor: self.local_actor };
228        self.version.advance(self.local_actor, ts.0);
229        id
230    }
231
232    // ─── High-Level Local API ─────────────────────────────────────────────────
233
234    /// Insert a stroke at the top of the z-order (after all existing strokes).
235    ///
236    /// If `simplify_epsilon > 0`, automatically applies Ramer-Douglas-Peucker
237    /// before storing and broadcasting — reducing point count and wire size.
238    pub fn insert_stroke(&mut self, mut data: StrokeData, properties: StrokeProperties) -> StrokeId {
239        // Auto-simplify before storing. Benefits: smaller RAM, smaller wire payload.
240        // Threshold > 2 because simplify() already no-ops on ≤ 2 points.
241        if self.simplify_epsilon > 0.0 && data.points.len() > 2 {
242            data.simplify(self.simplify_epsilon);
243        }
244
245        let id = self.next_op_id();
246
247        let origin_left = self
248            .stroke_order
249            .visible_items()
250            .last()
251            .map(|item| item.id)
252            .unwrap_or(OpId::ZERO);
253
254        let item = RgaItem {
255            id,
256            origin_left,
257            origin_right: OpId::ZERO,
258            content: id,
259            state: ItemState::Active,
260        };
261
262        self.stroke_order.integrate(item);
263        self.stroke_store.insert(id, data.clone(), properties.clone());
264
265        self.pending_ops.push(Operation::InsertStroke {
266            id,
267            origin_left,
268            origin_right: OpId::ZERO,
269            data,
270            properties,
271        });
272
273        // Track for undo. Drop the oldest entry if at capacity.
274        if self.undo_stack.len() >= MAX_UNDO_DEPTH {
275            self.undo_stack.remove(0);
276        }
277        self.undo_stack.push(id);
278
279        id
280    }
281
282    /// Delete a stroke by ID.
283    pub fn delete_stroke(&mut self, target: StrokeId) -> bool {
284        if !self.stroke_store.contains(&target) {
285            return false;
286        }
287        let id = self.next_op_id();
288        let deleted = self.stroke_order.mark_deleted(target, id);
289        if deleted {
290            self.pending_ops.push(Operation::DeleteStroke { id, target });
291        }
292        deleted
293    }
294
295    /// Update stroke color.
296    pub fn update_color(&mut self, target: StrokeId, color: u32) -> bool {
297        let id = self.next_op_id();
298        if let Some((_, props)) = self.stroke_store.get_mut(&target) {
299            props.color.apply(color, id);
300            self.pending_ops.push(Operation::UpdateProperty {
301                id,
302                target,
303                update: PropertyUpdate::Color(color),
304            });
305            true
306        } else {
307            false
308        }
309    }
310
311    /// Update stroke width.
312    pub fn update_stroke_width(&mut self, target: StrokeId, width: f32) -> bool {
313        let id = self.next_op_id();
314        if let Some((_, props)) = self.stroke_store.get_mut(&target) {
315            props.stroke_width.apply(width, id);
316            self.pending_ops.push(Operation::UpdateProperty {
317                id,
318                target,
319                update: PropertyUpdate::StrokeWidth(width),
320            });
321            true
322        } else {
323            false
324        }
325    }
326
327    /// Update stroke opacity.
328    pub fn update_opacity(&mut self, target: StrokeId, opacity: f32) -> bool {
329        let id = self.next_op_id();
330        if let Some((_, props)) = self.stroke_store.get_mut(&target) {
331            props.opacity.apply(opacity, id);
332            self.pending_ops.push(Operation::UpdateProperty {
333                id,
334                target,
335                update: PropertyUpdate::Opacity(opacity),
336            });
337            true
338        } else {
339            false
340        }
341    }
342
343    /// Update stroke transform.
344    pub fn update_transform(&mut self, target: StrokeId, transform: Transform2D) -> bool {
345        let id = self.next_op_id();
346        if let Some((_, props)) = self.stroke_store.get_mut(&target) {
347            props.transform.apply(transform, id);
348            self.pending_ops.push(Operation::UpdateProperty {
349                id,
350                target,
351                update: PropertyUpdate::Transform(transform),
352            });
353            true
354        } else {
355            false
356        }
357    }
358
359    /// Set a metadata key.
360    pub fn set_metadata(&mut self, key: MetadataKey, value: MetadataValue) {
361        let id = self.next_op_id();
362        self.metadata.set(key.clone(), value.clone(), id);
363        self.pending_ops.push(Operation::UpdateMetadata {
364            id,
365            key,
366            value: Some(value),
367        });
368    }
369
370    // ─── Remote Apply ─────────────────────────────────────────────────────────
371
372    /// Apply a remote operation. Core of the merge.
373    /// Returns the affected StrokeId (if any) for incremental re-render.
374    pub fn apply_remote(&mut self, op: Operation) -> Option<StrokeId> {
375        // Advance Lamport clock with the remote timestamp.
376        let op_id = op.id();
377        self.clock.merge(op_id.lamport);
378        self.version.advance(op_id.actor, op_id.lamport.0);
379
380        let affected = op.target_stroke();
381
382        match op {
383            Operation::InsertStroke { id, origin_left, origin_right, data, properties } => {
384                // Skip if already applied (idempotent).
385                if !self.stroke_store.contains(&id) {
386                    // Enforce document size limit. Silently drop if exceeded —
387                    // better than OOM. In practice the server should enforce
388                    // this before broadcasting.
389                    if self.stroke_order.total_count >= MAX_STROKES {
390                        return affected;
391                    }
392                    let item = RgaItem {
393                        id,
394                        origin_left,
395                        origin_right,
396                        content: id,
397                        state: ItemState::Active,
398                    };
399                    self.stroke_order.integrate(item);
400                    self.stroke_store.insert(id, data, properties);
401                }
402            }
403
404            Operation::DeleteStroke { id, target } => {
405                self.stroke_order.mark_deleted(target, id);
406                // Do NOT remove from stroke_store yet — wait for GC.
407            }
408
409            Operation::UpdateProperty { id, target, update } => {
410                if let Some((_, props)) = self.stroke_store.get_mut(&target) {
411                    match update {
412                        PropertyUpdate::Color(v) => { props.color.apply(v, id); }
413                        PropertyUpdate::StrokeWidth(v) => { props.stroke_width.apply(v, id); }
414                        PropertyUpdate::Opacity(v) => { props.opacity.apply(v, id); }
415                        PropertyUpdate::Transform(v) => { props.transform.apply(v, id); }
416                    }
417                }
418            }
419
420            Operation::UpdateMetadata { id, key, value } => {
421                match value {
422                    Some(v) => self.metadata.set(key, v, id),
423                    None => self.metadata.delete(key, id),
424                }
425            }
426        }
427
428        affected
429    }
430
431    // ─── Simplification ──────────────────────────────────────────────────────
432
433    /// Simplify an existing stroke's points using Ramer-Douglas-Peucker.
434    /// Returns the number of points removed, or 0 if not found.
435    ///
436    /// Does NOT generate a pending op — simplification is a local optimization
437    /// that reduces memory. The simplified version is only sent if the stroke
438    /// is subsequently re-inserted. For existing strokes already synced to
439    /// peers, use this only on your local replica.
440    pub fn simplify_stroke(&mut self, target: StrokeId, epsilon: f32) -> usize {
441        if let Some(data) = self.stroke_store.get_data_mut(&target) {
442            data.simplify(epsilon)
443        } else {
444            0
445        }
446    }
447
448    // ─── Undo ─────────────────────────────────────────────────────────────────
449
450    /// Undo the last stroke drawn by the local actor.
451    ///
452    /// Pops the undo stack and deletes the stroke — generating a `DeleteStroke`
453    /// operation that will be broadcast to peers. If the stroke was already
454    /// deleted remotely, skips it and tries the next one.
455    ///
456    /// Returns the `StrokeId` that was deleted, or `None` if the stack is empty.
457    pub fn undo_last_stroke(&mut self) -> Option<StrokeId> {
458        while let Some(id) = self.undo_stack.pop() {
459            // delete_stroke returns false if already tombstoned (remote delete).
460            if self.delete_stroke(id) {
461                return Some(id);
462            }
463            // Stroke was deleted remotely → skip and try previous.
464        }
465        None
466    }
467
468    /// Number of strokes currently available to undo.
469    pub fn undo_depth(&self) -> usize {
470        self.undo_stack.len()
471    }
472
473    // ─── Queries ──────────────────────────────────────────────────────────────
474
475    /// Returns visible stroke IDs in z-order (bottom to top).
476    pub fn visible_stroke_ids(&self) -> Vec<StrokeId> {
477        self.stroke_order
478            .visible_items()
479            .map(|item| item.content)
480            .collect()
481    }
482
483    /// Returns the stroke data + properties for a given ID.
484    pub fn get_stroke(&self, id: &StrokeId) -> Option<(&StrokeData, &StrokeProperties)> {
485        self.stroke_store.get(id)
486    }
487
488    // ─── Statistics ───────────────────────────────────────────────────────────
489
490    pub fn stats(&self) -> DocumentStats {
491        DocumentStats {
492            total_items: self.stroke_order.total_count,
493            visible_items: self.stroke_order.total_count - self.stroke_order.tombstone_count,
494            tombstones: self.stroke_order.tombstone_count,
495            tombstone_ratio: self.stroke_order.tombstone_ratio(),
496            gc_generation: self.gc_generation,
497            pending_ops: self.pending_ops.len(),
498        }
499    }
500}
501
502/// Snapshot of document statistics.
503#[derive(Debug, Clone)]
504pub struct DocumentStats {
505    pub total_items: usize,
506    pub visible_items: usize,
507    pub tombstones: usize,
508    pub tombstone_ratio: f64,
509    pub gc_generation: u64,
510    pub pending_ops: usize,
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    fn make_doc(actor: u64) -> Document {
518        Document::new(ActorId(actor))
519    }
520
521    fn simple_stroke(points: &[(f32, f32)]) -> (StrokeData, StrokeProperties) {
522        let pts: Box<[StrokePoint]> = points.iter().map(|&(x, y)| StrokePoint::basic(x, y)).collect();
523        let data = StrokeData::new(pts, ToolKind::Pen);
524        let props = StrokeProperties::new(0xFF0000FF, 2.0, 1.0, OpId::ZERO);
525        (data, props)
526    }
527
528    #[test]
529    fn insert_and_visible() {
530        let mut doc = make_doc(1);
531        let (data, props) = simple_stroke(&[(0.0, 0.0), (10.0, 10.0)]);
532        let id = doc.insert_stroke(data, props);
533        let visible = doc.visible_stroke_ids();
534        assert_eq!(visible, vec![id]);
535    }
536
537    #[test]
538    fn delete_stroke() {
539        let mut doc = make_doc(1);
540        let (data, props) = simple_stroke(&[(0.0, 0.0)]);
541        let id = doc.insert_stroke(data, props);
542        assert!(doc.delete_stroke(id));
543        assert!(doc.visible_stroke_ids().is_empty());
544    }
545
546    #[test]
547    fn remote_insert_merge() {
548        let mut doc_a = make_doc(1);
549        let mut doc_b = make_doc(2);
550
551        let (data, props) = simple_stroke(&[(0.0, 0.0)]);
552        let id_a = doc_a.insert_stroke(data.clone(), props.clone());
553
554        // Transfer pending ops from A to B.
555        let ops = std::mem::take(&mut doc_a.pending_ops);
556        for op in ops {
557            doc_b.apply_remote(op);
558        }
559
560        let visible_b = doc_b.visible_stroke_ids();
561        assert_eq!(visible_b, vec![id_a]);
562    }
563
564    #[test]
565    fn concurrent_insert_convergence() {
566        let mut doc_a = make_doc(1);
567        let mut doc_b = make_doc(2);
568
569        let (data, props) = simple_stroke(&[(1.0, 1.0)]);
570        let _id_a = doc_a.insert_stroke(data.clone(), props.clone());
571        let _id_b = doc_b.insert_stroke(data.clone(), props.clone());
572
573        let ops_a = std::mem::take(&mut doc_a.pending_ops);
574        let ops_b = std::mem::take(&mut doc_b.pending_ops);
575
576        for op in ops_b.clone() { doc_a.apply_remote(op); }
577        for op in ops_a.clone() { doc_b.apply_remote(op); }
578
579        let visible_a = doc_a.visible_stroke_ids();
580        let visible_b = doc_b.visible_stroke_ids();
581        assert_eq!(visible_a, visible_b, "docs must converge");
582        assert_eq!(visible_a.len(), 2);
583    }
584
585    #[test]
586    fn undo_last_stroke_basic() {
587        let mut doc = make_doc(1);
588        // Disable auto-simplify so 1-point strokes aren't modified.
589        doc.simplify_epsilon = 0.0;
590
591        let (d1, p1) = simple_stroke(&[(0.0, 0.0)]);
592        let (d2, p2) = simple_stroke(&[(1.0, 1.0)]);
593        let id1 = doc.insert_stroke(d1, p1);
594        let _id2 = doc.insert_stroke(d2, p2);
595        assert_eq!(doc.visible_stroke_ids().len(), 2);
596        assert_eq!(doc.undo_depth(), 2);
597
598        // Undo last insert (id2)
599        let undone = doc.undo_last_stroke();
600        assert!(undone.is_some());
601        assert_eq!(doc.visible_stroke_ids(), vec![id1]);
602        assert_eq!(doc.undo_depth(), 1);
603
604        // Undo first insert (id1)
605        let undone2 = doc.undo_last_stroke();
606        assert!(undone2.is_some());
607        assert!(doc.visible_stroke_ids().is_empty());
608        assert_eq!(doc.undo_depth(), 0);
609
610        // Nothing left to undo
611        assert!(doc.undo_last_stroke().is_none());
612    }
613
614    #[test]
615    fn undo_skips_remotely_deleted_strokes() {
616        let mut doc = make_doc(1);
617        doc.simplify_epsilon = 0.0;
618
619        let (d, p) = simple_stroke(&[(0.0, 0.0)]);
620        let id = doc.insert_stroke(d, p);
621        assert_eq!(doc.undo_depth(), 1);
622
623        // Simulate remote delete arriving before undo
624        let del_op = Operation::DeleteStroke {
625            id: OpId { lamport: LamportTs(99), actor: ActorId(2) },
626            target: id,
627        };
628        doc.apply_remote(del_op);
629        assert!(doc.visible_stroke_ids().is_empty());
630
631        // Undo should silently skip the already-deleted stroke
632        let result = doc.undo_last_stroke();
633        assert!(result.is_none(), "nothing left to undo");
634    }
635
636    #[test]
637    fn undo_generates_pending_delete_op() {
638        let mut doc = make_doc(1);
639        doc.simplify_epsilon = 0.0;
640
641        let (d, p) = simple_stroke(&[(0.0, 0.0)]);
642        doc.insert_stroke(d, p);
643        let _ = std::mem::take(&mut doc.pending_ops); // flush insert
644
645        doc.undo_last_stroke();
646        let ops = std::mem::take(&mut doc.pending_ops);
647        assert_eq!(ops.len(), 1, "undo should produce a DeleteStroke op");
648        assert!(matches!(ops[0], Operation::DeleteStroke { .. }));
649    }
650
651    #[test]
652    fn auto_simplify_enabled_by_default() {
653        let mut doc = make_doc(1);
654        // Default epsilon = 0.5; insert a straight line with many points.
655        let pts: Box<[StrokePoint]> = (0..100)
656            .map(|i| StrokePoint::basic(i as f32, i as f32))
657            .collect();
658        let data = StrokeData::new(pts, ToolKind::Pen);
659        let props = StrokeProperties::new(0xFF0000FF, 2.0, 1.0, OpId::ZERO);
660        let id = doc.insert_stroke(data, props);
661
662        // Stroke stored with simplified points
663        let (stored, _) = doc.get_stroke(&id).unwrap();
664        assert!(stored.points.len() < 100, "auto-simplify should reduce points");
665        assert_eq!(stored.points.len(), 2, "straight line → 2 points");
666    }
667
668    #[test]
669    fn auto_simplify_disabled() {
670        let mut doc = make_doc(1);
671        doc.simplify_epsilon = 0.0; // disabled
672
673        let pts: Box<[StrokePoint]> = (0..100)
674            .map(|i| StrokePoint::basic(i as f32, i as f32))
675            .collect();
676        let n = pts.len();
677        let data = StrokeData::new(pts, ToolKind::Pen);
678        let props = StrokeProperties::new(0xFF0000FF, 2.0, 1.0, OpId::ZERO);
679        let id = doc.insert_stroke(data, props);
680
681        let (stored, _) = doc.get_stroke(&id).unwrap();
682        assert_eq!(stored.points.len(), n, "no simplification when epsilon=0");
683    }
684}