# Keep a seat for a player who drops

**For:** Rust developers running a plaza game with a fixed number of seats who need dropped players to get back in without losing their place.

You will build a three-seat table that is never empty. Bots sit in every seat until people arrive. A person takes a bot's seat on the next tick. When a player's link drops, the seat is held for a grace period with everything in it. If they come back in time they resume where they were. If they don't, the hold runs out and a bot takes the seat. Last, the host kicks a player. The table frees that seat at once, because it knows the close was ordered and was not a drop.

Before you start: you need Rust with Cargo. plaza comes from crates.io as `plaza = "0"`.

## Set up the project

Start a new binary crate.

```sh
cargo new gracetable
cd gracetable
```

Add plaza, plaza_server_utils, Tokio and async-trait. The grace bookkeeping (`ReconnectTracker`) and the closure log (`ClosureLog`) are in plaza itself. The seating blocks (`Roster` and `Crew`) are in plaza_server_utils. Your `[dependencies]` section should look like this:

```toml title="Cargo.toml"
[dependencies]
plaza = "0"
plaza_server_utils = "0"
async-trait = "0.1"
tokio = { version = "1", features = ["macros", "rt"] }
```

The table runs over `InProcessSession`, so a dropped link is `session.disconnect` and a reconnect is another `session.connect` with the same agent. Time moves with `TickDriver::run_virtual`, which sends the controller a batch of time steps without waiting on a clock. The grace period is counted in ticks. Together with Tokio's `current_thread` runtime that makes every run print the same lines.

## Seat players and fill the rest with bots

Seating needs more policy than "first come, first seated". `Roster` holds the seats and adds the policies you switch on. `with_waitlist` queues a player who arrives at a full table instead of turning them away. Every admission carries a rank, where lower is better. People come in at rank 0 through `admit`. `Crew` seats bots at rank 1 through the same roster and remembers which seats are theirs, since a bot holds a seat but no connection.

The roster settles an arrival or a departure at once, but it only promotes from the waitlist in `resolve`. Your `TimeStep` arm calls it once a tick, so seating is decided in one place. A waiting person outranks a seated bot, so `resolve` returns a `Displaced` for the bot and a `Promoted` for the person. `crew.prune` then forgets the displaced bots and takes them off the waitlist, so they can't come back as strangers when a seat opens.

Replace `src/main.rs` with this:

