Offer a Continue link for ongoing games in the lobby
A player who accidentally closes the game page can now get back to it from the lobby. Backend: - GameStore.list_ongoing() returns live unfinished sessions (newest first), skipping expired keys and finished matches; - GET /api/me/games lists the games the caller is seated in, with the game name/description, players, open seats and your seat (no join code); - store, platform route and app integration tests. Frontend: - MyGame/MyGamesPage model plus api::my_games(); - the lobby shows a "Your games" card with phase, seats, players and a Continue button linking to /game/<id>; hidden when empty or logged out.
This commit is contained in:
@@ -128,6 +128,20 @@ pub async fn probe_game(game_id: &str) -> GameEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the live games the caller is seated in, for the lobby's
|
||||
/// "Continue" list.
|
||||
pub async fn my_games() -> Result<Vec<MyGame>, String> {
|
||||
let resp = Request::get("/api/me/games")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(page.results)
|
||||
}
|
||||
|
||||
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||
let url = match cursor {
|
||||
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
||||
|
||||
@@ -498,6 +498,66 @@ pub struct PreviewPlayer {
|
||||
pub team: Option<String>,
|
||||
}
|
||||
|
||||
/// One ongoing game the caller is seated in (GET /api/me/games): the
|
||||
/// lobby payload plus the game's display name, for the "Continue" list.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MyGame {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub game_type: String,
|
||||
#[serde(default)]
|
||||
pub game_name: String,
|
||||
#[serde(default)]
|
||||
pub game_description: String,
|
||||
#[serde(default)]
|
||||
pub phase: String,
|
||||
#[serde(default)]
|
||||
pub players: Vec<PreviewPlayer>,
|
||||
#[serde(default)]
|
||||
pub seats_open: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub your_seat: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
impl MyGame {
|
||||
/// Human-readable phase for the lobby list.
|
||||
pub fn phase_label(&self) -> &'static str {
|
||||
match self.phase.as_str() {
|
||||
"lobby" => "Waiting for players",
|
||||
"hand_end" => "Between hands",
|
||||
_ => "In progress",
|
||||
}
|
||||
}
|
||||
|
||||
/// Seated/total note, e.g. `"2/4 seated"`.
|
||||
pub fn seats_text(&self) -> String {
|
||||
match self.seats_open {
|
||||
Some(open) => {
|
||||
format!("{}/{} seated", self.players.len(), self.players.len() + open)
|
||||
}
|
||||
None => format!("{} seated", self.players.len()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The seated players' names, for the list subtitle.
|
||||
pub fn players_text(&self) -> String {
|
||||
self.players
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MyGamesPage {
|
||||
#[serde(default)]
|
||||
pub results: Vec<MyGame>,
|
||||
}
|
||||
|
||||
/// One option to show in the join preview, rendered from the game's
|
||||
/// schema and the engine-echoed value.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -1218,6 +1278,41 @@ mod tests {
|
||||
assert_eq!("C", g.player_name_for_team("C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn my_game_phase_and_seats_text() {
|
||||
let g: MyGame = serde_json::from_value(json!({
|
||||
"id": "g1",
|
||||
"game_type": "briscola_a_quattro",
|
||||
"game_name": "Briscola a quattro",
|
||||
"phase": "lobby",
|
||||
"players": [
|
||||
{"name": "Alice", "seat": 0, "team": "A"},
|
||||
{"name": "Bob", "seat": 1, "team": "B"},
|
||||
],
|
||||
"seats_open": 2,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("Waiting for players", g.phase_label());
|
||||
assert_eq!("2/4 seated", g.seats_text());
|
||||
assert_eq!("Alice, Bob", g.players_text());
|
||||
|
||||
let playing: MyGame = serde_json::from_value(json!({
|
||||
"id": "g2",
|
||||
"game_type": "briscola",
|
||||
"phase": "playing",
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("In progress", playing.phase_label());
|
||||
assert_eq!("0 seated", playing.seats_text());
|
||||
|
||||
let between: MyGame = serde_json::from_value(json!({
|
||||
"id": "g3",
|
||||
"phase": "hand_end",
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("Between hands", between.phase_label());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_game_types_cover_the_registered_games() {
|
||||
let types = fallback_game_types();
|
||||
|
||||
+69
-1
@@ -16,7 +16,7 @@ use crate::components::card::card_back;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{
|
||||
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
||||
OptionField, OptionKind, PlayerRating, User,
|
||||
MyGame, OptionField, OptionKind, PlayerRating, User,
|
||||
};
|
||||
|
||||
/// Human-readable label for a provider name ("google" -> "Google").
|
||||
@@ -292,6 +292,8 @@ pub fn LobbyPage() -> View {
|
||||
let auth_providers = create_signal(Vec::<AuthProvider>::new());
|
||||
// The player's Elo ratings, one row per game type played.
|
||||
let ratings = create_signal(Vec::<PlayerRating>::new());
|
||||
// Live games the player is seated in, for the "Continue" list.
|
||||
let my_games = create_signal(Vec::<MyGame>::new());
|
||||
let error = create_signal(Option::<String>::None);
|
||||
let code = create_signal(String::new());
|
||||
let game_types = create_signal(fallback_game_types());
|
||||
@@ -386,6 +388,9 @@ pub fn LobbyPage() -> View {
|
||||
if let Ok(p) = api::my_ratings().await {
|
||||
ratings.set(p.results);
|
||||
}
|
||||
if let Ok(games) = api::my_games().await {
|
||||
my_games.set(games);
|
||||
}
|
||||
});
|
||||
}
|
||||
user.set(Some(me));
|
||||
@@ -549,6 +554,69 @@ pub fn LobbyPage() -> View {
|
||||
"Play scopone scientifico and briscola with friends."
|
||||
}
|
||||
}
|
||||
// Ongoing games: a "Continue" link back into a
|
||||
// match whose page was accidentally closed.
|
||||
(move || {
|
||||
let games = my_games.get_clone();
|
||||
if games.is_empty() {
|
||||
return view! {};
|
||||
}
|
||||
let types = game_types.get_clone();
|
||||
let rows = games
|
||||
.into_iter()
|
||||
.map(|g| {
|
||||
let name = if g.game_name.is_empty() {
|
||||
types
|
||||
.iter()
|
||||
.find(|t| t.id == g.game_type)
|
||||
.map(|t| t.name.clone())
|
||||
.unwrap_or_else(|| g.game_type.clone())
|
||||
} else {
|
||||
g.game_name.clone()
|
||||
};
|
||||
let phase = g.phase_label();
|
||||
let seats = g.seats_text();
|
||||
let players = g.players_text();
|
||||
let players_note = if players.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" · {players}")
|
||||
};
|
||||
let href = format!("/game/{}", g.id);
|
||||
view! {
|
||||
li(
|
||||
class="list-group-item d-flex flex-wrap \
|
||||
justify-content-between \
|
||||
align-items-center gap-2"
|
||||
) {
|
||||
div {
|
||||
div(class="fw-semibold") { (name) }
|
||||
div(class="text-secondary small") {
|
||||
(phase) " · " (seats)
|
||||
(players_note)
|
||||
}
|
||||
}
|
||||
a(
|
||||
class="btn btn-sm btn-warning",
|
||||
href=href,
|
||||
) {
|
||||
"Continue"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class="card mb-4") {
|
||||
div(class="card-header") { "Your games" }
|
||||
div(class="card-body p-0") {
|
||||
ul(class="list-group list-group-flush") {
|
||||
(rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
div(class="row g-4") {
|
||||
div(class="col-lg-7") {
|
||||
div(class="card h-100") {
|
||||
|
||||
Reference in New Issue
Block a user