Protocol/Server: implement STATE_DELTA + PART partitioning and per-tick minimal changes

- Protocol: SnakeDelta structure; build_state_delta(_body); build_part
- Server: compute per-snake changes (move/grow/blocked-shrink), apples diffs
- Partition large deltas by snake changes with apples in first part; use update_id
- Send STATE_DELTA when under MTU; else PART packets referencing STATE_DELTA
This commit is contained in:
Vladyslav Doloman
2025-10-07 20:30:48 +03:00
parent 991b8f3660
commit 967784542d
2 changed files with 183 additions and 28 deletions

View File

@@ -16,9 +16,10 @@ from .protocol import (
build_config_update,
build_input_broadcast,
build_state_full,
BodyTLV,
build_state_full,
quic_varint_encode,
build_state_delta,
build_state_delta_body,
build_part,
SnakeDelta,
pack_header,
parse_input,
parse_join,
@@ -32,11 +33,16 @@ class ServerRuntime:
state: GameState
seq: int = 0
version: int = 1
update_id: int = 0
def next_seq(self) -> int:
self.seq = (self.seq + 1) & 0xFFFF
return self.seq
def next_update_id(self) -> int:
self.update_id = (self.update_id + 1) & 0xFFFF
return self.update_id
class GameServer:
def __init__(self, transport: DatagramServerTransport, config: ServerConfig):
@@ -157,6 +163,8 @@ class GameServer:
st = self.runtime.state
cfg = self.runtime.config
apples_eaten: List[Coord] = []
apples_before = set(st.apples)
changes: List[SnakeDelta] = []
# Prepare snapshot of tails to allow moving into own tail when it vacates
tails: Dict[int, Coord] = {}
for sid, s in st.snakes.items():
@@ -187,6 +195,18 @@ class GameServer:
if s.length > 1:
tx, ty = s.body.pop()
st.occupancy.pop((tx, ty), None)
changes.append(
SnakeDelta(
snake_id=sid,
head_moved=False,
tail_removed=(s.length > 1),
grew=False,
blocked=True,
new_head_x=hx,
new_head_y=hy,
direction=s.direction,
)
)
continue
# Move or grow
s.blocked = False
@@ -198,40 +218,98 @@ class GameServer:
# eat apple; no tail removal this tick
st.apples.remove((nx, ny))
apples_eaten.append((nx, ny))
changes.append(
SnakeDelta(
snake_id=sid,
head_moved=True,
tail_removed=False,
grew=True,
blocked=False,
new_head_x=nx,
new_head_y=ny,
direction=s.direction,
)
)
else:
# normal move: remove tail (unless length==0)
tx, ty = s.body.pop()
if (tx, ty) in st.occupancy:
st.occupancy.pop((tx, ty), None)
changes.append(
SnakeDelta(
snake_id=sid,
head_moved=True,
tail_removed=True,
grew=False,
blocked=False,
new_head_x=nx,
new_head_y=ny,
direction=s.direction,
)
)
# Replenish apples to target
self._ensure_apples()
apples_after = set(st.apples)
apples_added = sorted(list(apples_after - apples_before))
apples_removed = sorted(list(apples_before - apples_after))
# Broadcast a basic delta (currently full snakes + apples as delta)
snakes_dirs: List[Tuple[int, int, int, int, List[Direction]]] = []
for s in st.snakes.values():
dirs: List[Direction] = []
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))
delta = build_state_full(
version=self.runtime.version,
seq=self.runtime.next_seq(),
tick=st.tick,
snakes=snakes_dirs,
apples=st.apples,
update_id = self.runtime.next_update_id()
# Serialize full delta body once, then partition if large
full_body = build_state_delta_body(
update_id=update_id,
changes=changes,
apples_added=apples_added,
apples_removed=apples_removed,
)
for session in list(self.sessions.values()):
await self.transport.send(delta, TransportPeer(session.peer))
MTU = 1200 # soft limit for payload to avoid fragmentation
if len(full_body) <= MTU:
packet = pack_header(self.runtime.version, PacketType.STATE_DELTA, 0, self.runtime.next_seq(), st.tick & 0xFFFF) + full_body
for session in list(self.sessions.values()):
await self.transport.send(packet, TransportPeer(session.peer))
else:
# Partition by splitting snake changes across parts; include apples only in the first part
parts: List[bytes] = []
remaining = list(changes)
idx = 0
part_index = 0
while remaining:
chunk: List[SnakeDelta] = []
# greedy pack until size would exceed MTU
# start with apples only for first part
add_ap = apples_added if part_index == 0 else []
rem_ap = apples_removed if part_index == 0 else []
# Try adding changes one by one
for i, ch in enumerate(remaining):
tmp_body = build_state_delta_body(update_id=update_id, changes=chunk + [ch], apples_added=add_ap, apples_removed=rem_ap)
if len(tmp_body) > MTU and chunk:
break
if len(tmp_body) > MTU:
# even a single change + apples doesn't fit; force single
chunk.append(ch)
i += 1
break
chunk.append(ch)
# Remove chunked items
remaining = remaining[len(chunk) :]
# Build chunk body
chunk_body = build_state_delta_body(update_id=update_id, changes=chunk, apples_added=add_ap, apples_removed=rem_ap)
parts.append(chunk_body)
part_index += 1
# Emit PART packets
total = len(parts)
for i, body in enumerate(parts):
pkt = build_part(
version=self.runtime.version,
seq=self.runtime.next_seq(),
tick=st.tick & 0xFFFF,
update_id=update_id,
part_index=i,
parts_total=total,
inner_type=PacketType.STATE_DELTA,
chunk_payload=body,
)
for session in list(self.sessions.values()):
await self.transport.send(pkt, TransportPeer(session.peer))
# --- Join / Spawn ---