```rust title="src/main.rs"
use std::error::Error;
use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use plaza::controller::{ControllerCommand, StateControllerBuilder, query_with};
use plaza::session::in_process::ClientInbox;
use plaza::state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError};
use plaza::{Agent, InProcessSession, SnapshotFn, TargetedOp, TickDriver};
use plaza_server_utils::{Admission, Crew, Roster, SeatState, Shuffle};

type PlayerId = u64;

const SEATS: usize = 3;
const BOT_BASE: PlayerId = 1000;
const STEP: Duration = Duration::from_millis(100);

#[derive(Clone, Debug)]
enum TableOp {
    Welcome { seat: Option<usize> },
    Seated { seat: usize },
}

#[derive(Debug)]
struct Table {
    tick: u64,
    roster: Roster<PlayerId>,
    crew: Crew<PlayerId>,
    next_bot: PlayerId,
}

impl Table {
    fn new() -> Self {
        let mut table = Table {
            tick: 0,
            roster: Roster::new(SEATS).with_waitlist(),
            crew: Crew::new(),
            next_bot: BOT_BASE,
        };
        table.fill_with_bots();
        table
    }

    fn fill_with_bots(&mut self) {
        let open = self.roster.capacity() - self.roster.occupied_count();
        let first = self.next_bot;
        let seats = self
            .crew
            .fill(&mut self.roster, open, 1, |i| first + i as PlayerId);
        self.next_bot += seats.len() as PlayerId;
        for seat in seats {
            println!(
                "tick {}: {} takes seat {seat}",
                self.tick,
                name(self.seated(seat))
            );
        }
    }

    fn seated(&self, seat: usize) -> PlayerId {
        match self.roster.seat_state(seat) {
            SeatState::Human(id) | SeatState::Held(id) => *id,
            SeatState::Open => 0,
        }
    }

    fn seat_map(&self) -> String {
        let seats: Vec<String> = self
            .roster
            .seats()
            .enumerate()
            .map(|(seat, state)| match state {
                SeatState::Human(id) => format!("{seat}: {}", name(*id)),
                SeatState::Held(id) => format!("{seat}: held for {}", name(*id)),
                SeatState::Open => format!("{seat}: open"),
            })
            .collect();
        seats.join(", ")
    }
}

fn name(id: PlayerId) -> String {
    match id {
        1 => "alice".into(),
        2 => "bob".into(),
        3 => "carol".into(),
        bot => format!("bot {}", bot - BOT_BASE),
    }
}

fn seat_round(table: &mut Table, out: &mut Vec<TargetedOp<TableOp, PlayerId>>) {
    let mut promoted = Vec::new();
    for shuffle in table.roster.resolve() {
        match shuffle {
            Shuffle::Displaced { key, seat } => {
                println!("tick {}: {} gives up seat {seat}", table.tick, name(key));
            }
            Shuffle::Promoted { key, seat } => {
                println!("tick {}: {} takes seat {seat}", table.tick, name(key));
                promoted.push((key, seat));
            }
        }
    }
    table.crew.prune(&mut table.roster);
    for (key, seat) in promoted {
        if !table.crew.holds(seat) {
            out.push(TargetedOp::new_system_to(
                key,
                vec![TableOp::Seated { seat }],
            ));
        }
    }
    table.fill_with_bots();
}

struct TableRules;

#[async_trait]
impl StateLogic<TableOp, PlayerId, Table> for TableRules {
    async fn process_input(
        &self,
        table: &mut Table,
        input: LogicInput<TableOp, PlayerId>,
    ) -> Result<LogicOutput<TableOp, PlayerId>, StateLogicError> {
        let mut out = Vec::new();
        match input {
            LogicInput::AgentJoined { agent } => {
                let Some(id) = agent.id_cloned() else {
                    return Ok(LogicOutput::none());
                };
                match table.roster.admit(id) {
                    Admission::Seated { seat, .. } => {
                        println!("tick {}: {} sits in open seat {seat}", table.tick, name(id))
                    }
                    Admission::Waitlisted { position } => {
                        println!(
                            "tick {}: {} waits for a seat, position {position}",
                            table.tick,
                            name(id)
                        )
                    }
                    other => println!("tick {}: {} not seated: {other:?}", table.tick, name(id)),
                }
            }
            LogicInput::AgentLeft { agent_id } => {
                table.roster.depart(&agent_id);
            }
            LogicInput::TimeStep { .. } => {
                table.tick += 1;
                seat_round(table, &mut out);
            }
            LogicInput::AgentOps { .. } => {}
        }
        Ok(out.into())
    }
}

fn welcome(table: &Table, target: Option<&Agent<PlayerId>>) -> Option<TableOp> {
    let id = target?.id_cloned()?;
    Some(TableOp::Welcome {
        seat: table.roster.seat_of(&id),
    })
}

async fn heard(id: PlayerId, inbox: &ClientInbox<TableOp, PlayerId>) -> Result<(), Box<dyn Error>> {
    let msg = inbox.recv().await?;
    for op in msg.ops {
        match op {
            TableOp::Welcome { seat: Some(seat) } => {
                println!("  {} hears: welcome, seat {seat}", name(id))
            }
            TableOp::Welcome { seat: None } => {
                println!("  {} hears: welcome, no seat yet", name(id))
            }
            TableOp::Seated { seat } => println!("  {} hears: you are in seat {seat}", name(id)),
        }
    }
    Ok(())
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn Error>> {
    let session = InProcessSession::<TableOp, PlayerId>::new();
    let (tx, controller) = StateControllerBuilder::new(
        Arc::new(TableRules),
        session.clone(),
        Arc::new(SnapshotFn(welcome)),
        Table::new(),
    )
    .build();
    let running = tokio::spawn(controller.run());

    let (_alice_conn, alice) = session.connect(Agent::new_human(1)).await?;
    heard(1, &alice).await?;
    let (_bob_conn, bob) = session.connect(Agent::new_human(2)).await?;
    heard(2, &bob).await?;

    TickDriver::run_virtual(&tx, STEP, 1).await;
    heard(1, &alice).await?;
    heard(2, &bob).await?;
    println!(
        "seats: {}",
        query_with(&tx, |table: &Table| table.seat_map()).await?
    );

    tx.send(ControllerCommand::Shutdown).await?;
    running.await??;
    Ok(())
}
```

