1use 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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct ScoreHolder {
24 name: String,
25}
26
27impl ScoreHolder {
28 #[must_use]
30 pub fn new(name: impl Into<String>) -> Self {
31 Self { name: name.into() }
32 }
33
34 #[must_use]
36 pub fn name(&self) -> &str {
37 &self.name
38 }
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct ScoreboardObjective {
44 name: String,
45 read_only: bool,
46}
47
48impl ScoreboardObjective {
49 #[must_use]
51 pub fn name(&self) -> &str {
52 &self.name
53 }
54
55 #[must_use]
57 pub const fn is_read_only(&self) -> bool {
58 self.read_only
59 }
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct ScoreboardTeam {
65 name: String,
66}
67
68impl ScoreboardTeam {
69 #[must_use]
71 pub fn name(&self) -> &str {
72 &self.name
73 }
74}
75
76#[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 #[must_use]
95 pub const fn value(self) -> i32 {
96 self.value
97 }
98
99 #[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#[derive(Clone, Debug, Error, PartialEq, Eq)]
133pub enum ScoreboardError {
134 #[error("objective name cannot be empty")]
136 EmptyObjectiveName,
137 #[error("team name cannot be empty")]
139 EmptyTeamName,
140 #[error("score holder name cannot be empty")]
142 EmptyScoreHolderName,
143 #[error("objective '{0}' already exists")]
145 DuplicateObjective(String),
146 #[error("team '{0}' already exists")]
148 DuplicateTeam(String),
149 #[error("objective '{0}' does not exist")]
151 MissingObjective(String),
152 #[error("team '{0}' does not exist")]
154 MissingTeam(String),
155 #[error("objective '{0}' is read-only")]
157 ReadOnlyObjective(String),
158}
159
160struct ScoreboardSaveSnapshot {
161 revision: u64,
162 state: PersistentScoreboard,
163}
164
165pub struct Scoreboard {
167 state: SyncRwLock<PersistentScoreboard>,
168 revision: AtomicU64,
169 saved_revision: AtomicU64,
170}
171
172impl Scoreboard {
173 #[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 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 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 #[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 #[must_use]
242 pub fn objective_names(&self) -> Vec<String> {
243 self.state.read().objectives.keys().cloned().collect()
244 }
245
246 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 #[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 #[must_use]
276 pub fn team_names(&self) -> Vec<String> {
277 self.state.read().teams.iter().cloned().collect()
278 }
279
280 #[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 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 #[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 #[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 #[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 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 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 #[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
514pub struct DomainScoreboards {
516 scoreboards: BTreeMap<String, Scoreboard>,
517 save_lock: AsyncMutex<()>,
518}
519
520impl DomainScoreboards {
521 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 #[must_use]
549 pub fn get(&self, domain: &str) -> Option<&Scoreboard> {
550 self.scoreboards.get(domain)
551 }
552
553 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}