CI / Build and push docker image (push) Successful in 4m20s
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.
484 lines
16 KiB
Rust
484 lines
16 KiB
Rust
//! `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"]
|
|
);
|
|
}
|