Replace the Python/kaya server in server/ with a Rust Cargo workspace of three crates mirroring the old layout: tavolo-platform (lobby API, live-game store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions), tavolo-scopone (pure scopone scientifico rules plus the platform adapter) and tavolo-app (composition root and the `tavolo` binary with serve/migrate/backfill-elo). The HTTP and WebSocket contracts, error messages, Redis key layout and Postgres schema are unchanged, so the existing web/ frontend and in-flight games keep working. Notable differences: sessions are JSON under tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations replace aerich, and OpenTelemetry exports Rust process metrics instead of CPython gc metrics (no Postgres/Redis client instrumentation). Docker Compose and the Kubernetes manifest now run the single Rust image (embedded migrations, /static and /assets served by the app); the release CI keeps building server/Dockerfile. The full test suite (scopone rules, platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is ported and runs with cargo test --workspace; Redis/Postgres integration tests are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
This commit is contained in:
1 parent
adc3d340b1
commit
7b3b38ccbc
136 files changed
+16821
-11777
No files matched your search
@@ -0,0 +1,483 @@
|
||||
//! `ScoponeEngine` adapter tests: the platform contract over the pure rules.
|
||||
//!
|
||||
//! Ported from `tavolo-scopone/tests/test_plugin.py`.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use tavolo_platform::engine::GameSession;
|
||||
use tavolo_platform::errors::Error;
|
||||
use tavolo_platform::{GameEngine, Seat};
|
||||
use tavolo_scopone::engine as rules;
|
||||
use tavolo_scopone::state::{Card, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING};
|
||||
use tavolo_scopone::ScoponeEngine;
|
||||
|
||||
fn opts(value: Value) -> Map<String, Value> {
|
||||
value.as_object().unwrap().clone()
|
||||
}
|
||||
|
||||
fn empty() -> Map<String, Value> {
|
||||
Map::new()
|
||||
}
|
||||
|
||||
fn capitalize(name: &str) -> String {
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_session(engine: &ScoponeEngine) -> GameSession {
|
||||
let mut session = GameSession::new("s1", engine.id(), "CODE01");
|
||||
session.creator_sub = "alice".to_string();
|
||||
session.players = vec![Seat {
|
||||
user_sub: "alice".to_string(),
|
||||
display_name: "Alice".to_string(),
|
||||
team: None,
|
||||
}];
|
||||
session
|
||||
}
|
||||
|
||||
fn make_session(engine: &ScoponeEngine, options: Map<String, Value>) -> Result<GameSession, Error> {
|
||||
let mut session = base_session(engine);
|
||||
engine.create(&mut session, &options)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn started(engine: &ScoponeEngine, options: Map<String, Value>) -> GameSession {
|
||||
let mut session = make_session(engine, options).unwrap();
|
||||
for name in ["bob", "carol", "dave"] {
|
||||
engine.join(&mut session, name, &capitalize(name)).unwrap();
|
||||
}
|
||||
session
|
||||
}
|
||||
|
||||
fn state(session: &GameSession) -> &ScoponeState {
|
||||
session
|
||||
.state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.downcast_ref::<ScoponeState>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn state_mut(session: &mut GameSession) -> &mut ScoponeState {
|
||||
session
|
||||
.state
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<ScoponeState>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn err_message(error: &Error) -> String {
|
||||
error
|
||||
.as_game()
|
||||
.map(|game| game.message.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extract the error from a failed `make_session` without requiring
|
||||
/// `GameSession: Debug` (which `unwrap_err` would).
|
||||
fn session_error(result: Result<GameSession, Error>) -> Error {
|
||||
match result {
|
||||
Ok(_) => panic!("expected an error"),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Play legal moves (and acknowledge hand ends) until the match finishes.
|
||||
fn drive_to_finish(engine: &ScoponeEngine, session: &mut GameSession) -> usize {
|
||||
let mut moves = 0;
|
||||
while !engine.is_finished(session) && moves < 200_000 {
|
||||
let (action, sub, payload): (&str, String, Map<String, Value>) = {
|
||||
let st = state(session);
|
||||
if st.phase == PHASE_HAND_END {
|
||||
let player = st
|
||||
.players
|
||||
.iter()
|
||||
.find(|player| !st.acked.contains(&player.seat))
|
||||
.unwrap();
|
||||
("ack", player.sub.clone(), Map::new())
|
||||
} else {
|
||||
let player = st
|
||||
.players
|
||||
.iter()
|
||||
.find(|player| player.seat == st.turn)
|
||||
.unwrap();
|
||||
let played = player.hand[0].clone();
|
||||
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
|
||||
let mut payload = Map::new();
|
||||
payload.insert("card".to_string(), json!(played.code()));
|
||||
if let Some(option) = options.first() {
|
||||
payload.insert(
|
||||
"capture".to_string(),
|
||||
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
|
||||
);
|
||||
}
|
||||
("play", player.sub.clone(), payload)
|
||||
}
|
||||
};
|
||||
engine
|
||||
.handle_action(session, &sub, action, &payload)
|
||||
.unwrap();
|
||||
moves += 1;
|
||||
}
|
||||
moves
|
||||
}
|
||||
|
||||
// -- create ----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn create_seats_creator_on_team_a() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let session = make_session(&engine, empty()).unwrap();
|
||||
assert_eq!(Some("A".to_string()), session.players[0].team);
|
||||
let st = state(&session);
|
||||
assert_eq!(11, st.target_score);
|
||||
assert!(st.napola);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_options() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let session =
|
||||
make_session(&engine, opts(json!({"target_score": 16, "napola": false}))).unwrap();
|
||||
let st = state(&session);
|
||||
assert_eq!(16, st.target_score);
|
||||
assert!(!st.napola);
|
||||
assert!(!st.asso_pigliatutto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_asso_pigliatutto_option() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let session = make_session(&engine, opts(json!({"asso_pigliatutto": true}))).unwrap();
|
||||
assert!(state(&session).asso_pigliatutto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_rejects_bad_options() {
|
||||
let engine = ScoponeEngine::default();
|
||||
assert_eq!(
|
||||
"target_score must be between 1 and 100",
|
||||
err_message(&session_error(make_session(
|
||||
&engine,
|
||||
opts(json!({"target_score": 0}))
|
||||
)))
|
||||
);
|
||||
assert_eq!(
|
||||
"target_score must be an integer",
|
||||
err_message(&session_error(make_session(
|
||||
&engine,
|
||||
opts(json!({"target_score": "eleven"}))
|
||||
)))
|
||||
);
|
||||
assert_eq!(
|
||||
"napola must be a boolean",
|
||||
err_message(&session_error(make_session(
|
||||
&engine,
|
||||
opts(json!({"napola": "yes"}))
|
||||
)))
|
||||
);
|
||||
assert_eq!(
|
||||
"asso_pigliatutto must be a boolean",
|
||||
err_message(&session_error(make_session(
|
||||
&engine,
|
||||
opts(json!({"asso_pigliatutto": "yes"}))
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeouts_come_from_the_engine() {
|
||||
let engine = ScoponeEngine::new(7, 9);
|
||||
let session = make_session(&engine, empty()).unwrap();
|
||||
let st = state(&session);
|
||||
assert_eq!(7, st.turn_timeout);
|
||||
assert_eq!(9, st.hand_ack_timeout);
|
||||
}
|
||||
|
||||
// -- join ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn join_assigns_teams_and_starts() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = make_session(&engine, empty()).unwrap();
|
||||
assert!(engine.in_lobby(&session));
|
||||
for (name, team) in [("bob", "B"), ("carol", "A"), ("dave", "B")] {
|
||||
engine.join(&mut session, name, &capitalize(name)).unwrap();
|
||||
assert_eq!(Some(team.to_string()), session.players.last().unwrap().team);
|
||||
}
|
||||
assert!(!engine.in_lobby(&session));
|
||||
assert_eq!(PHASE_PLAYING, state(&session).phase);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_errors() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = make_session(&engine, empty()).unwrap();
|
||||
assert_eq!(
|
||||
"already joined this game",
|
||||
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
|
||||
);
|
||||
for name in ["bob", "carol", "dave"] {
|
||||
engine.join(&mut session, name, &capitalize(name)).unwrap();
|
||||
}
|
||||
// The lobby filled up and the match started: late joins and even re-joins
|
||||
// are rejected as "already started".
|
||||
assert_eq!(
|
||||
"game has already started",
|
||||
err_message(&engine.join(&mut session, "erin", "Erin").unwrap_err())
|
||||
);
|
||||
assert_eq!(
|
||||
"game has already started",
|
||||
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
|
||||
);
|
||||
}
|
||||
|
||||
// -- actions ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn unknown_action_rejected() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = started(&engine, empty());
|
||||
assert!(engine
|
||||
.handle_action(&mut session, "alice", "dance", &Map::new())
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_validates_payload() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = started(&engine, empty());
|
||||
assert!(engine
|
||||
.handle_action(&mut session, "bob", "play", &opts(json!({"card": 42})))
|
||||
.is_err());
|
||||
assert!(engine
|
||||
.handle_action(&mut session, "bob", "play", &Map::new())
|
||||
.is_err());
|
||||
assert!(engine
|
||||
.handle_action(
|
||||
&mut session,
|
||||
"bob",
|
||||
"play",
|
||||
&opts(json!({"card": "01D", "capture": "02C"}))
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_card_code_is_illegal_move() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = started(&engine, empty());
|
||||
let error = engine
|
||||
.handle_action(&mut session, "bob", "play", &opts(json!({"card": "nope"})))
|
||||
.unwrap_err();
|
||||
assert_eq!("invalid card code", err_message(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_match_rejects_actions() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = started(&engine, opts(json!({"target_score": 1})));
|
||||
drive_to_finish(&engine, &mut session);
|
||||
assert!(engine.is_finished(&session));
|
||||
let error = engine
|
||||
.handle_action(&mut session, "alice", "play", &opts(json!({"card": "01D"})))
|
||||
.unwrap_err();
|
||||
assert_eq!("the match is over", err_message(&error));
|
||||
}
|
||||
|
||||
// -- views -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn view_for_hides_other_hands() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let session = started(&engine, empty());
|
||||
let view = engine.view_for(&session, "alice");
|
||||
let players = view["players"].as_array().unwrap();
|
||||
let alice = players.iter().find(|p| p["seat"] == 0).unwrap();
|
||||
let bob = players.iter().find(|p| p["seat"] == 1).unwrap();
|
||||
assert!(alice.get("hand").is_some());
|
||||
assert!(bob.get("hand").is_none());
|
||||
// The envelope is the platform's job, not the view's.
|
||||
assert!(view.get("id").is_none());
|
||||
assert!(view.get("join_code").is_none());
|
||||
assert!(view.get("game_type").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lobby_view() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let session = make_session(&engine, opts(json!({"target_score": 16}))).unwrap();
|
||||
let lobby = engine.lobby_view(&session);
|
||||
assert_eq!(json!("lobby"), lobby["phase"]);
|
||||
assert_eq!(json!(16), lobby["target_score"]);
|
||||
assert_eq!(json!(true), lobby["napola"]);
|
||||
assert_eq!(json!(false), lobby["asso_pigliatutto"]);
|
||||
}
|
||||
|
||||
// -- serialization ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn state_roundtrip() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let session = started(&engine, empty());
|
||||
let state_value = engine
|
||||
.state_to_json(session.state.as_ref().unwrap().as_ref())
|
||||
.unwrap();
|
||||
let restored = engine.state_from_json(&state_value).unwrap();
|
||||
let restored = restored.downcast_ref::<ScoponeState>().unwrap();
|
||||
assert_eq!(state(&session).phase, restored.phase);
|
||||
assert_eq!(state(&session).turn, restored.turn);
|
||||
assert_eq!(
|
||||
state(&session)
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| p.sub.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
restored
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| p.sub.clone())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
// -- deadlines -------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn no_deadline_in_lobby() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let session = make_session(&engine, empty()).unwrap();
|
||||
assert!(engine.next_deadline(&session).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_deadline_and_revalidation() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = started(&engine, empty());
|
||||
let deadline = engine.next_deadline(&session).unwrap().unwrap();
|
||||
assert_eq!("turn", deadline.kind);
|
||||
// A forged token is stale.
|
||||
assert!(engine
|
||||
.fire_deadline(&mut session, "turn", "turn:1:1:0")
|
||||
.is_err());
|
||||
let turn_before = state(&session).turn;
|
||||
engine
|
||||
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
|
||||
.unwrap();
|
||||
assert_ne!(turn_before, state(&session).turn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_deadline_after_play() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = started(&engine, empty());
|
||||
let deadline = engine.next_deadline(&session).unwrap().unwrap();
|
||||
// A play lands in time: the armed deadline is overtaken.
|
||||
let (sub, payload) = {
|
||||
let st = state(&session);
|
||||
let player = st.players.iter().find(|p| p.seat == st.turn).unwrap();
|
||||
let played = player.hand[0].clone();
|
||||
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
|
||||
let mut payload = Map::new();
|
||||
payload.insert("card".to_string(), json!(played.code()));
|
||||
if let Some(option) = options.first() {
|
||||
payload.insert(
|
||||
"capture".to_string(),
|
||||
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
|
||||
);
|
||||
}
|
||||
(player.sub.clone(), payload)
|
||||
};
|
||||
engine
|
||||
.handle_action(&mut session, &sub, "play", &payload)
|
||||
.unwrap();
|
||||
assert!(engine
|
||||
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_end_deadline_advances() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = started(&engine, empty());
|
||||
{
|
||||
let st = state_mut(&mut session);
|
||||
st.phase = PHASE_HAND_END.to_string();
|
||||
st.hand_end_deadline = Some(tavolo_platform::datetime::to_iso(
|
||||
tavolo_platform::datetime::now() + chrono::Duration::seconds(60),
|
||||
));
|
||||
}
|
||||
let deadline = engine.next_deadline(&session).unwrap().unwrap();
|
||||
assert_eq!("hand_end", deadline.kind);
|
||||
engine
|
||||
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
|
||||
.unwrap();
|
||||
let st = state(&session);
|
||||
assert_eq!(PHASE_PLAYING, st.phase);
|
||||
assert_eq!(2, st.hand_number);
|
||||
}
|
||||
|
||||
// -- results ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn result_of_finished_match() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = started(&engine, opts(json!({"target_score": 1})));
|
||||
drive_to_finish(&engine, &mut session);
|
||||
let result = engine.result(&session).unwrap();
|
||||
assert_eq!(2, result.teams.len());
|
||||
assert!(result.winner_team == 0 || result.winner_team == 1);
|
||||
assert_eq!(4, result.players.len());
|
||||
let winner_name = if result.winner_team == 0 { "A" } else { "B" };
|
||||
for player in &result.players {
|
||||
assert_eq!(player.won, player.team.as_deref() == Some(winner_name));
|
||||
}
|
||||
assert!(result.summary.get("team_a_score").is_some());
|
||||
assert!(result.summary.get("hands_played").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_requires_finished_match() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let session = started(&engine, empty());
|
||||
assert!(engine.result(&session).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_over_view() {
|
||||
let engine = ScoponeEngine::default();
|
||||
let mut session = started(&engine, empty());
|
||||
{
|
||||
let st = state_mut(&mut session);
|
||||
st.phase = PHASE_FINISHED.to_string();
|
||||
st.winner = Some(1);
|
||||
st.scores = vec![3, 11];
|
||||
}
|
||||
let over = engine.game_over_view(&session, "alice");
|
||||
assert_eq!(json!({"A": 3, "B": 11}), over["scores"]);
|
||||
assert_eq!(json!("B"), over["winner"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_metadata() {
|
||||
let engine = ScoponeEngine::default();
|
||||
assert_eq!("scopone_scientifico", engine.id());
|
||||
assert_eq!(4, engine.min_players());
|
||||
assert_eq!(4, engine.max_players());
|
||||
let schema = engine.options_schema();
|
||||
assert!(schema["properties"]["target_score"].is_object());
|
||||
assert!(schema["properties"]["napola"].is_object());
|
||||
assert!(schema["properties"]["asso_pigliatutto"].is_object());
|
||||
// Byte-compatible option schema: descriptions and defaults match Python.
|
||||
assert_eq!(
|
||||
json!("Score the napola rule; a full denari sweep wins the match"),
|
||||
schema["properties"]["napola"]["description"]
|
||||
);
|
||||
assert_eq!(
|
||||
json!("Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)"),
|
||||
schema["properties"]["asso_pigliatutto"]["description"]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user