Server: implement JOIN/JOIN_ACK/JOIN_DENY handling, input parsing/relay, spawn logic, apples maintenance; fix InMemoryTransport to address specific peer; add state_full encoder

- Protocol: join parser, join_ack/deny builders, input parser, state_full builder
- Server: on_datagram dispatch, spawn per rules (prefer length 3 else 1), join deny if no cell, immediate input_broadcast relay
- Model: occupancy map and helpers
- Transport: deliver to specified peer in in-memory mode
This commit is contained in:
Vladyslav Doloman
2025-10-07 20:22:22 +03:00
parent 9043ba81c0
commit 7a5f2d8794
4 changed files with 301 additions and 12 deletions

View File

@@ -38,4 +38,18 @@ class GameState:
snakes: Dict[int, Snake] = field(default_factory=dict)
apples: List[Coord] = field(default_factory=list)
tick: int = 0
occupancy: Dict[Coord, Tuple[int, int]] = field(default_factory=dict) # cell -> (snake_id, index)
def in_bounds(self, x: int, y: int) -> bool:
return 0 <= x < self.width and 0 <= y < self.height
def cell_free(self, x: int, y: int) -> bool:
return (x, y) not in self.occupancy
def occupy_snake(self, snake: Snake) -> None:
for idx, (cx, cy) in enumerate(snake.body):
self.occupancy[(cx, cy)] = (snake.snake_id, idx)
def clear_snake(self, snake: Snake) -> None:
for (cx, cy) in list(snake.body):
self.occupancy.pop((cx, cy), None)

View File