`Table::new` fills all three seats with bots before anyone connects. `fill_with_bots` gives every fill its own range of keys from `next_bot`, because `Crew::fill` names bots `0..count` through the closure and expects the caller to keep them unique. Keys from 1000 up stay clear of the people's ids. The snapshot provider is a plain function wrapped in `SnapshotFn`. It tells a joining client which seat it has, if any.

Run it:

```shell-session
$ cargo run
tick 0: bot 0 takes seat 0
tick 0: bot 1 takes seat 1
tick 0: bot 2 takes seat 2
tick 0: alice waits for a seat, position 0
  alice hears: welcome, no seat yet
tick 0: bob waits for a seat, position 1
  bob hears: welcome, no seat yet
tick 1: bot 2 gives up seat 2
tick 1: alice takes seat 2
tick 1: bot 1 gives up seat 1
tick 1: bob takes seat 1
  alice hears: you are in seat 2
  bob hears: you are in seat 1
seats: 0: bot 0, 1: bob, 2: alice
```

Alice and Bob both arrive at a full table and wait. On tick 1 `resolve` displaces the bots in the two highest seats, since among equal ranks the later seat goes first. The people sit down in them. Each of them gets a `Seated` op addressed only to them. Bot 0 keeps seat 0.

## Hold the seat of a player who drops

The transport only knows that a socket closed. It can't tell whether the player quit or is back in ten seconds. In plaza, your rules decide that with a `ReconnectTracker`. It holds no timers and spawns no tasks. It keeps a record of who left and when. You drive it from your rules: `on_disconnect` from `AgentLeft`, `on_reconnect` from `AgentJoined` and `expired` from your tick.

`holding_seats` makes the roster keep a departed player's seat. `depart` returns `Departure::Held` instead of freeing it. The seat stays held until you call `expire`. When the player comes back, `admit` returns `Admission::Resumed` and everything that belongs to the seat is still there. That is different from `Admission::Seated { fresh: true }`, which tells you to reset the seat because its state belonged to someone else.

A returning player has to arrive with the same agent id. Here that is `Agent::new_human(1)` again. On a real server, derive the id from something durable such as an auth token, never from the connection.

Each occupied seat now earns a chip per tick, standing in for whatever a player builds up during a game. A held seat earns nothing. Replace `src/main.rs` with the full program:

