diff --git a/server/model.py b/server/model.py index e9a22db..a2cee20 100644 --- a/server/model.py +++ b/server/model.py @@ -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) diff --git a/server/protocol.py b/server/protocol.py index aa78f08..09e3e67 100644 --- a/server/protocol.py +++ b/server/protocol.py @@ -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) diff --git a/server/server.py b/server/server.py index e4cee81..c60c5ce 100644 --- a/server/server.py +++ b/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 - diff --git a/server/transport.py b/server/transport.py index 489d6b0..764689e 100644 --- a/server/transport.py +++ b/server/transport.py @@ -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") -