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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
182
server/server.py
182
server/server.py
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user