```rust title="src/main.rs" {6-7,11,16,22,32-33,40,43-44,58,80-87,113,128,130-135,154-167,174,176-178,181-192,196,206-208,215-219,221,227-228,230,232-237,253,261,263-264,266-268}
use std::error::Error;
use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use plaza::common::reconnect::ReconnectTracker;
use plaza::controller::{CommandSender, ControllerCommand, StateControllerBuilder, query_with};
use plaza::session::in_process::ClientInbox;
use plaza::state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError};
use plaza::{Agent, InProcessSession, SnapshotFn, TargetedOp, TickDriver};
use plaza_server_utils::{Admission, Crew, Departure, Roster, SeatState, Shuffle};

type PlayerId = u64;

const SEATS: usize = 3;
const GRACE_TICKS: u64 = 5;
const BOT_BASE: PlayerId = 1000;
const STEP: Duration = Duration::from_millis(100);

#[derive(Clone, Debug)]
enum TableOp {
    Welcome { seat: Option<usize>, chips: u32 },
    Seated { seat: usize },
}

#[derive(Debug)]
struct Table {
    tick: u64,
    roster: Roster<PlayerId>,
    crew: Crew<PlayerId>,
    next_bot: PlayerId,
    tracker: ReconnectTracker<PlayerId, u64>,
    chips: [u32; SEATS],
}

impl Table {
    fn new() -> Self {
        let mut table = Table {
            tick: 0,
            roster: Roster::new(SEATS).with_waitlist().holding_seats(),
            crew: Crew::new(),
            next_bot: BOT_BASE,
            tracker: ReconnectTracker::new(GRACE_TICKS),
            chips: [0; SEATS],
        };
        table.fill_with_bots();
        table
    }

    fn fill_with_bots(&mut self) {
        let open = self.roster.capacity() - self.roster.occupied_count();
        let first = self.next_bot;
        let seats = self
            .crew
            .fill(&mut self.roster, open, 1, |i| first + i as PlayerId);
        self.next_bot += seats.len() as PlayerId;
        for seat in seats {
            self.chips[seat] = 0;
            println!(
                "tick {}: {} takes seat {seat}",
                self.tick,
                name(self.seated(seat))
            );
        }
    }

    fn seated(&self, seat: usize) -> PlayerId {
        match self.roster.seat_state(seat) {
            SeatState::Human(id) | SeatState::Held(id) => *id,
            SeatState::Open => 0,
        }
    }

    fn seat_map(&self) -> String {
        let seats: Vec<String> = self
            .roster
            .seats()
            .enumerate()
            .map(|(seat, state)| match state {
                SeatState::Human(id) => {
                    format!("{seat}: {} (chips {})", name(*id), self.chips[seat])
                }
                SeatState::Held(id) => format!(
                    "{seat}: held for {} (chips {})",
                    name(*id),
                    self.chips[seat]
                ),
                SeatState::Open => format!("{seat}: open"),
            })
            .collect();
        seats.join(", ")
    }
}

fn name(id: PlayerId) -> String {
    match id {
        1 => "alice".into(),
        2 => "bob".into(),
        3 => "carol".into(),
        bot => format!("bot {}", bot - BOT_BASE),
    }
}

fn seat_round(table: &mut Table, out: &mut Vec<TargetedOp<TableOp, PlayerId>>) {
    let mut promoted = Vec::new();
    for shuffle in table.roster.resolve() {
        match shuffle {
            Shuffle::Displaced { key, seat } => {
                println!("tick {}: {} gives up seat {seat}", table.tick, name(key));
            }
            Shuffle::Promoted { key, seat } => {
                println!("tick {}: {} takes seat {seat}", table.tick, name(key));
                table.chips[seat] = 0;
                promoted.push((key, seat));
            }
        }
    }
    table.crew.prune(&mut table.roster);
    for (key, seat) in promoted {
        if !table.crew.holds(seat) {
            out.push(TargetedOp::new_system_to(
                key,
                vec![TableOp::Seated { seat }],
            ));
        }
    }
    table.fill_with_bots();
}

fn play(table: &mut Table) {
    for seat in 0..SEATS {
        if let SeatState::Human(_) = table.roster.seat_state(seat) {
            table.chips[seat] += 1;
        }
    }
}

struct TableRules;

#[async_trait]
impl StateLogic<TableOp, PlayerId, Table> for TableRules {
    async fn process_input(
        &self,
        table: &mut Table,
        input: LogicInput<TableOp, PlayerId>,
    ) -> Result<LogicOutput<TableOp, PlayerId>, StateLogicError> {
        let mut out = Vec::new();
        match input {
            LogicInput::AgentJoined { agent } => {
                let Some(id) = agent.id_cloned() else {
                    return Ok(LogicOutput::none());
                };
                match table.roster.admit(id) {
                    Admission::Resumed { seat } => {
                        let returning = table.tracker.on_reconnect(&id);
                        println!(
                            "tick {}: {} is back (returning: {returning}), seat {seat} resumes with {} chips",
                            table.tick,
                            name(id),
                            table.chips[seat]
                        );
                    }
                    Admission::Seated { seat, fresh } => {
                        if fresh {
                            table.chips[seat] = 0;
                        }
                        println!("tick {}: {} sits in open seat {seat}", table.tick, name(id));
                    }
                    Admission::Waitlisted { position } => {
                        println!(
                            "tick {}: {} waits for a seat, position {position}",
                            table.tick,
                            name(id)
                        );
                    }
                    Admission::Turned(why) => {
                        println!("tick {}: {} turned away: {why:?}", table.tick, name(id))
                    }
                }
            }
            LogicInput::AgentLeft { agent_id } => match table.roster.depart(&agent_id) {
                Departure::Held { seat } => {
                    table.tracker.on_disconnect(agent_id, table.tick);
                    let until = table.tracker.deadline_for(&agent_id).unwrap_or_default();
                    println!(
                        "tick {}: {} dropped, seat {seat} held until tick {until}",
                        table.tick,
                        name(agent_id)
                    );
                }
                other => println!("tick {}: {} left: {other:?}", table.tick, name(agent_id)),
            },
            LogicInput::TimeStep { .. } => {
                table.tick += 1;
                seat_round(table, &mut out);
                play(table);
            }
            LogicInput::AgentOps { .. } => {}
        }
        Ok(out.into())
    }
}

fn welcome(table: &Table, target: Option<&Agent<PlayerId>>) -> Option<TableOp> {
    let id = target?.id_cloned()?;
    let seat = table.roster.seat_of(&id);
    let chips = seat.map_or(0, |seat| table.chips[seat]);
    Some(TableOp::Welcome { seat, chips })
}

async fn heard(id: PlayerId, inbox: &ClientInbox<TableOp, PlayerId>) -> Result<(), Box<dyn Error>> {
    let msg = inbox.recv().await?;
    for op in msg.ops {
        match op {
            TableOp::Welcome {
                seat: Some(seat),
                chips,
            } => {
                println!("  {} hears: welcome, seat {seat}, {chips} chips", name(id))
            }
            TableOp::Welcome { seat: None, .. } => {
                println!("  {} hears: welcome, no seat yet", name(id))
            }
            TableOp::Seated { seat } => println!("  {} hears: you are in seat {seat}", name(id)),
        }
    }
    Ok(())
}

type Tx = CommandSender<TableOp, PlayerId, Table>;

async fn ticks(tx: &Tx, count: u64) -> Result<(), Box<dyn Error>> {
    TickDriver::run_virtual(tx, STEP, count).await;
    println!(
        "seats: {}",
        query_with(tx, |table: &Table| table.seat_map()).await?
    );
    Ok(())
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn Error>> {
    let session = InProcessSession::<TableOp, PlayerId>::new();
    let (tx, controller) = StateControllerBuilder::new(
        Arc::new(TableRules),
        session.clone(),
        Arc::new(SnapshotFn(welcome)),
        Table::new(),
    )
    .build();
    let running = tokio::spawn(controller.run());

    let (alice_conn, alice) = session.connect(Agent::new_human(1)).await?;
    heard(1, &alice).await?;
    let (_bob_conn, bob) = session.connect(Agent::new_human(2)).await?;
    heard(2, &bob).await?;

    TickDriver::run_virtual(&tx, STEP, 1).await;
    heard(1, &alice).await?;
    heard(2, &bob).await?;
    ticks(&tx, 3).await?;

    session.disconnect(&1, alice_conn).await;
    ticks(&tx, 2).await?;

    let (_alice_conn, alice) = session.connect(Agent::new_human(1)).await?;
    heard(1, &alice).await?;
    ticks(&tx, 1).await?;

    tx.send(ControllerCommand::Shutdown).await?;
    running.await??;
    Ok(())
}
```

