Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
target/
tmp/
node_modules
.idea
.env
.envrc
.env.local
Expand Down
21 changes: 21 additions & 0 deletions late-core/migrations/090_create_traffic.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Traffic keeps one best score per (user, track); the Traffic high score is the
-- sum of a user's per-track bests. Per-track scores are normalized 0..1000.
CREATE TABLE traffic_track_scores (
id UUID PRIMARY KEY DEFAULT uuidv7(),
created TIMESTAMPTZ NOT NULL DEFAULT current_timestamp,
updated TIMESTAMPTZ NOT NULL DEFAULT current_timestamp,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_key TEXT NOT NULL,
score INT NOT NULL DEFAULT 0,
UNIQUE (user_id, track_key)
);

-- Aggregate row mirroring the other high-score games so leaderboard queries
-- stay uniform. score is always the sum of traffic_track_scores for the user.
CREATE TABLE traffic_high_scores (
id UUID PRIMARY KEY DEFAULT uuidv7(),
created TIMESTAMPTZ NOT NULL DEFAULT current_timestamp,
updated TIMESTAMPTZ NOT NULL DEFAULT current_timestamp,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
score INT NOT NULL DEFAULT 0
);
39 changes: 39 additions & 0 deletions late-core/src/models/leaderboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub struct LeaderboardData {
pub monthly_tetris_high_scores: Vec<HighScoreEntry>,
pub monthly_2048_high_scores: Vec<HighScoreEntry>,
pub monthly_snake_high_scores: Vec<HighScoreEntry>,
pub monthly_traffic_high_scores: Vec<HighScoreEntry>,
}

pub async fn fetch_leaderboard_data(client: &Client) -> Result<LeaderboardData> {
Expand All @@ -88,6 +89,7 @@ pub async fn fetch_leaderboard_data(client: &Client) -> Result<LeaderboardData>
monthly_tetris_high_scores,
monthly_2048_high_scores,
monthly_snake_high_scores,
monthly_traffic_high_scores,
) = tokio::try_join!(
fetch_today_champions(client, 10),
fetch_today_daily_statuses(client),
Expand All @@ -99,6 +101,7 @@ pub async fn fetch_leaderboard_data(client: &Client) -> Result<LeaderboardData>
fetch_monthly_tetris_high_scores(client, 500),
fetch_monthly_2048_high_scores(client, 500),
fetch_monthly_snake_high_scores(client, 500),
fetch_monthly_traffic_high_scores(client, 500),
)?;

Ok(LeaderboardData {
Expand All @@ -112,6 +115,7 @@ pub async fn fetch_leaderboard_data(client: &Client) -> Result<LeaderboardData>
monthly_tetris_high_scores,
monthly_2048_high_scores,
monthly_snake_high_scores,
monthly_traffic_high_scores,
})
}

Expand Down Expand Up @@ -312,6 +316,34 @@ async fn fetch_high_scores(client: &Client, limit: i64) -> Result<Vec<HighScoreE
});
}

// Traffic top scores (aggregate of per-track bests)
let rows = client
.query(
"WITH ranked AS (
SELECT u.username,
h.user_id,
h.score,
RANK() OVER (ORDER BY h.score DESC) AS rank
FROM traffic_high_scores h
JOIN users u ON u.id = h.user_id
)
SELECT username, user_id, score, rank
FROM ranked
ORDER BY rank ASC, username ASC
LIMIT $1",
&[&limit],
)
.await?;
for row in rows {
entries.push(HighScoreEntry {
game: "Traffic",
username: row.get("username"),
user_id: row.get("user_id"),
rank: row.get("rank"),
score: row.get("score"),
});
}

Ok(entries)
}

Expand Down Expand Up @@ -343,6 +375,13 @@ async fn fetch_monthly_snake_high_scores(
fetch_monthly_score_board(client, "Snake", "snake", "snake_high_scores", limit).await
}