@@ -114,7 +114,7 @@ def unpack_header(buf: bytes, expect_tick: bool) -> Tuple[int, PacketType, int,
return ver, ptype, flags, seq, tick, off
# Message builders (subset/skeleton)
# Message builders/parsers (subset)
def build_config_update(
*,
@@ -191,3 +191,109 @@ def bitpack_2bit_directions(directions: Iterable[Direction]) -> bytes:
out.append(acc & 0xFF) # zero-padded high bits
return bytes(out)
# Join / Ack / Deny
def build_join(name_utf8: bytes, preferred_color_id: int | None = None) -> bytes:
# Client-side helper (not used in server)
raise NotImplementedError
def parse_join(buf: bytes, offset: int) -> Tuple[str, int | None, int]:
name_len, off = quic_varint_decode(buf, offset)
name_b = buf[off : off + name_len]
off += name_len
preferred = None
if off < len(buf):
preferred = buf[off]
off += 1
name = name_b.decode("utf-8", errors="ignore")
return name, preferred, off
def build_join_ack(
*,
version: int,
seq: int,
player_id: int,
color_id: int,
width: int,
height: int,
tick_rate: int,
wrap_edges: bool,
apples_per_snake: int,
apples_cap: int,
compression_mode: int,
) -> bytes:
header = pack_header(version, PacketType.JOIN_ACK, 0, seq, None)
body = bytearray()
body.append(player_id & 0xFF)
body.append(color_id & 0xFF)
body.append(width & 0xFF)
body.append(height & 0xFF)
body.append(tick_rate & 0xFF)
body.append(1 if wrap_edges else 0)
body.append(apples_per_snake & 0xFF)
body.append(apples_cap & 0xFF)
body.append(compression_mode & 0xFF)
return header + bytes(body)
def build_join_deny(*, version: int, seq: int, reason: str) -> bytes:
header = pack_header(version, PacketType.JOIN_DENY, 0, seq, None)
rb = reason.encode("utf-8")[:64]
return header + quic_varint_encode(len(rb)) + rb
# Input (client -> server)
def parse_input(buf: bytes, offset: int) -> Tuple[int, int, int, List[InputEvent], int]:
ack_seq = int.from_bytes(buf[offset : offset + 2], "big")
offset += 2
input_seq = int.from_bytes(buf[offset : offset + 2], "big")
offset += 2
base_tick = int.from_bytes(buf[offset : offset + 2], "big")
offset += 2
n_ev, offset = quic_varint_decode(buf, offset)
events: List[InputEvent] = []
for _ in range(n_ev):
rel, offset = quic_varint_decode(buf, offset)
d = Direction(buf[offset] & 0x03)
offset += 1
events.append(InputEvent(rel_tick_offset=int(rel), direction=d))
return ack_seq, input_seq, base_tick, events, offset
# State snapshot (server -> client)
def build_state_full(
*,
version: int,
seq: int,
tick: int,
snakes: Sequence[Tuple[int, int, int, int, Sequence[Direction]]],
apples: Sequence[Tuple[int, int]],
) -> bytes:
"""Build a minimal state_full: per-snake header + BODY_2BIT TLV; apples list.
snakes: sequence of (snake_id, len, head_x, head_y, body_dirs_from_head)
apples: sequence of (x, y)
"""
header = pack_header(version, PacketType.STATE_FULL, 0, seq, tick & 0xFFFF)
body = bytearray()
# snakes count
body.extend(quic_varint_encode(len(snakes)))
for sid, slen, hx, hy, dirs in snakes:
body.append(sid & 0xFF)
body.extend(int(slen & 0xFFFF).to_bytes(2, "big"))
body.append(hx & 0xFF)
body.append(hy & 0xFF)
payload = bitpack_2bit_directions(dirs)
tlv = pack_body_tlv(BodyTLV.BODY_2BIT, payload)
body.extend(tlv)
# apples
body.extend(quic_varint_encode(len(apples)))
for ax, ay in apples:
body.append(ax & 0xFF)
body.append(ay & 0xFF)
return header + bytes(body)

View File

@@ -2,17 +2,22 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Dict, List
from typing import Dict, List, Optional, Tuple
from .config import ServerConfig
from .model import GameState, PlayerSession
from .model import GameState, PlayerSession, Snake, Coord
from .protocol import (
Direction,
InputEvent,
PacketType,
build_join_ack,
build_join_deny,
build_config_update,
build_input_broadcast,
build_state_full,
pack_header,
parse_input,
parse_join,
)
from .transport import DatagramServerTransport, TransportPeer
@@ -37,8 +42,21 @@ class GameServer:
self._config_update_interval_ticks = 50 # periodic resend
async def on_datagram(self, data: bytes, peer: TransportPeer) -> None:
# Skeleton: just ignore inbound for now; real impl would parse and dispatch
return
# Minimal header parse
if len(data) < 5:
return
ver = data[0]
ptype = PacketType(data[1])
flags = data[2]
# seq = int.from_bytes(data[3:5], 'big') # currently unused
off = 5
if ptype == PacketType.JOIN:
await self._handle_join(data, off, peer)
elif ptype == PacketType.INPUT:
await self._handle_input(data, off, peer)
else:
# ignore others in skeleton
return
async def broadcast_config_update(self) -> None:
r = self.runtime
@@ -97,6 +115,161 @@ class GameServer:
elapsed = asyncio.get_event_loop().time() - start
await asyncio.sleep(max(0.0, tick_duration - elapsed))
# --- Join / Spawn ---
def _allocate_player_id(self) -> Optional[int]:
used = {s.player_id for s in self.sessions.values()}
for pid in range(self.runtime.config.players_max):
if pid not in used:
return pid
return None
def _choose_color_id(self) -> int:
used = {s.color_id for s in self.sessions.values()}
for cid in range(32):
if cid not in used:
return cid
return 0
def _neighbors(self, x: int, y: int) -> List[Tuple[Direction, Coord]]:
return [
(Direction.UP, (x, y - 1)),
(Direction.RIGHT, (x + 1, y)),
(Direction.DOWN, (x, y + 1)),
(Direction.LEFT, (x - 1, y)),
]
def _find_spawn(self) -> Optional[Snake]:
st = self.runtime.state
# Try to find a 3-cell straight strip
for y in range(st.height):
for x in range(st.width):
if not st.cell_free(x, y):
continue
for d, (nx, ny) in self._neighbors(x, y):
# check two cells in direction
x2, y2 = nx, ny
x3, y3 = nx + (1 if d == Direction.RIGHT else -1 if d == Direction.LEFT else 0), ny + (1 if d == Direction.DOWN else -1 if d == Direction.UP else 0)
if st.in_bounds(x2, y2) and st.in_bounds(x3, y3) and st.cell_free(x2, y2) and st.cell_free(x3, y3):
body = [
(x, y),
(x2, y2),
(x3, y3),
]
return Snake(snake_id=-1, head=(x, y), direction=d, body=deque(body))
# Fallback: any single free cell
for y in range(st.height):
for x in range(st.width):
if st.cell_free(x, y):
return Snake(snake_id=-1, head=(x, y), direction=Direction.RIGHT, body=deque([(x, y)]))
return None
def _ensure_apples(self) -> None:
st = self.runtime.state
cfg = self.runtime.config
import random
random.seed(0xC0FFEE)
target = 3 if not self.sessions else min(cfg.apples_cap, max(0, len(self.sessions) * cfg.apples_per_snake))
# grow apples up to target
while len(st.apples) < target:
x = random.randrange(st.width)
y = random.randrange(st.height)
if st.cell_free(x, y) and (x, y) not in st.apples:
st.apples.append((x, y))
# shrink if too many
if len(st.apples) > target:
st.apples = st.apples[:target]
async def _handle_join(self, buf: bytes, off: int, peer: TransportPeer) -> None:
name, preferred, off2 = parse_join(buf, off)
# enforce name <=16 bytes utf-8
name = name.encode("utf-8")[:16].decode("utf-8", errors="ignore")
pid = self._allocate_player_id()
if pid is None:
payload = build_join_deny(version=self.runtime.version, seq=self.runtime.next_seq(), reason="Server full")
await self.transport.send(payload, peer)
return
# Spawn snake
snake = self._find_spawn()
if snake is None:
payload = build_join_deny(version=self.runtime.version, seq=self.runtime.next_seq(), reason="No free cell, please wait")
await self.transport.send(payload, peer)
return
# Register session and snake
color_id = preferred if (preferred is not None) else self._choose_color_id()
session = PlayerSession(player_id=pid, name=name, color_id=color_id, peer=peer.addr)
self.sessions[pid] = session
snake.snake_id = pid
self.runtime.state.snakes[pid] = snake
self.runtime.state.occupy_snake(snake)
self._ensure_apples()
# Send join_ack
cfg = self.runtime.config
ack = build_join_ack(
version=self.runtime.version,
seq=self.runtime.next_seq(),
player_id=pid,
color_id=color_id,
width=cfg.width,
height=cfg.height,
tick_rate=cfg.tick_rate,
wrap_edges=cfg.wrap_edges,
apples_per_snake=cfg.apples_per_snake,
apples_cap=cfg.apples_cap,
compression_mode=0 if cfg.compression_mode == "none" else 1,
)
await self.transport.send(ack, peer)
# Send initial state_full
snakes_dirs: List[Tuple[int, int, int, int, List[Direction]]] = []
for s in self.runtime.state.snakes.values():
dirs: List[Direction] = []
# build directions from consecutive coords: from head toward tail
coords = list(s.body)
for i in range(len(coords) - 1):
x0, y0 = coords[i]
x1, y1 = coords[i + 1]
if x1 == x0 and y1 == y0 - 1:
dirs.append(Direction.UP)
elif x1 == x0 + 1 and y1 == y0:
dirs.append(Direction.RIGHT)
elif x1 == x0 and y1 == y0 + 1:
dirs.append(Direction.DOWN)
elif x1 == x0 - 1 and y1 == y0:
dirs.append(Direction.LEFT)
hx, hy = coords[0]
snakes_dirs.append((s.snake_id, s.length, hx, hy, dirs))
full = build_state_full(
version=self.runtime.version,
seq=self.runtime.next_seq(),
tick=self.runtime.state.tick,
snakes=snakes_dirs,
apples=self.runtime.state.apples,
)
await self.transport.send(full, peer)
async def _handle_input(self, buf: bytes, off: int, peer: TransportPeer) -> None:
try:
ack_seq, input_seq, base_tick, events, off2 = parse_input(buf, off)
except Exception:
return
# Find player by peer
player_id = None
for pid, sess in self.sessions.items():
if sess.peer is peer.addr:
player_id = pid
break
if player_id is None:
return
# Relay to others immediately for prediction
await self.relay_input_broadcast(
from_player_id=player_id,
input_seq=input_seq,
base_tick=base_tick,
events=events,
apply_at_tick=None,
)
async def main() -> None:
from .transport import InMemoryTransport
@@ -112,4 +285,3 @@ if __name__ == "__main__":
asyncio.run(main())
except KeyboardInterrupt:
pass

View File

@@ -32,11 +32,9 @@ class InMemoryTransport(DatagramServerTransport):
self._peers.append(peer)
async def send(self, data: bytes, peer: TransportPeer) -> None:
# In-memory: deliver to all except sender to simulate broadcast domain
for p in self._peers:
if p is peer:
continue
await self._on_datagram(data, p)
# In-memory: deliver only to the addressed peer
if peer in self._peers:
await self._on_datagram(data, peer)
async def run(self) -> None:
# Nothing to do in in-memory test transport
@@ -58,4 +56,3 @@ class QuicWebTransportServer(DatagramServerTransport):
async def run(self) -> None:
raise NotImplementedError("QUIC server not implemented in skeleton")