`ticks` advances the table and prints the seats. The controller takes a join or a leave ahead of any queued command, so the ticks `main` sends after `session.disconnect` land after the departure.

Run it:

```shell-session
$ cargo run
tick 0: bot 0 takes seat 0
tick 0: bot 1 takes seat 1
tick 0: bot 2 takes seat 2
tick 0: alice waits for a seat, position 0
  alice hears: welcome, no seat yet
tick 0: bob waits for a seat, position 1
  bob hears: welcome, no seat yet
tick 1: bot 2 gives up seat 2
tick 1: alice takes seat 2
tick 1: bot 1 gives up seat 1
tick 1: bob takes seat 1
  alice hears: you are in seat 2
  bob hears: you are in seat 1
seats: 0: bot 0 (chips 4), 1: bob (chips 4), 2: alice (chips 4)
tick 4: alice dropped, seat 2 held until tick 9
seats: 0: bot 0 (chips 6), 1: bob (chips 6), 2: held for alice (chips 4)
tick 6: alice is back (returning: true), seat 2 resumes with 4 chips
  alice hears: welcome, seat 2, 4 chips
seats: 0: bot 0 (chips 7), 1: bob (chips 7), 2: alice (chips 5)
```

Alice drops on tick 4 with 4 chips. Her seat stays hers for 5 ticks and stops earning. She reconnects on tick 6, `on_reconnect` returns `true` and the welcome snapshot she gets shows seat 2 and the same 4 chips. On the next tick she is earning again.

