Skip to main content

steel_core/
scoreboard.rs

1//! Domain-scoped command scoreboard state.
2//!
3//! This module owns the scoreboard data needed by selectors and command
4//! execution: objective identity and mutability, score values and locks, and
5//! team membership. Display slots and client presentation are outside this
6//! command-system scope.
7
8use std::{
9    collections::{BTreeMap, BTreeSet, btree_map::Entry},
10    io,
11    sync::atomic::{AtomicU64, Ordering},
12};
13
14use serde::{Deserialize, Serialize};
15use steel_utils::locks::{AsyncMutex, SyncRwLock};
16use thiserror::Error;
17
18use crate::{server::worlds::WorldMap, world::World};
19use steel_utils::saved_data::names as saved_data_names;
20
21/// Score holder name stored by the vanilla scoreboard.
22#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct ScoreHolder {
24    name: String,
25}
26
27impl ScoreHolder {
28    /// Creates a score holder from its scoreboard name.
29    #[must_use]
30    pub fn new(name: impl Into<String>) -> Self {
31        Self { name: name.into() }
32    }
33
34    /// Returns the scoreboard name.
35    #[must_use]
36    pub fn name(&self) -> &str {
37        &self.name
38    }
39}
40
41/// Objective identity resolved from one domain scoreboard.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct ScoreboardObjective {
44    name: String,
45    read_only: bool,
46}
47
48impl ScoreboardObjective {
49    /// Returns the objective name.
50    #[must_use]
51    pub fn name(&self) -> &str {
52        &self.name
53    }
54
55    /// Returns whether commands may change scores for this objective.
56    #[must_use]
57    pub const fn is_read_only(&self) -> bool {
58        self.read_only
59    }
60}
61
62/// Team identity resolved from one domain scoreboard.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct ScoreboardTeam {
65    name: String,
66}
67
68impl ScoreboardTeam {
69    /// Returns the team name.
70    #[must_use]
71    pub fn name(&self) -> &str {
72        &self.name
73    }
74}
75
76/// Stored score fields used by command execution.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(default, deny_unknown_fields)]
79pub struct ScoreboardScore {
80    value: i32,
81    #[serde(default = "default_score_locked")]
82    locked: bool,
83}
84
85impl ScoreboardScore {
86    const fn new(value: i32) -> Self {
87        Self {
88            value,
89            locked: true,
90        }
91    }
92
93    /// Returns the integer score value.
94    #[must_use]
95    pub const fn value(self) -> i32 {
96        self.value
97    }
98
99    /// Returns whether `/trigger`-style writes are locked.
100    #[must_use]
101    pub const fn is_locked(self) -> bool {
102        self.locked
103    }
104}
105
106impl Default for ScoreboardScore {
107    fn default() -> Self {
108        Self::new(0)
109    }
110}
111
112const fn default_score_locked() -> bool {
113    true
114}
115
116#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(default, deny_unknown_fields)]
118struct ObjectiveState {
119    read_only: bool,
120}
121
122#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(default, deny_unknown_fields)]
124struct PersistentScoreboard {
125    objectives: BTreeMap<String, ObjectiveState>,
126    scores: BTreeMap<String, BTreeMap<String, ScoreboardScore>>,
127    teams: BTreeSet<String>,
128    holder_teams: BTreeMap<String, String>,
129}
130
131/// Invalid scoreboard operation or persisted state.
132#[derive(Clone, Debug, Error, PartialEq, Eq)]
133pub enum ScoreboardError {
134    /// Objective names may not be empty.
135    #[error("objective name cannot be empty")]
136    EmptyObjectiveName,
137    /// Team names may not be empty.
138    #[error("team name cannot be empty")]
139    EmptyTeamName,
140    /// Score holder names may not be empty.
141    #[error("score holder name cannot be empty")]
142    EmptyScoreHolderName,
143    /// An objective already exists.
144    #[error("objective '{0}' already exists")]
145    DuplicateObjective(String),
146    /// A team already exists.
147    #[error("team '{0}' already exists")]
148    DuplicateTeam(String),
149    /// The requested objective does not exist.
150    #[error("objective '{0}' does not exist")]
151    MissingObjective(String),
152    /// The requested team does not exist.
153    #[error("team '{0}' does not exist")]
154    MissingTeam(String),
155    /// The objective cannot be written by commands.
156    #[error("objective '{0}' is read-only")]
157    ReadOnlyObjective(String),
158}
159
160struct ScoreboardSaveSnapshot {
161    revision: u64,
162    state: PersistentScoreboard,
163}
164
165/// Command-facing scoreboard for one Steel domain.
166pub struct Scoreboard {
167    state: SyncRwLock<PersistentScoreboard>,
168    revision: AtomicU64,
169    saved_revision: AtomicU64,
170}
171
172impl Scoreboard {
173    /// Creates an empty, clean scoreboard.
174    #[must_use]
175    pub fn new() -> Self {
176        Self {
177            state: SyncRwLock::new(PersistentScoreboard::default()),
178            revision: AtomicU64::new(0),
179            saved_revision: AtomicU64::new(0),
180        }
181    }
182
183    fn from_persistent(state: PersistentScoreboard) -> Result<Self, ScoreboardError> {
184        validate_persistent_scoreboard(&state)?;
185        Ok(Self {
186            state: SyncRwLock::new(state),
187            revision: AtomicU64::new(0),
188            saved_revision: AtomicU64::new(0),
189        })
190    }
191
192    /// Adds a writable objective.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if the objective name is empty or already exists.
197    pub fn add_objective(
198        &self,
199        name: impl Into<String>,
200    ) -> Result<ScoreboardObjective, ScoreboardError> {
201        self.add_objective_with_read_only(name, false)
202    }
203
204    /// Adds an objective with explicit command mutability.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if the objective name is empty or already exists.
209    pub fn add_objective_with_read_only(
210        &self,
211        name: impl Into<String>,
212        read_only: bool,
213    ) -> Result<ScoreboardObjective, ScoreboardError> {
214        let name = name.into();
215        ensure_objective_name(&name)?;
216        let mut state = self.state.write();
217        if state.objectives.contains_key(&name) {
218            return Err(ScoreboardError::DuplicateObjective(name));
219        }
220        state
221            .objectives
222            .insert(name.clone(), ObjectiveState { read_only });
223        self.mark_dirty();
224        Ok(ScoreboardObjective { name, read_only })
225    }
226
227    /// Returns an objective by name.
228    #[must_use]
229    pub fn objective(&self, name: &str) -> Option<ScoreboardObjective> {
230        self.state
231            .read()
232            .objectives
233            .get(name)
234            .map(|objective| ScoreboardObjective {
235                name: name.to_owned(),
236                read_only: objective.read_only,
237            })
238    }
239
240    /// Returns objective names in stable order.
241    #[must_use]
242    pub fn objective_names(&self) -> Vec<String> {
243        self.state.read().objectives.keys().cloned().collect()
244    }
245
246    /// Adds a team.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if the team name is empty or already exists.
251    pub fn add_team(&self, name: impl Into<String>) -> Result<ScoreboardTeam, ScoreboardError> {
252        let name = name.into();
253        ensure_team_name(&name)?;
254        let mut state = self.state.write();
255        if !state.teams.insert(name.clone()) {
256            return Err(ScoreboardError::DuplicateTeam(name));
257        }
258        self.mark_dirty();
259        Ok(ScoreboardTeam { name })
260    }
261
262    /// Returns a team by name.
263    #[must_use]
264    pub fn team(&self, name: &str) -> Option<ScoreboardTeam> {
265        self.state
266            .read()
267            .teams
268            .contains(name)
269            .then(|| ScoreboardTeam {
270                name: name.to_owned(),
271            })
272    }
273
274    /// Returns team names in stable order.
275    #[must_use]
276    pub fn team_names(&self) -> Vec<String> {
277        self.state.read().teams.iter().cloned().collect()
278    }
279
280    /// Returns the current team name for a score holder.
281    #[must_use]
282    pub fn holder_team_name(&self, holder: &ScoreHolder) -> Option<String> {
283        self.state.read().holder_teams.get(holder.name()).cloned()
284    }
285
286    /// Adds a holder to a team, replacing any prior membership.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error for an empty holder or a team that no longer exists.
291    pub fn add_holder_to_team(
292        &self,
293        holder: &ScoreHolder,
294        team: &ScoreboardTeam,
295    ) -> Result<(), ScoreboardError> {
296        ensure_holder_name(holder.name())?;
297        let mut state = self.state.write();
298        if !state.teams.contains(team.name()) {
299            return Err(ScoreboardError::MissingTeam(team.name().to_owned()));
300        }
301        if state
302            .holder_teams
303            .insert(holder.name().to_owned(), team.name().to_owned())
304            .as_deref()
305            == Some(team.name())
306        {
307            return Ok(());
308        }
309        self.mark_dirty();
310        Ok(())
311    }
312
313    /// Returns tracked score holders in stable order.
314    #[must_use]
315    pub fn tracked_holders(&self) -> Vec<ScoreHolder> {
316        self.state
317            .read()
318            .scores
319            .keys()
320            .map(|name| ScoreHolder::new(name.to_owned()))
321            .collect()
322    }
323
324    /// Returns the complete score entry for a holder and objective.
325    #[must_use]
326    pub fn score_entry(
327        &self,
328        holder: &ScoreHolder,
329        objective: &ScoreboardObjective,
330    ) -> Option<ScoreboardScore> {
331        self.state
332            .read()
333            .scores
334            .get(holder.name())
335            .and_then(|scores| scores.get(objective.name()).copied())
336    }
337
338    /// Returns the integer score for a holder and objective.
339    #[must_use]
340    pub fn score(&self, holder: &ScoreHolder, objective: &ScoreboardObjective) -> Option<i32> {
341        self.score_entry(holder, objective)
342            .map(ScoreboardScore::value)
343    }
344
345    /// Sets a holder's score, preserving its lock state when already present.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error for an empty holder, a missing objective, or a read-only objective.
350    pub fn set_score(
351        &self,
352        holder: &ScoreHolder,
353        objective: &ScoreboardObjective,
354        value: i32,
355    ) -> Result<(), ScoreboardError> {
356        ensure_holder_name(holder.name())?;
357        let mut state = self.state.write();
358        ensure_writable_objective(&state, objective)?;
359        let scores = state.scores.entry(holder.name().to_owned()).or_default();
360        match scores.entry(objective.name().to_owned()) {
361            Entry::Vacant(entry) => {
362                entry.insert(ScoreboardScore::new(value));
363            }
364            Entry::Occupied(mut entry) => {
365                if entry.get().value == value {
366                    return Ok(());
367                }
368                entry.get_mut().value = value;
369            }
370        }
371        self.mark_dirty();
372        Ok(())
373    }
374
375    /// Changes a score's trigger lock state.
376    ///
377    /// # Errors
378    ///
379    /// Returns an error for an empty holder or a missing objective.
380    pub fn set_score_locked(
381        &self,
382        holder: &ScoreHolder,
383        objective: &ScoreboardObjective,
384        locked: bool,
385    ) -> Result<(), ScoreboardError> {
386        ensure_holder_name(holder.name())?;
387        let mut state = self.state.write();
388        ensure_objective_exists(&state, objective)?;
389        let scores = state.scores.entry(holder.name().to_owned()).or_default();
390        match scores.entry(objective.name().to_owned()) {
391            Entry::Vacant(entry) => {
392                entry.insert(ScoreboardScore { value: 0, locked });
393            }
394            Entry::Occupied(mut entry) => {
395                if entry.get().locked == locked {
396                    return Ok(());
397                }
398                entry.get_mut().locked = locked;
399            }
400        }
401        self.mark_dirty();
402        Ok(())
403    }
404
405    /// Returns objective names that have a score for the holder.
406    #[must_use]
407    pub fn holder_objectives(&self, holder: &ScoreHolder) -> BTreeSet<String> {
408        self.state
409            .read()
410            .scores
411            .get(holder.name())
412            .map_or_else(BTreeSet::new, |scores| scores.keys().cloned().collect())
413    }
414
415    fn mark_dirty(&self) {
416        self.revision.fetch_add(1, Ordering::Release);
417    }
418
419    fn pending_save(&self) -> Option<ScoreboardSaveSnapshot> {
420        let state = self.state.read();
421        let revision = self.revision.load(Ordering::Acquire);
422        if revision == self.saved_revision.load(Ordering::Acquire) {
423            return None;
424        }
425        Some(ScoreboardSaveSnapshot {
426            revision,
427            state: state.clone(),
428        })
429    }
430
431    fn mark_saved(&self, revision: u64) {
432        self.saved_revision.fetch_max(revision, Ordering::Release);
433    }
434}
435
436impl Default for Scoreboard {
437    fn default() -> Self {
438        Self::new()
439    }
440}
441
442fn validate_persistent_scoreboard(state: &PersistentScoreboard) -> Result<(), ScoreboardError> {
443    for name in state.objectives.keys() {
444        ensure_objective_name(name)?;
445    }
446    for name in &state.teams {
447        ensure_team_name(name)?;
448    }
449    for (holder, scores) in &state.scores {
450        ensure_holder_name(holder)?;
451        for objective in scores.keys() {
452            if !state.objectives.contains_key(objective) {
453                return Err(ScoreboardError::MissingObjective(objective.clone()));
454            }
455        }
456    }
457    for (holder, team) in &state.holder_teams {
458        ensure_holder_name(holder)?;
459        if !state.teams.contains(team) {
460            return Err(ScoreboardError::MissingTeam(team.clone()));
461        }
462    }
463    Ok(())
464}
465
466const fn ensure_objective_name(name: &str) -> Result<(), ScoreboardError> {
467    if name.is_empty() {
468        Err(ScoreboardError::EmptyObjectiveName)
469    } else {
470        Ok(())
471    }
472}
473
474const fn ensure_team_name(name: &str) -> Result<(), ScoreboardError> {
475    if name.is_empty() {
476        Err(ScoreboardError::EmptyTeamName)
477    } else {
478        Ok(())
479    }
480}
481
482const fn ensure_holder_name(name: &str) -> Result<(), ScoreboardError> {
483    if name.is_empty() {
484        Err(ScoreboardError::EmptyScoreHolderName)
485    } else {
486        Ok(())
487    }
488}
489
490fn ensure_objective_exists(
491    state: &PersistentScoreboard,
492    objective: &ScoreboardObjective,
493) -> Result<ObjectiveState, ScoreboardError> {
494    state
495        .objectives
496        .get(objective.name())
497        .copied()
498        .ok_or_else(|| ScoreboardError::MissingObjective(objective.name().to_owned()))
499}
500
501fn ensure_writable_objective(
502    state: &PersistentScoreboard,
503    objective: &ScoreboardObjective,
504) -> Result<(), ScoreboardError> {
505    if ensure_objective_exists(state, objective)?.read_only {
506        Err(ScoreboardError::ReadOnlyObjective(
507            objective.name().to_owned(),
508        ))
509    } else {
510        Ok(())
511    }
512}
513
514/// Loaded command scoreboards keyed by Steel domain.
515pub struct DomainScoreboards {
516    scoreboards: BTreeMap<String, Scoreboard>,
517    save_lock: AsyncMutex<()>,
518}
519
520impl DomainScoreboards {
521    /// Loads one scoreboard through each domain's default world saved-data boundary.
522    pub async fn load(worlds: &WorldMap) -> io::Result<Self> {
523        let mut domains = worlds.domain_names().collect::<Vec<_>>();
524        domains.sort_unstable();
525        let mut scoreboards = BTreeMap::new();
526        for domain in domains {
527            let world = domain_default_world(worlds, domain)?;
528            let persistent: PersistentScoreboard = world
529                .saved_data
530                .load_or_default(saved_data_names::SCOREBOARD)
531                .await
532                .map_err(|error| scoreboard_io_error(domain, error))?;
533            let scoreboard = Scoreboard::from_persistent(persistent).map_err(|error| {
534                io::Error::new(
535                    io::ErrorKind::InvalidData,
536                    format!("invalid scoreboard for domain '{domain}': {error}"),
537                )
538            })?;
539            scoreboards.insert(domain.to_owned(), scoreboard);
540        }
541        Ok(Self {
542            scoreboards,
543            save_lock: AsyncMutex::new(()),
544        })
545    }
546
547    /// Returns the scoreboard for a domain.
548    #[must_use]
549    pub fn get(&self, domain: &str) -> Option<&Scoreboard> {
550        self.scoreboards.get(domain)
551    }
552
553    /// Saves every dirty domain scoreboard and returns the number written.
554    pub async fn save(&self, worlds: &WorldMap) -> io::Result<usize> {
555        let _save_guard = self.save_lock.lock().await;
556        let mut saved = 0;
557        for (domain, scoreboard) in &self.scoreboards {
558            let Some(snapshot) = scoreboard.pending_save() else {
559                continue;
560            };
561            let world = domain_default_world(worlds, domain)?;
562            world
563                .saved_data
564                .save(saved_data_names::SCOREBOARD, &snapshot.state)
565                .await
566                .map_err(|error| scoreboard_io_error(domain, error))?;
567            scoreboard.mark_saved(snapshot.revision);
568            saved += 1;
569        }
570        Ok(saved)
571    }
572}
573
574fn domain_default_world<'a>(worlds: &'a WorldMap, domain: &str) -> io::Result<&'a World> {
575    worlds
576        .default_world(domain)
577        .map(AsRef::as_ref)
578        .ok_or_else(|| {
579            io::Error::new(
580                io::ErrorKind::NotFound,
581                format!("domain '{domain}' has no loaded default world"),
582            )
583        })
584}
585
586fn scoreboard_io_error(domain: &str, error: io::Error) -> io::Error {
587    io::Error::new(
588        error.kind(),
589        format!("scoreboard I/O failed for domain '{domain}': {error}"),
590    )
591}
592
593#[cfg(test)]
594mod tests {
595    use std::{
596        env::temp_dir,
597        time::{SystemTime, UNIX_EPOCH},
598    };
599    use tokio::fs;
600
601    use steel_utils::saved_data::{SavedDataManager, names as saved_data_names};
602
603    use super::{
604        AsyncMutex, DomainScoreboards, PersistentScoreboard, ScoreHolder, Scoreboard,
605        ScoreboardError,
606    };
607
608    #[test]
609    fn score_value_and_lock_state_are_independent() {
610        let scoreboard = Scoreboard::new();
611        let objective = scoreboard
612            .add_objective("kills")
613            .expect("objective should be added");
614        let holder = ScoreHolder::new("Steve");
615
616        scoreboard
617            .set_score(&holder, &objective, 7)
618            .expect("score should be writable");
619        scoreboard
620            .set_score_locked(&holder, &objective, false)
621            .expect("score lock should change");
622        scoreboard
623            .set_score(&holder, &objective, 9)
624            .expect("score should remain writable");
625
626        let entry = scoreboard
627            .score_entry(&holder, &objective)
628            .expect("score should exist");
629        assert_eq!(entry.value(), 9);
630        assert!(!entry.is_locked());
631    }
632
633    #[test]
634    fn read_only_objective_rejects_score_writes() {
635        let scoreboard = Scoreboard::new();
636        let objective = scoreboard
637            .add_objective_with_read_only("health", true)
638            .expect("objective should be added");
639
640        assert_eq!(
641            scoreboard.set_score(&ScoreHolder::new("Steve"), &objective, 20),
642            Err(ScoreboardError::ReadOnlyObjective("health".to_owned()))
643        );
644    }
645
646    #[test]
647    fn team_assignment_replaces_prior_membership() {
648        let scoreboard = Scoreboard::new();
649        let red = scoreboard
650            .add_team("red")
651            .expect("red team should be added");
652        let blue = scoreboard
653            .add_team("blue")
654            .expect("blue team should be added");
655        let holder = ScoreHolder::new("Steve");
656
657        scoreboard
658            .add_holder_to_team(&holder, &red)
659            .expect("holder should join red");
660        scoreboard
661            .add_holder_to_team(&holder, &blue)
662            .expect("holder should move to blue");
663
664        assert_eq!(
665            scoreboard.holder_team_name(&holder).as_deref(),
666            Some("blue")
667        );
668    }
669
670    #[tokio::test]
671    async fn persisted_scoreboard_round_trips_and_becomes_clean_after_save() {
672        let unique = SystemTime::now()
673            .duration_since(UNIX_EPOCH)
674            .expect("system time should be after Unix epoch")
675            .as_nanos();
676        let path = temp_dir().join(format!("steel-scoreboard-{unique}"));
677        let manager = SavedDataManager::new(Some(&path));
678        let scoreboard = Scoreboard::new();
679        let objective = scoreboard
680            .add_objective("kills")
681            .expect("objective should be added");
682        let holder = ScoreHolder::new("Steve");
683        scoreboard
684            .set_score(&holder, &objective, 5)
685            .expect("score should be writable");
686
687        let snapshot = scoreboard
688            .pending_save()
689            .expect("scoreboard should be dirty");
690        manager
691            .save(saved_data_names::SCOREBOARD, &snapshot.state)
692            .await
693            .expect("scoreboard should save");
694        scoreboard.mark_saved(snapshot.revision);
695        assert!(scoreboard.pending_save().is_none());
696
697        let persistent: PersistentScoreboard = manager
698            .load_or_default(saved_data_names::SCOREBOARD)
699            .await
700            .expect("scoreboard should load");
701        let restored = Scoreboard::from_persistent(persistent).expect("scoreboard should validate");
702        let restored_objective = restored
703            .objective("kills")
704            .expect("objective should persist");
705        assert_eq!(restored.score(&holder, &restored_objective), Some(5));
706
707        fs::remove_dir_all(path)
708            .await
709            .expect("temporary scoreboard directory should be removed");
710    }
711
712    #[test]
713    fn mutation_after_snapshot_remains_dirty_when_snapshot_is_marked_saved() {
714        let scoreboard = Scoreboard::new();
715        scoreboard
716            .add_objective("kills")
717            .expect("objective should be added");
718        let snapshot = scoreboard
719            .pending_save()
720            .expect("scoreboard should be dirty");
721
722        scoreboard
723            .add_objective("deaths")
724            .expect("second objective should be added");
725        scoreboard.mark_saved(snapshot.revision);
726
727        let pending = scoreboard
728            .pending_save()
729            .expect("newer mutation should remain dirty");
730        assert!(pending.revision > snapshot.revision);
731        assert!(pending.state.objectives.contains_key("deaths"));
732    }
733
734    #[test]
735    fn domains_keep_independent_scoreboards() {
736        let scoreboards = DomainScoreboards {
737            scoreboards: [
738                ("alpha".to_owned(), Scoreboard::new()),
739                ("beta".to_owned(), Scoreboard::new()),
740            ]
741            .into_iter()
742            .collect(),
743            save_lock: AsyncMutex::new(()),
744        };
745        scoreboards
746            .get("alpha")
747            .expect("alpha scoreboard should exist")
748            .add_objective("kills")
749            .expect("alpha objective should be added");
750
751        assert!(
752            scoreboards
753                .get("beta")
754                .expect("beta scoreboard should exist")
755                .objective("kills")
756                .is_none()
757        );
758    }
759}