state advancer reducer

This commit is contained in:
Tao Tien 2026-02-20 15:36:04 -08:00
parent c12667938e
commit e2c9c815ec
44 changed files with 2063 additions and 773 deletions

View file

@ -1,15 +1,9 @@
use log::debug;
use spacetimedb::{ReducerContext, Table, reducer};
use jong_types::TurnState;
use crate::tables::*;
mod reducers {
mod deal;
mod hand;
mod lobby;
}
mod reducers;
mod tables;
#[reducer]
@ -32,27 +26,40 @@ pub fn clear_all(ctx: &ReducerContext) {
}
#[reducer(client_connected)]
pub fn login_or_add_player(ctx: &ReducerContext) {
let identity = ctx.sender;
// TODO remove player on disconnect
if let Ok(player) = ctx.db.player().try_insert(Player {
identity,
id: 0,
name: None,
lobby_id: 0,
ready: false,
sort: true,
hand: vec![],
pond: vec![],
drawn_tile: None,
turn_state: TurnState::None,
}) {
debug!("added player: {:?}", player);
pub fn connect(ctx: &ReducerContext) -> Result<(), String> {
let player = if let Some(player) = ctx.db.logged_out_player().identity().find(ctx.sender) {
let player = ctx.db.player().insert(player);
ctx.db.logged_out_player().identity().delete(ctx.sender);
player
} else {
let player = ctx.db.player().identity().find(identity).unwrap();
debug!("player {:?} has reconnected", player)
}
ctx.db.player().try_insert(Player {
identity: ctx.sender,
id: 0,
name: None,
lobby_id: 0,
ready: false,
sort: true,
})?
};
debug!("player connected: {:?}", player);
Ok(())
}
#[reducer(client_disconnected)]
pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> {
let player = ctx
.db
.player()
.identity()
.find(ctx.sender)
.ok_or_else(|| format!("can't find player {}", ctx.sender))?;
ctx.db.logged_out_player().insert(player);
ctx.db.player().identity().delete(ctx.sender);
Ok(())
}
// #[reducer(init)]
@ -65,11 +72,6 @@ pub fn login_or_add_player(ctx: &ReducerContext) {
// // Called everytime a new client connects
// }
// #[reducer(client_disconnected)]
// pub fn identity_disconnected(_ctx: &ReducerContext) {
// // Called everytime a client disconnects
// }
// #[reducer]
// pub fn add(ctx: &ReducerContext, name: String) {
// ctx.db.player().insert(Player { name });

118
jong-line/src/reducers.rs Normal file
View file

@ -0,0 +1,118 @@
use std::time::Duration;
use spacetimedb::{ReducerContext, ScheduleAt::Interval, reducer};
use jong_types::{GameState, TurnState};
use crate::{
reducers::deal::shuffle_deal,
tables::{
GameTimer, PlayerClock, PlayerOrBot, bot, game_timer, lobby as _, player_clock,
player_hand, wall,
},
};
mod deal;
mod hand;
mod lobby;
#[reducer]
pub fn advance_game(ctx: &ReducerContext, mut game_timer: GameTimer) -> Result<(), String> {
// checks every second (or more? when users make moves) on whether to advance the game's various states
// TODO this, or allow player/debug to call this?
if !ctx.sender_auth().is_internal() {
return Err("This reducer can only be called by the scheduler".to_string());
}
if let Some(mut lobby) = ctx.db.lobby().id().find(game_timer.lobby_id) {
match lobby.game_state {
GameState::Setup => {
// TODO reduce interval beforehand so we don't wait a second?
// TODO keep a count to clear stale lobbies
lobby.game_state = GameState::Deal;
}
GameState::Deal => {
// TODO reduce interval beforehand so this can animate?
shuffle_deal(ctx, lobby.id);
}
GameState::Play => {
let curr_player = lobby.players.get(lobby.current_idx as usize).unwrap();
match curr_player {
PlayerOrBot::Player { id: player_id } => {
let mut clock = ctx.db.player_clock().player_id().find(player_id).unwrap();
let mut hand = ctx.db.player_hand().player_id().find(player_id).unwrap();
match hand.turn_state {
TurnState::None => {
// TODO draw tile
if let Some(mut wall) = ctx.db.wall().lobby_id().find(lobby.id)
&& let Some(tile) = wall.tiles.pop()
{
hand.working_tile = Some(tile);
hand.turn_state = TurnState::Tsumo;
ctx.db.wall().lobby_id().update(wall);
ctx.db.player_hand().id().update(hand);
} else {
// TODO out of tiles
todo!()
}
}
TurnState::Tsumo => {
if clock.tick() {
ctx.db.player_clock().id().update(clock);
} else {
// TODO auto-discard
}
}
TurnState::Menzen => {}
TurnState::RiichiKan => {}
TurnState::RonChiiPonKan => {}
TurnState::End => {}
}
}
PlayerOrBot::Bot { id: bot_id } => {
let b = ctx.db.bot().id().find(bot_id).unwrap();
}
}
}
GameState::Exit => {
ctx.db.game_timer().id().delete(game_timer.id);
ctx.db.lobby().id().delete(lobby.id);
// TODO reset all player lobbies, delete bots, etc?
// is there a way to do this automatically, or rely on elsewhere's checks clearing the state?
return Ok(());
}
// TODO handle stale lobbies
// TODO should this delete the timer?
_ => Err(format!("lobby {} in impossible state", lobby.id))?,
}
ctx.db.game_timer().id().update(game_timer);
} else {
ctx.db.game_timer().id().delete(game_timer.id);
Err(format!(
"ran schedule {} for empty lobby {}",
game_timer.id, game_timer.lobby_id
))?;
}
Ok(())
}
impl PlayerClock {
fn tick(&mut self) -> bool {
if self.renewable > 0 {
self.renewable -= 1;
true
} else if self.total > 0 {
self.total -= 1;
true
} else {
false
}
}
fn renew(&mut self) {
self.renewable = 5;
}
}

View file

@ -1,18 +1,13 @@
use log::debug;
use spacetimedb::{ReducerContext, Table, rand::seq::SliceRandom, reducer};
use super::hand::deal_hands;
use crate::tables::*;
#[reducer]
pub fn shuffle_deal(ctx: &ReducerContext, lobby_id: u32) {
debug!("lobby_id: {lobby_id}");
let mut lobby = ctx.db.lobby().id().find(lobby_id).unwrap();
if lobby.game_state == jong_types::states::GameState::Setup {
lobby.game_state = jong_types::states::GameState::Deal;
lobby = ctx.db.lobby().id().update(lobby);
if lobby.game_state == jong_types::states::GameState::Deal {
let tiles = new_shuffled_wall(ctx);
ctx.db.wall().insert(DbWall {
@ -24,7 +19,6 @@ pub fn shuffle_deal(ctx: &ReducerContext, lobby_id: u32) {
deal_hands(ctx, lobby_id);
lobby.game_state = jong_types::states::GameState::Play;
lobby.turn_state = jong_types::states::TurnState::Tsumo;
ctx.db.lobby().id().update(lobby);
}
}
@ -39,3 +33,32 @@ pub fn new_shuffled_wall(ctx: &ReducerContext) -> Vec<DbTile> {
wall
}
pub fn deal_hands(ctx: &ReducerContext, lobby_id: u32) {
let players = ctx.db.player().lobby_id().filter(lobby_id);
let bots = ctx.db.bot().lobby_id().filter(lobby_id);
let mut wall = ctx.db.wall().lobby_id().find(lobby_id).unwrap();
// FIXME rectify deal orders
for player in players {
let mut tiles = wall.tiles.split_off(wall.tiles.len() - 13);
wall = ctx.db.wall().lobby_id().update(wall);
tiles.sort_by_key(|t| t.tile);
ctx.db.player_hand().insert(PlayerHand {
id: 0,
player_id: player.id,
turn_state: jong_types::TurnState::None,
pond: vec![],
hand: tiles,
working_tile: None,
});
}
for mut bot in bots {
let mut tiles = wall.tiles.split_off(wall.tiles.len() - 13);
wall = ctx.db.wall().lobby_id().update(wall);
tiles.sort_by_key(|t| t.tile);
bot.hand = tiles;
ctx.db.bot().id().update(bot);
}
}

View file

@ -1,66 +1,27 @@
use log::{debug, trace};
use spacetimedb::{ReducerContext, reducer};
use crate::tables::{player::player, *};
use jong_types::states::TurnState;
pub fn deal_hands(ctx: &ReducerContext, lobby_id: u32) {
let players = ctx.db.player().lobby_id().filter(lobby_id);
let bots = ctx.db.bot().lobby_id().filter(lobby_id);
let mut wall = ctx.db.wall().lobby_id().find(lobby_id).unwrap();
// FIXME rectify deal orders
for mut player in players {
let mut tiles = wall.tiles.split_off(wall.tiles.len() - 13);
wall = ctx.db.wall().lobby_id().update(wall);
tiles.sort_by_key(|t| t.tile);
player.hand = tiles;
ctx.db.player().id().update(player);
}
for mut bot in bots {
let mut tiles = wall.tiles.split_off(wall.tiles.len() - 13);
wall = ctx.db.wall().lobby_id().update(wall);
tiles.sort_by_key(|t| t.tile);
bot.hand = tiles;
ctx.db.bot().id().update(bot);
}
}
#[reducer]
pub fn draw_tile(ctx: &ReducerContext) {
let mut player = ctx.db.player().identity().find(ctx.sender).unwrap();
let mut wall = ctx.db.wall().lobby_id().find(player.lobby_id).unwrap();
// TODO if no more tiles, exhaust somehow
player.drawn_tile = wall.tiles.pop();
ctx.db.wall().lobby_id().update(wall);
ctx.db.player().id().update(player);
}
use crate::tables::*;
// TODO make sure this can't be called or just error here?
#[reducer]
pub fn discard_tile(ctx: &ReducerContext, tile_id: u32) -> Result<(), String> {
let mut player = ctx.db.player().identity().find(ctx.sender).unwrap();
let mut lobby = ctx.db.lobby().id().find(player.lobby_id).unwrap();
let player = ctx.db.player().identity().find(ctx.sender).unwrap();
let mut hand = ctx.db.player_hand().player_id().find(player.id).unwrap();
let dealt_tile = if let Some(dealt) = ctx.db.tile().id().find(tile_id) {
if let Some(drawn) = player.drawn_tile {
if let Some(drawn) = hand.working_tile {
if drawn.id == dealt.id {
// dealt from drawn tile
dealt
} else if let Some((i, _)) = player
.hand
.iter()
.enumerate()
.find(|(_, t)| t.id == tile_id)
} else if let Some((i, _)) = hand.hand.iter().enumerate().find(|(_, t)| t.id == tile_id)
{
// dealt from hand
let dealt = player.hand.remove(i);
player.hand.push(drawn);
player.hand.sort_by_key(|t| t.tile);
let dealt = hand.hand.remove(i);
hand.hand.push(drawn);
hand.hand.sort_by_key(|t| t.tile);
dealt
} else {
@ -82,54 +43,53 @@ pub fn discard_tile(ctx: &ReducerContext, tile_id: u32) -> Result<(), String> {
));
};
player.pond.push(dealt_tile);
player.drawn_tile = None;
lobby.turn_state = TurnState::RonChiiPonKan;
hand.pond.push(dealt_tile);
hand.working_tile = None;
hand.turn_state = TurnState::None;
let player = ctx.db.player().id().update(player);
ctx.db.lobby().id().update(lobby);
ctx.db.player_hand().id().update(hand);
debug!("player {} discarded tile {:?}", player.id, dealt_tile.tile);
Ok(())
}
#[reducer]
pub fn skip_call(ctx: &ReducerContext) {
trace!("skip_call");
// #[reducer]
// pub fn skip_call(ctx: &ReducerContext) {
// trace!("skip_call");
let player = ctx.db.player().identity().find(ctx.sender).unwrap();
let mut lobby = ctx.db.lobby().id().find(player.lobby_id).unwrap();
// let player = ctx.db.player().identity().find(ctx.sender).unwrap();
// let mut lobby = ctx.db.lobby().id().find(player.lobby_id).unwrap();
lobby.turn_state = TurnState::Tsumo;
lobby.current_idx += 1;
if lobby.current_idx >= 3 {
lobby.current_idx = 0;
}
// lobby.turn_state = TurnState::Tsumo;
// lobby.current_idx += 1;
// if lobby.current_idx >= 3 {
// lobby.current_idx = 0;
// }
// FIXME where better can this go
bot_moves(ctx, &mut lobby);
// // FIXME where better can this go
// bot_moves(ctx, &mut lobby);
ctx.db.player().id().update(player);
ctx.db.lobby().id().update(lobby);
}
// ctx.db.player().id().update(player);
// ctx.db.lobby().id().update(lobby);
// }
fn bot_moves(ctx: &ReducerContext, lobby: &mut Lobby) {
let mut wall = ctx.db.wall().lobby_id().find(lobby.id).unwrap();
if let Some(PlayerOrBot::Bot { id }) = lobby.players.get(lobby.current_idx as usize + 1) {
let mut bot = ctx.db.bot().id().find(id).unwrap();
bot.pond.push(wall.tiles.pop().unwrap());
ctx.db.bot().id().update(bot);
lobby.turn_state = TurnState::RonChiiPonKan;
} else {
lobby.turn_state = TurnState::Tsumo;
}
// fn bot_moves(ctx: &ReducerContext, lobby: &mut Lobby) {
// let mut wall = ctx.db.wall().lobby_id().find(lobby.id).unwrap();
// if let Some(PlayerOrBot::Bot { id }) = lobby.players.get(lobby.current_idx as usize + 1) {
// let mut bot = ctx.db.bot().id().find(id).unwrap();
// bot.pond.push(wall.tiles.pop().unwrap());
// ctx.db.bot().id().update(bot);
// lobby.turn_state = TurnState::RonChiiPonKan;
// } else {
// lobby.turn_state = TurnState::Tsumo;
// }
lobby.current_idx += 1;
if lobby.current_idx >= 3 {
lobby.current_idx = 0;
}
}
// lobby.current_idx += 1;
// if lobby.current_idx >= 3 {
// lobby.current_idx = 0;
// }
// }
// #[view(name = view_player_hand, public)]
// pub fn view_player_hand(ctx: &ViewContext) -> Option<Hand> {

View file

@ -1,3 +1,5 @@
use std::time::Duration;
use log::info;
use spacetimedb::{ReducerContext, Table, rand::seq::SliceRandom, reducer};
@ -5,18 +7,16 @@ use crate::tables::*;
#[reducer]
pub fn join_or_create_lobby(ctx: &ReducerContext, mut lobby_id: u32) -> Result<(), String> {
let ok_or = ctx
let mut player = ctx
.db
.player()
.identity()
.find(ctx.sender)
.ok_or(format!("cannot find player {}", ctx.sender))?;
let mut player = ok_or;
if lobby_id == 0 {
let lobby = ctx.db.lobby().insert(Lobby {
id: 0,
host_player_id: player.id,
players: vec![PlayerOrBot::Player { id: player.id }],
game_state: jong_types::states::GameState::Lobby,
dealer_idx: 0,
@ -49,7 +49,8 @@ pub fn add_bot(ctx: &ReducerContext, lobby_id: u32) -> Result<(), String> {
lobby_id,
hand: vec![],
pond: vec![],
drawn_tile: None,
working_tile: None,
turn_state: jong_types::TurnState::None,
});
lobby.players.push(PlayerOrBot::Bot { id: bot.id });
ctx.db.lobby().id().update(lobby);
@ -61,29 +62,33 @@ pub fn add_bot(ctx: &ReducerContext, lobby_id: u32) -> Result<(), String> {
}
#[reducer]
pub fn set_ready(ctx: &ReducerContext, ready: bool) {
pub fn set_ready(ctx: &ReducerContext, ready: bool) -> Result<(), String> {
let mut player = ctx.db.player().identity().find(ctx.sender).unwrap();
player.ready = ready;
player = ctx.db.player().identity().update(player);
ctx.db.player().identity().update(player);
}
#[reducer]
pub fn start_game(ctx: &ReducerContext) {
let player = ctx.db.player().identity().find(ctx.sender).unwrap();
if let Some(mut lobby) = ctx.db.lobby().host_player_id().find(player.id)
if let Some(mut lobby) = ctx.db.lobby().id().find(player.lobby_id)
&& lobby.players.len() == 4
&& lobby.players.iter().all(|p| match p {
PlayerOrBot::Player { id } => ctx.db.player().id().find(id).is_some_and(|p| p.ready),
PlayerOrBot::Bot { id } => ctx.db.bot().id().find(id).is_some(),
})
&& ctx.db.player().lobby_id().filter(lobby.id).all(|p| p.ready)
{
lobby.game_state = jong_types::states::GameState::Setup;
lobby.players.shuffle(&mut ctx.rng());
lobby.dealer_idx += 1;
if lobby.dealer_idx > 3 {
lobby.dealer_idx = 0;
}
ctx.db.lobby().id().update(lobby);
let lobby = ctx.db.lobby().id().update(lobby);
// TODO should we schedule this outside so that we can clear out stale lobbies?
ctx.db.game_timer().insert(GameTimer {
id: 0,
lobby_id: lobby.id,
scheduled_at: spacetimedb::ScheduleAt::Interval(Duration::from_secs(1).into()),
});
} else {
// if lobby doesn't exist, reset player state
player.lobby_id = 0;
player.ready = false;
player = ctx.db.player().identity().update(player);
return Err(format!("couldn't find lobby with id: {}", player.lobby_id));
}
Ok(())
}

View file

@ -1,3 +1,5 @@
use std::time::Instant;
use spacetimedb::{SpacetimeType, table};
use jong_types::{
@ -5,6 +7,8 @@ use jong_types::{
tiles::Tile,
};
use crate::reducers::advance_game;
#[derive(Debug, Clone)]
#[table(name = lobby, public)]
pub struct Lobby {
@ -12,8 +16,6 @@ pub struct Lobby {
#[auto_inc]
pub id: u32,
#[unique]
pub host_player_id: u32,
pub players: Vec<PlayerOrBot>,
pub dealer_idx: u8,
pub current_idx: u8,
@ -45,41 +47,55 @@ pub enum PlayerOrBot {
Bot { id: u32 },
}
#[table(name = advance_state_timer)]
pub struct AdvanceStateTimer {
#[primary_key]
#[auto_inc]
scheduled_id: u64,
scheduled_at: spacetimedb::ScheduleAt,
lobby_id: u32,
}
// FIXME this shant be public, use views
#[table(name = player, public)]
#[table(name = logged_out_player)]
#[derive(Debug)]
pub struct Player {
#[primary_key]
pub identity: spacetimedb::Identity,
#[unique]
#[auto_inc]
pub id: u32,
#[primary_key]
pub identity: spacetimedb::Identity,
pub name: Option<String>,
#[index(btree)]
pub lobby_id: u32,
pub ready: bool,
pub sort: bool,
}
#[table(name = player_clock, public)]
pub struct PlayerClock {
#[primary_key]
pub id: u32,
#[unique]
pub player_id: u32,
pub renewable: u16,
pub total: u16,
}
#[table(name = player_hand)]
pub struct PlayerHand {
#[primary_key]
#[auto_inc]
pub id: u32,
#[unique]
pub player_id: u32,
pub turn_state: TurnState,
pub sort: bool,
pub hand: Vec<DbTile>,
pub pond: Vec<DbTile>,
pub hand: Vec<DbTile>,
pub drawn_tile: Option<DbTile>,
/// drawn or callable tile
pub working_tile: Option<DbTile>,
}
#[table(name = bot)]
@ -91,8 +107,22 @@ pub struct Bot {
#[index(btree)]
pub lobby_id: u32,
pub turn_state: TurnState,
pub hand: Vec<DbTile>,
pub pond: Vec<DbTile>,
pub drawn_tile: Option<DbTile>,
pub working_tile: Option<DbTile>,
}
#[table(name = game_timer, scheduled(advance_game))]
pub struct GameTimer {
#[primary_key]
#[auto_inc]
pub id: u64,
#[unique]
pub lobby_id: u32,
pub scheduled_at: spacetimedb::ScheduleAt,
}