## Give an unclaimed seat to a bot

Nothing in the tracker or the roster frees a held seat by itself. Your tick asks the tracker who ran out of grace with `expired(now)` and tells the roster with `expire`. The seat is then open. The same `seat_round` that runs every tick puts a bot in it. Replace the `TimeStep` arm:

```rust title="src/main.rs"
LogicInput::TimeStep { .. } => {
    table.tick += 1;
    for id in table.tracker.expired(table.tick) {
        if let Some(seat) = table.roster.expire(&id) {
            println!(
                "tick {}: {}'s grace ran out, seat {seat} opens",
                table.tick,
                name(id)
            );
        }
    }
    seat_round(table, &mut out);
    play(table);
}
```

The script now drops Alice a second time and lets the grace run out, then brings her back. Her second reconnect keeps its connection id, so change that `_alice_conn` to `alice_conn`. Then add this to the end of the script in `main`:

```rust title="src/main.rs"
session.disconnect(&1, alice_conn).await;
ticks(&tx, GRACE_TICKS).await?;

let (_alice_conn, alice) = session.connect(Agent::new_human(1)).await?;
heard(1, &alice).await?;
TickDriver::run_virtual(&tx, STEP, 1).await;
heard(1, &alice).await?;
ticks(&tx, 1).await?;
```

Run it:

```shell-session
$ cargo run
tick 0: bot 0 takes seat 0
tick 0: bot 1 takes seat 1
tick 0: bot 2 takes seat 2
tick 0: alice waits for a seat, position 0
  alice hears: welcome, no seat yet
tick 0: bob waits for a seat, position 1
  bob hears: welcome, no seat yet
tick 1: bot 2 gives up seat 2
tick 1: alice takes seat 2
tick 1: bot 1 gives up seat 1
tick 1: bob takes seat 1
  alice hears: you are in seat 2
  bob hears: you are in seat 1
seats: 0: bot 0 (chips 4), 1: bob (chips 4), 2: alice (chips 4)
tick 4: alice dropped, seat 2 held until tick 9
seats: 0: bot 0 (chips 6), 1: bob (chips 6), 2: held for alice (chips 4)
tick 6: alice is back (returning: true), seat 2 resumes with 4 chips
  alice hears: welcome, seat 2, 4 chips
seats: 0: bot 0 (chips 7), 1: bob (chips 7), 2: alice (chips 5)
tick 7: alice dropped, seat 2 held until tick 12
tick 12: alice's grace ran out, seat 2 opens
tick 12: bot 3 takes seat 2
seats: 0: bot 0 (chips 12), 1: bob (chips 12), 2: bot 3 (chips 1)
tick 12: alice waits for a seat, position 0
  alice hears: welcome, no seat yet
tick 13: bot 3 gives up seat 2
tick 13: alice takes seat 2
  alice hears: you are in seat 2
seats: 0: bot 0 (chips 14), 1: bob (chips 14), 2: alice (chips 2)
```

