fix(call-to-play): expire elapsed calls clearly

Deadline completion previously shared the green Ready presentation with a full
roster and remained visible forever. An abandoned call therefore looked ready
to launch and required its creator to return and cancel it.

Give elapsed calls a distinct Time's up state and a five-minute grace period in
which the creator can start or extend them. After that, both the reducer and
peer store remove the call as a unit. Filled calls remain ready until their
deadline, and active calls continue to retain complete history for late joiners.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, 147 peer tests
- `just frontend-test` -- passed, 22 tests
- `just build` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-07-21 22:56:02 +02:00
parent 640d81d919
commit cc7dacf6c3
9 changed files with 171 additions and 40 deletions
+74 -11
View File
@@ -1,6 +1,9 @@
//! Replicated event history for Call to Play coordination.
use std::collections::{HashMap, HashSet};
use std::{
collections::{HashMap, HashSet},
time::{SystemTime, UNIX_EPOCH},
};
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
use tokio::sync::mpsc::UnboundedSender;
@@ -12,6 +15,7 @@ const MAX_ID_CHARS: usize = 128;
const MAX_GAME_ID_CHARS: usize = 256;
const MAX_USERNAME_CHARS: usize = 24;
const MAX_MESSAGE_CHARS: usize = 500;
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
#[derive(Debug, Default)]
pub(crate) struct CallToPlayStore {
@@ -33,7 +37,7 @@ impl CallToPlayStore {
let event_id = event.id.clone();
self.event_ids.insert(event_id.clone());
self.events.push(event);
self.evict_terminal_calls();
self.compact_inactive_calls(now_ms());
if self.events.len() > MAX_EVENTS {
self.events.retain(|event| event.id != event_id);
self.event_ids.remove(&event_id);
@@ -54,13 +58,13 @@ impl CallToPlayStore {
accepted
}
fn evict_terminal_calls(&mut self) {
let mut creators = HashMap::<String, (i64, String, String)>::new();
fn compact_inactive_calls(&mut self, now: i64) {
let mut creators = HashMap::<String, (i64, String, String, i64)>::new();
for event in &self.events {
if !matches!(event.action, CallToPlayAction::Create { .. }) {
let CallToPlayAction::Create { deadline, .. } = event.action else {
continue;
}
let candidate = (event.at, event.id.clone(), event.actor_id.clone());
};
let candidate = (event.at, event.id.clone(), event.actor_id.clone(), deadline);
creators
.entry(event.call_id.clone())
.and_modify(|current| {
@@ -73,7 +77,7 @@ impl CallToPlayStore {
let mut terminal_events = HashMap::<String, (i64, String)>::new();
for event in &self.events {
let Some((created_at, create_id, creator_id)) = creators.get(&event.call_id) else {
let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else {
continue;
};
if !matches!(
@@ -94,7 +98,42 @@ impl CallToPlayStore {
})
.or_insert(candidate);
}
let mut extensions = HashMap::<String, (i64, String, i64)>::new();
for event in &self.events {
let CallToPlayAction::AddTime { deadline } = event.action else {
continue;
};
let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else {
continue;
};
if event.actor_id != *creator_id || (event.at, &event.id) <= (*created_at, create_id) {
continue;
}
let candidate = (event.at, event.id.clone(), deadline);
extensions
.entry(event.call_id.clone())
.and_modify(|current| {
if (candidate.0, &candidate.1) > (current.0, &current.1) {
current.clone_from(&candidate);
}
})
.or_insert(candidate);
}
let expired_calls = creators
.iter()
.filter_map(|(call_id, (_, _, _, original_deadline))| {
let deadline = extensions
.get(call_id)
.map_or(*original_deadline, |(_, _, deadline)| *deadline);
(now - deadline > EXPIRED_RETENTION_MS).then(|| call_id.clone())
})
.collect::<HashSet<_>>();
self.events.retain(|event| {
if expired_calls.contains(&event.call_id) {
return false;
}
terminal_events
.get(&event.call_id)
.is_none_or(|(_, terminal_id)| event.id == *terminal_id)
@@ -102,6 +141,16 @@ impl CallToPlayStore {
}
}
fn now_ms() -> i64 {
i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
)
.unwrap_or(i64::MAX)
}
pub(crate) async fn publish(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
@@ -208,18 +257,20 @@ mod tests {
use super::{CallToPlayStore, MAX_EVENTS};
const TEST_NOW: i64 = 8_000_000_000_000;
fn create_event(id: &str) -> CallToPlayEvent {
CallToPlayEvent {
id: id.to_string(),
call_id: "call-1".to_string(),
actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(),
at: 1_000,
at: TEST_NOW,
action: CallToPlayAction::Create {
game_id: "game-1".to_string(),
max_players: 4,
scheduled_for: None,
deadline: 61_000,
deadline: TEST_NOW + 60_000,
},
}
}
@@ -230,7 +281,7 @@ mod tests {
call_id: call_id.to_string(),
actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(),
at: 2_000,
at: TEST_NOW + 1_000,
action,
}
}
@@ -360,4 +411,16 @@ mod tests {
);
assert_eq!(store.snapshot().len(), MAX_EVENTS);
}
#[test]
fn expired_call_history_is_evicted_as_a_unit() {
let mut store = CallToPlayStore::default();
store
.insert(create_event("create"))
.expect("active call should fit");
store.compact_inactive_calls(TEST_NOW + 5 * 60_000 + 60_001);
assert!(store.snapshot().is_empty());
}
}
@@ -315,12 +315,12 @@ mod tests {
call_id: "call-1".to_string(),
actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(),
at: 1_000,
at: 8_000_000_000_000,
action: CallToPlayAction::Create {
game_id: "game".to_string(),
max_players: 4,
scheduled_for: None,
deadline: 61_000,
deadline: 8_000_000_060_000,
},
}
}