async fn fetch_monthly_traffic_high_scores(
client: &Client,
limit: i64,
) -> Result<Vec<HighScoreEntry>> {
fetch_monthly_score_board(client, "Traffic", "traffic", "traffic_high_scores", limit).await
}

async fn fetch_monthly_score_board(
client: &Client,
display_game: &'static str,
Expand Down
1 change: 1 addition & 0 deletions late-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub mod snake;
pub mod solitaire;
pub mod sudoku;
pub mod tetris;
pub mod traffic;
pub mod twenty_forty_eight;
pub mod ultimate_cooldown;
pub mod user;
Expand Down
108 changes: 108 additions & 0 deletions late-core/src/models/traffic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use anyhow::Result;
use tokio_postgres::Client;
use uuid::Uuid;

/// One user's best normalized score (0..1000) for a single track.
pub struct TrackScore {
pub track_key: String,
pub score: i32,
}

/// A user's aggregate Traffic high score: the sum of their per-track bests.
pub struct HighScore {
pub user_id: Uuid,
pub score: i32,
}

impl TrackScore {
/// Every per-track best the user has recorded.
pub async fn list_for_user(client: &Client, user_id: Uuid) -> Result<Vec<TrackScore>> {
let rows = client
.query(
"SELECT track_key, score FROM traffic_track_scores WHERE user_id = $1",
&[&user_id],
)
.await?;
Ok(rows
.into_iter()
.map(|row| TrackScore {
track_key: row.get("track_key"),
score: row.get("score"),
})
.collect())
}
}

impl HighScore {
pub async fn find_by_user_id(client: &Client, user_id: Uuid) -> Result<Option<Self>> {
let row = client
.query_opt(
"SELECT user_id, score FROM traffic_high_scores WHERE user_id = $1",
&[&user_id],
)
.await?;
Ok(row.map(|row| Self {
user_id: row.get("user_id"),
score: row.get("score"),
}))
}

/// Record a track finish: keep the best per-track score, then recompute the
/// aggregate Traffic high score as the sum of the user's per-track bests.
/// Returns the new aggregate total.
///
/// All three statements run in one transaction so the `traffic_high_scores`
/// aggregate can never persist a total that disagrees with the source-of-
/// truth `traffic_track_scores` sum, even under concurrent finishes for the
/// same user.
pub async fn update_track_score_if_higher(
client: &mut Client,
user_id: Uuid,
track_key: &str,
new_score: i32,
) -> Result<i32> {
let tx = client.transaction().await?;

tx.execute(
"INSERT INTO traffic_track_scores (user_id, track_key, score)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, track_key) DO UPDATE
SET score = GREATEST(traffic_track_scores.score, $3),
updated = current_timestamp",
&[&user_id, &track_key, &new_score],
)
.await?;

let total: i32 = tx
.query_one(
"SELECT COALESCE(SUM(score), 0)::int AS total
FROM traffic_track_scores WHERE user_id = $1",
&[&user_id],
)
.await?
.get("total");

tx.execute(
"INSERT INTO traffic_high_scores (user_id, score)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE
SET score = $2, updated = current_timestamp",
&[&user_id, &total],
)
.await?;

tx.commit().await?;
Ok(total)
}

pub async fn record_score_event(client: &Client, user_id: Uuid, total: i32) -> Result<()> {
client
.execute(
"INSERT INTO game_score_events (user_id, game, score)
VALUES ($1, 'traffic', $2)",
&[&user_id, &total],
)
.await?;
Ok(())
}
}
4 changes: 4 additions & 0 deletions late-ssh/src/app/activity/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub enum ActivityGame {
TwentyFortyEight,
Tron,
Snake,
Traffic,
}

impl ActivityGame {
Expand All @@ -97,6 +98,7 @@ impl ActivityGame {
Self::TwentyFortyEight => "2048",
Self::Tron => "tron",
Self::Snake => "snake",
Self::Traffic => "traffic",
}
}

Expand All @@ -120,6 +122,7 @@ impl ActivityGame {
Self::TwentyFortyEight => "2048",
Self::Tron => "Tron",
Self::Snake => "Snake",
Self::Traffic => "Traffic",
}
}
}
Expand Down Expand Up @@ -189,6 +192,7 @@ impl ActivityEvent {
ActivityGame::TwentyFortyEight => "won 2048",
ActivityGame::Tron => "won Tron round",
ActivityGame::Snake => "won Snake",
ActivityGame::Traffic => "finished a Traffic track",
};
let action = match detail.as_deref() {
Some(detail) if !detail.is_empty() => format!("{base_action} ({detail})"),
Expand Down
2 changes: 2 additions & 0 deletions late-ssh/src/app/arcade/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Keep `mod.rs` declaration-only. Do not add `pub use` re-export layers.
- `input.rs` routes The Arcade lobby and selected active game input.
- `ui.rs` renders the lobby and exposes Arcade-only bottom-bar/status helpers.
- `twenty_forty_eight/`, `tetris/`, and `snake/` are high-score games.
- `traffic/` is a multi-track high-score game. Each track finish is graded to a normalized `0..=1000` score (`Track::grade_time`, from the track's theoretical fastest/slowest completion time, so every track yields a comparable range regardless of its distance/speed definition); crashing before the finish scores nothing. The user's Traffic high score is the **sum** of their per-track bests. Persistence keeps one best per `(user, track_key)` in `traffic_track_scores` plus a mirrored aggregate row in `traffic_high_scores` (`= SUM(track scores)`) so leaderboard queries stay uniform with the other high-score games. `track_key` is the `Track::name`.
- `rubiks_cube/` is a daily deterministic puzzle game with a real cube state, face turns, a three-face angled render, and a compact net. It records one daily win per user/date, publishes Activity for the once-per-day base chip payout and Hub quest progress, and counts toward Arcade Wins.
- `nes_cabinet/` is a Potatis-backed local emulator cabinet for bundled legal/homebrew ROMs: Squirrel Domino, Thwaite, DABG, Falling, Brick Breaker, Escape from Pong, RHDE, Concentration Room, Zap Ruder, and 2048.
- `sudoku/`, `nonogram/`, `minesweeper/`, `solitaire/`, `le_word/`, and `rubiks_cube/` are daily puzzle games. Le Word has a single global daily word rather than personal runs. Rubik's Cube has one shared daily scramble and no personal mode.
Expand Down Expand Up @@ -61,6 +62,7 @@ Per-game directories generally follow:
| Category | Games | Persistence | Leaderboard |
| --- | --- | --- | --- |
| High-score | 2048, Lateris, Snake | One current run plus best score plus final score events | Monthly and all-time high scores in Hub |
| High-score (multi-track) | Traffic | One best per track (`traffic_track_scores`) plus aggregate sum (`traffic_high_scores`) plus final score events | Monthly and all-time Traffic high scores in Hub |
| Daily puzzles | Sudoku, Nonograms, Minesweeper, Solitaire, Le Word, Rubik's Cube | One daily and one personal slot per user/difficulty or pack, except Le Word's global daily answer and Rubik's shared daily scramble | Daily completion status / Arcade Wins in Hub, plus Hub Quests via Activity |
| Emulator cabinet | NES Cabinet | Runtime only, bundled ROMs only | None |
| Economy support | Chips | `user_chips` plus `chip_ledger` | Monthly chip earners in Hub |
Expand Down
22 changes: 20 additions & 2 deletions late-ssh/src/app/arcade/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ use crate::app::state::{
GAME_SELECTION_NES_ESCAPE_FROM_PONG, GAME_SELECTION_NES_FALLING, GAME_SELECTION_NES_RHDE,
GAME_SELECTION_NES_SQUIRREL_DOMINO, GAME_SELECTION_NES_THWAITE, GAME_SELECTION_NES_ZAP_RUDER,
GAME_SELECTION_NONOGRAMS, GAME_SELECTION_RUBIKS_CUBE, GAME_SELECTION_SNAKE,
GAME_SELECTION_SOLITAIRE, GAME_SELECTION_SUDOKU, GAME_SELECTION_TETRIS,
GAME_SELECTION_SOLITAIRE, GAME_SELECTION_SUDOKU, GAME_SELECTION_TETRIS, GAME_SELECTION_TRAFFIC,
};

const LOBBY_GAME_ORDER: [usize; 19] = [
const LOBBY_GAME_ORDER: [usize; 20] = [
GAME_SELECTION_2048,
GAME_SELECTION_TETRIS,
GAME_SELECTION_SNAKE,
GAME_SELECTION_TRAFFIC,
GAME_SELECTION_LE_WORD,
GAME_SELECTION_RUBIKS_CUBE,
GAME_SELECTION_SUDOKU,
Expand Down Expand Up @@ -107,6 +108,12 @@ pub fn handle_key(app: &mut App, byte: u8) -> bool {
return true;
}
return super::snake::input::handle_key(&mut app.snake_state, byte);
} else if app.game_selection == GAME_SELECTION_TRAFFIC {
if byte == 0x1B || byte == b'q' || byte == b'Q' {
app.is_playing_game = false;
return true;
}
return super::traffic::input::handle_key(&mut app.traffic_state, byte);
} else if app.game_selection == GAME_SELECTION_RUBIKS_CUBE {
if byte == 0x1B || byte == b'q' || byte == b'Q' {
app.is_playing_game = false;
Expand Down Expand Up @@ -177,6 +184,7 @@ pub fn handle_key(app: &mut App, byte: u8) -> bool {
if app.game_selection == GAME_SELECTION_2048
|| app.game_selection == GAME_SELECTION_TETRIS
|| app.game_selection == GAME_SELECTION_SNAKE
|| app.game_selection == GAME_SELECTION_TRAFFIC
|| app.game_selection == GAME_SELECTION_RUBIKS_CUBE
|| app.game_selection == GAME_SELECTION_LE_WORD
|| is_nes_selection(app.game_selection)
Expand Down Expand Up @@ -219,6 +227,8 @@ pub fn handle_arrow(app: &mut App, key: u8) -> bool {
return super::tetris::input::handle_arrow(&mut app.tetris_state, key);
} else if app.game_selection == GAME_SELECTION_SNAKE {
return super::snake::input::handle_arrow(&mut app.snake_state, key);
} else if app.game_selection == GAME_SELECTION_TRAFFIC {
return super::traffic::input::handle_arrow(&mut app.traffic_state, key);
} else if app.game_selection == GAME_SELECTION_RUBIKS_CUBE {
app.rubiks_cube_state.ensure_current_daily();
return super::rubiks_cube::input::handle_arrow(&mut app.rubiks_cube_state, key);
Expand Down Expand Up @@ -333,6 +343,10 @@ mod tests {
);
assert_eq!(
next_lobby_selection(GAME_SELECTION_SNAKE),
GAME_SELECTION_TRAFFIC
);
assert_eq!(
next_lobby_selection(GAME_SELECTION_TRAFFIC),
GAME_SELECTION_LE_WORD
);
assert_eq!(
Expand All @@ -359,6 +373,10 @@ mod tests {
next_lobby_selection(GAME_SELECTION_NES_FALLING),
GAME_SELECTION_NES_BRICK_BREAKER
);
assert_eq!(
next_lobby_selection(GAME_SELECTION_NES_FALLING),
GAME_SELECTION_NES_BRICK_BREAKER
);
assert_eq!(
next_lobby_selection(GAME_SELECTION_NES_BRICK_BREAKER),
GAME_SELECTION_NES_ESCAPE_FROM_PONG
Expand Down
1 change: 1 addition & 0 deletions late-ssh/src/app/arcade/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ pub mod snake;
pub mod solitaire;
pub mod sudoku;
pub mod tetris;
pub mod traffic;
pub mod twenty_forty_eight;
pub mod ui;
Loading