Alice's second drop on tick 7 is held until tick 12. On tick 12 `expired` names her, `expire` opens seat 2 and bot 3 sits down in the same tick. When Alice comes back after that she is a new arrival. She waits, displaces bot 3 on the next tick and starts again from zero chips, because `Promoted` always means a fresh seat.

## Tell a kick from a drop

Every departure reaches your rules as the same `AgentLeft`, whether the network failed or the host removed the player. Only the code that ordered a close knows the difference. `ClosureLog` records it: call `order` when you decide to close someone. Then `departed` in `AgentLeft` returns `Departed::Ordered(reason)` for that close and `Departed::Netdrop` for everything else. A drop holds the seat. A removal clears it.

Add a reason and two ops, one for the host's order and one for the player's goodbye:

```rust title="src/main.rs"
#[derive(Clone, Copy, Debug)]
enum Reason {
    Kicked,
}

#[derive(Clone, Debug)]
enum TableOp {
    Welcome { seat: Option<usize>, chips: u32 },
    Seated { seat: usize },
    Kick(PlayerId),
    Farewell(Reason),
}
```

Add `closures: ClosureLog<PlayerId, Reason>` to `Table` and `closures: ClosureLog::new()` to `Table::new`. Import `ClosureLog` and `Departed` from `plaza::common::closure`. The kick arrives as a system op. `order` returns `true` only the first time for a key, so the goodbye goes out once even if several rules decide to close the same player. Add this arm to the rules, above the existing `AgentOps` arm:

```rust title="src/main.rs"
LogicInput::AgentOps { source, ops } if source.is_system() => {
    for op in ops {
        let TableOp::Kick(id) = op else { continue };
        if table.closures.order(id, Reason::Kicked) {
            println!("tick {}: the host orders {} out", table.tick, name(id));
            out.push(TargetedOp::new_system_to(
                id,
                vec![TableOp::Farewell(Reason::Kicked)],
            ));
        }
    }
}
```

Replace the `AgentLeft` arm so a departure is checked against the log first:

```rust title="src/main.rs"
LogicInput::AgentLeft { agent_id } => match table.closures.departed(&agent_id) {
    Departed::Ordered(reason) => {
        table.roster.depart(&agent_id);
        if let Some(seat) = table.roster.expire(&agent_id) {
            println!(
                "tick {}: {} left as ordered ({reason:?}), seat {seat} opens",
                table.tick,
                name(agent_id)
            );
        }
    }
    Departed::Netdrop => match table.roster.depart(&agent_id) {
        Departure::Held { seat } => {
            table.tracker.on_disconnect(agent_id, table.tick);
            let until = table.tracker.deadline_for(&agent_id).unwrap_or_default();
            println!(
                "tick {}: {} dropped, seat {seat} held until tick {until}",
                table.tick,
                name(agent_id)
            );
        }
        other => println!("tick {}: {} left: {other:?}", table.tick, name(agent_id)),
    },
},
```

The log holds no sockets. The rules send the farewell and the application closes the connection, which over `InProcessSession` is `session.disconnect`. Give `heard` arms for the two new ops:

```rust title="src/main.rs"
async fn heard(id: PlayerId, inbox: &ClientInbox<TableOp, PlayerId>) -> Result<(), Box<dyn Error>> {
    let msg = inbox.recv().await?;
    for op in msg.ops {
        match op {
            TableOp::Welcome {
                seat: Some(seat),
                chips,
            } => {
                println!("  {} hears: welcome, seat {seat}, {chips} chips", name(id))
            }
            TableOp::Welcome { seat: None, .. } => {
                println!("  {} hears: welcome, no seat yet", name(id))
            }
            TableOp::Seated { seat } => println!("  {} hears: you are in seat {seat}", name(id)),
            TableOp::Farewell(reason) => println!("  {} hears: farewell, {reason:?}", name(id)),
            TableOp::Kick(_) => {}
        }
    }
    Ok(())
}
```

Keep Bob's connection id by renaming `_bob_conn` to `bob_conn`. Then add this to the end of the script:

```rust title="src/main.rs"
tx.send(ControllerCommand::SubmitSystemOps {
    source_description: "host".into(),
    ops: vec![TableOp::Kick(2)],
})
.await?;
heard(2, &bob).await?;
session.disconnect(&2, bob_conn).await;
ticks(&tx, 1).await?;
```

Run it:

```shell-session
$ cargo run
tick 0: bot 0 takes seat 0
tick 0: bot 1 takes seat 1
tick 0: bot 2 takes seat 2
tick 0: alice waits for a seat, position 0
  alice hears: welcome, no seat yet
tick 0: bob waits for a seat, position 1
  bob hears: welcome, no seat yet
tick 1: bot 2 gives up seat 2
tick 1: alice takes seat 2
tick 1: bot 1 gives up seat 1
tick 1: bob takes seat 1
  alice hears: you are in seat 2
  bob hears: you are in seat 1
seats: 0: bot 0 (chips 4), 1: bob (chips 4), 2: alice (chips 4)
tick 4: alice dropped, seat 2 held until tick 9
seats: 0: bot 0 (chips 6), 1: bob (chips 6), 2: held for alice (chips 4)
tick 6: alice is back (returning: true), seat 2 resumes with 4 chips
  alice hears: welcome, seat 2, 4 chips
seats: 0: bot 0 (chips 7), 1: bob (chips 7), 2: alice (chips 5)
tick 7: alice dropped, seat 2 held until tick 12
tick 12: alice's grace ran out, seat 2 opens
tick 12: bot 3 takes seat 2
seats: 0: bot 0 (chips 12), 1: bob (chips 12), 2: bot 3 (chips 1)
tick 12: alice waits for a seat, position 0
  alice hears: welcome, no seat yet
tick 13: bot 3 gives up seat 2
tick 13: alice takes seat 2
  alice hears: you are in seat 2
seats: 0: bot 0 (chips 14), 1: bob (chips 14), 2: alice (chips 2)
tick 14: the host orders bob out
  bob hears: farewell, Kicked
tick 14: bob left as ordered (Kicked), seat 1 opens
tick 15: bot 4 takes seat 1
seats: 0: bot 0 (chips 15), 1: bot 4 (chips 1), 2: alice (chips 3)
```

Bob hears his farewell, his connection closes and `departed` reports the close as ordered. His seat opens on the spot with no grace period. Bot 4 takes it on the next tick. Alice's two drops earlier in the run went through the `Netdrop` arm and were held.

An op from a closed player can still arrive after the close, since ops and presence travel on separate streams. `was_ordered` stays `true` after the departure, so the rules can drop those ops.

## Where to go next

When seating needs no policy at all, `SeatTable` is the smaller block: `seat` returns `Seating::Fresh`, `Seating::Existing` or `Seating::Full` and `unseat` frees the seat. `Roster` also has `lock` and `unlock` for games that only seat people between rounds. A grace rule that depends on more than a fixed window, such as a longer hold for a ranked player, still uses the tracker: it reports who ran out and leaves the decision to you.

The [grace_run example](https://github.com/excsn/plaza/tree/main/examples/grace_run) holds seats across drops in a playable game, with meters for how long the party waited and how many runs a short window cost. [Chapter 12 of the plaza guide, Players come and go](https://github.com/excsn/plaza/blob/main/docs/guide/12-players-come-and-go.md), covers presence, held seats, bots and what happens when a match ends. The [plaza usage guide](https://github.com/excsn/plaza/blob/main/core/README.USAGE.md) and the [plaza_server_utils usage guide](https://github.com/excsn/plaza/blob/main/server_utils/README.USAGE.md) on GitHub cover the tracker and the seating blocks.

The full API references are on docs.rs: [plaza](https://docs.rs/plaza) and [plaza_server_utils](https://docs.rs/plaza_server_utils).
