mirror of
https://github.com/Qortal/Qortal-Hub.git
synced 2026-08-25 07:36:41 +00:00
2627 lines
122 KiB
Python
2627 lines
122 KiB
Python
"""Private Qortal Land game links and their authenticated loopback control plane.
|
|
|
|
This module deliberately exposes a small, typed command surface. It never
|
|
accepts arbitrary Reticulum operations from the renderer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import queue
|
|
import secrets
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
from collections import deque
|
|
from typing import Any, Callable, Dict, Optional
|
|
|
|
import RNS
|
|
from RNS.Channel import CEType, ChannelException, MessageBase
|
|
from RNS.vendor import umsgpack
|
|
from qortalland_proximity import PROXIMITY_COMMANDS, QortalLandProximityVoiceManager
|
|
|
|
try:
|
|
from websockets.sync.server import serve
|
|
except ImportError: # The frozen bundle and development bootstrap install it.
|
|
serve = None
|
|
|
|
|
|
MAGIC = b"QLG1"
|
|
MSGTYPE = 0x0514
|
|
MAX_LOCAL_FRAME = 16 * 1024
|
|
MAX_CHANNEL_PAYLOAD = 425
|
|
AUTH_TIMEOUT = 2.0
|
|
INVITE_TTL = 60
|
|
LINK_TIMEOUT = 45
|
|
RECOVERY_WINDOW = 30
|
|
HEARTBEAT_INTERVAL = 10
|
|
PROTOCOL_VERSION = 2
|
|
GAME_CONFIGS = {
|
|
"connect-four": {"gameVersion": 1, "rulesVersion": 1, "maxPly": 42},
|
|
"checkers": {"gameVersion": 1, "rulesVersion": 1, "maxPly": 200},
|
|
"chess": {"gameVersion": 1, "rulesVersion": 1, "maxPly": 600},
|
|
}
|
|
ADDRESS_VERSION = 58
|
|
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
BASE58_MAP = {c: i for i, c in enumerate(BASE58_ALPHABET)}
|
|
|
|
COMMANDS = {
|
|
"SET_LAND_CONTEXT",
|
|
"CLEAR_LAND_CONTEXT",
|
|
"OPEN_GAME_LINK",
|
|
"SUBMIT_HANDSHAKE_SIGNATURE",
|
|
"RESPOND_TO_INVITE",
|
|
"SEND_GAME_MESSAGE",
|
|
"RESIGN_GAME",
|
|
"CLOSE_GAME_LINK",
|
|
"GET_ACTIVE_MATCH",
|
|
} | PROXIMITY_COMMANDS
|
|
|
|
HANDSHAKE_TYPES = {
|
|
"QORTAL_LAND_GAME_INVITE",
|
|
"QORTAL_LAND_GAME_ACCEPT",
|
|
"QORTAL_LAND_GAME_DECLINE",
|
|
"QORTAL_LAND_GAME_CONFIRM",
|
|
"QORTAL_LAND_GAME_RESUME_REQUEST",
|
|
"QORTAL_LAND_GAME_RESUME_ACCEPT",
|
|
"QORTAL_LAND_GAME_RESUME_CONFIRM",
|
|
}
|
|
|
|
HANDSHAKE_FIELD_ORDER = {
|
|
"QORTAL_LAND_GAME_INVITE": (
|
|
"type", "protocolVersion", "game", "gameVersion", "rulesVersion",
|
|
"matchId", "groupId", "requesterAddress", "recipientAddress",
|
|
"sourceSessionId", "targetSessionId", "sourceDestinationHash", "targetDestinationHash",
|
|
"signerPublicKey", "requesterNonce", "linkId", "createdAt", "expiresAt",
|
|
),
|
|
"QORTAL_LAND_GAME_ACCEPT": (
|
|
"type", "inviteHash", "matchId", "requesterNonce", "recipientNonce",
|
|
"responderAddress", "signerPublicKey", "linkId", "createdAt",
|
|
),
|
|
"QORTAL_LAND_GAME_DECLINE": (
|
|
"type", "inviteHash", "matchId", "responderAddress", "signerPublicKey",
|
|
"reason", "linkId", "createdAt",
|
|
),
|
|
"QORTAL_LAND_GAME_CONFIRM": (
|
|
"type", "acceptHash", "matchId", "requesterNonce", "recipientNonce",
|
|
"starter", "initialStateHash", "requesterAddress", "signerPublicKey",
|
|
"linkId", "createdAt",
|
|
),
|
|
"QORTAL_LAND_GAME_RESUME_REQUEST": (
|
|
"type", "matchId", "roundId", "requesterAddress", "signerPublicKey", "linkId",
|
|
"sourceSessionId", "targetSessionId", "sourceDestinationHash", "targetDestinationHash",
|
|
"requesterNonce", "lastAcknowledgedPly", "stateHash", "transcriptHash", "createdAt",
|
|
),
|
|
"QORTAL_LAND_GAME_RESUME_ACCEPT": (
|
|
"type", "matchId", "roundId", "responderAddress", "signerPublicKey", "linkId",
|
|
"requesterNonce", "recipientNonce", "lastAcknowledgedPly", "stateHash", "transcriptHash", "createdAt",
|
|
),
|
|
"QORTAL_LAND_GAME_RESUME_CONFIRM": (
|
|
"type", "matchId", "roundId", "requesterAddress", "signerPublicKey", "linkId",
|
|
"requesterNonce", "recipientNonce", "lastAcknowledgedPly", "stateHash", "transcriptHash", "createdAt",
|
|
),
|
|
}
|
|
HANDSHAKE_TYPE_CODES = {name: index + 1 for index, name in enumerate(HANDSHAKE_FIELD_ORDER)}
|
|
HANDSHAKE_TYPES_BY_CODE = {code: name for name, code in HANDSHAKE_TYPE_CODES.items()}
|
|
HEX_FIELD_LENGTHS = {
|
|
"requesterNonce": 16,
|
|
"recipientNonce": 16,
|
|
"linkId": 16,
|
|
"inviteHash": 32,
|
|
"acceptHash": 32,
|
|
"initialStateHash": 32,
|
|
"stateHash": 32,
|
|
"transcriptHash": 32,
|
|
"sourceDestinationHash": 16,
|
|
"targetDestinationHash": 16,
|
|
}
|
|
ADDRESS_FIELDS = {"requesterAddress", "recipientAddress", "responderAddress"}
|
|
|
|
ACTIVE_TYPES = {
|
|
"MOVE",
|
|
"MOVE_ACK",
|
|
"MATCH_PING",
|
|
"MATCH_PONG",
|
|
"RESIGN",
|
|
"RESIGN_ACK",
|
|
"GAME_OVER",
|
|
"GAME_OVER_ACK",
|
|
"SYNC_REQUEST",
|
|
"SYNC_MOVE",
|
|
"START_ACK",
|
|
"PROTOCOL_ERROR",
|
|
"ROUND_REQUEST",
|
|
"ROUND_RESPONSE",
|
|
"ROUND_CANCEL",
|
|
"CHAT_MESSAGE",
|
|
"CHAT_CHUNK",
|
|
"CHAT_ACK",
|
|
"CHAT_TYPING",
|
|
}
|
|
|
|
ROUND_PHASES = {"session_idle", "round_waiting", "round_incoming"}
|
|
ROUND_BOUND_TYPES = {"MOVE", "MOVE_ACK", "RESIGN", "RESIGN_ACK", "GAME_OVER", "GAME_OVER_ACK", "SYNC_REQUEST", "SYNC_MOVE"}
|
|
CHAT_MAX_CHARS = 500
|
|
CHAT_MAX_BYTES = 2000
|
|
CHAT_CHUNK_BYTES = 180
|
|
CHAT_HISTORY_LIMIT = 100
|
|
GAME_SEND_QUEUE_MAX = 128
|
|
GAME_SEND_FLUSH_BUDGET = 16
|
|
EPHEMERAL_GAME_MESSAGE_TYPES = {"MATCH_PING", "MATCH_PONG", "CHAT_TYPING"}
|
|
|
|
|
|
def _b58decode(value: str) -> bytes:
|
|
if not isinstance(value, str) or not value:
|
|
raise ValueError("invalid base58 value")
|
|
number = 0
|
|
for char in value:
|
|
if char not in BASE58_MAP:
|
|
raise ValueError("invalid base58 character")
|
|
number = number * 58 + BASE58_MAP[char]
|
|
body = number.to_bytes((number.bit_length() + 7) // 8, "big") if number else b""
|
|
return b"\0" * (len(value) - len(value.lstrip("1"))) + body
|
|
|
|
|
|
def _b58encode(value: bytes) -> str:
|
|
number = int.from_bytes(value, "big")
|
|
out = ""
|
|
while number:
|
|
number, remainder = divmod(number, 58)
|
|
out = BASE58_ALPHABET[remainder] + out
|
|
return "1" * (len(value) - len(value.lstrip(b"\0"))) + (out or "")
|
|
|
|
|
|
def derive_qortal_address(public_key_b58: str) -> str:
|
|
public_key = _b58decode(public_key_b58)
|
|
if len(public_key) != 32:
|
|
raise ValueError("invalid public key length")
|
|
sha = hashlib.sha256(public_key).digest()
|
|
ripe = hashlib.new("ripemd160", sha).digest()
|
|
versioned = bytes([ADDRESS_VERSION]) + ripe
|
|
checksum = hashlib.sha256(hashlib.sha256(versioned).digest()).digest()[:4]
|
|
return _b58encode(versioned + checksum)
|
|
|
|
|
|
def canonical_bytes(fields: Dict[str, Any]) -> bytes:
|
|
return json.dumps(fields, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
|
|
|
|
def valid_hex(value: Any, byte_length: int) -> bool:
|
|
if not isinstance(value, str) or len(value) != byte_length * 2:
|
|
return False
|
|
try:
|
|
return len(bytes.fromhex(value)) == byte_length
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def verify_signature(fields: Dict[str, Any], public_key_b58: str, signature_b58: str) -> bool:
|
|
try:
|
|
public_key = _b58decode(public_key_b58)
|
|
signature = _b58decode(signature_b58)
|
|
if len(public_key) != 32 or len(signature) != 64:
|
|
return False
|
|
RNS.Cryptography.Ed25519PublicKey.from_public_bytes(public_key).verify(
|
|
signature, canonical_bytes(fields)
|
|
)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
class GameMessage(MessageBase):
|
|
MSGTYPE = MSGTYPE
|
|
|
|
def __init__(self, payload: Optional[Dict[str, Any]] = None):
|
|
self.payload = payload or {}
|
|
|
|
def pack(self) -> bytes:
|
|
raw = umsgpack.packb(self.payload)
|
|
if len(raw) > MAX_CHANNEL_PAYLOAD:
|
|
raise ValueError("game channel payload exceeds 425 bytes")
|
|
return raw
|
|
|
|
def unpack(self, raw: bytes):
|
|
if len(raw) > MAX_CHANNEL_PAYLOAD:
|
|
raise ValueError("oversized game channel payload")
|
|
value = umsgpack.unpackb(raw)
|
|
if not isinstance(value, dict):
|
|
raise ValueError("game channel payload must be a map")
|
|
self.payload = value
|
|
|
|
|
|
class QortalLandGameManager:
|
|
def __init__(
|
|
self,
|
|
emit: Callable[[str, Dict[str, Any]], None],
|
|
log: Callable[[str], None],
|
|
resolve_peer: Callable[[str, str], Optional[str]],
|
|
resolve_identity: Callable[[str], Any],
|
|
build_destination: Callable[[Any], Any],
|
|
link_id_bytes: Callable[[Any], bytes],
|
|
enqueue: Callable[[Callable[..., Any], tuple], bool],
|
|
refresh_path: Optional[Callable[[str, str], bool]] = None,
|
|
broadcast_proximity: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
enqueue_proximity_media: Optional[Callable[[Callable[..., Any], tuple], bool]] = None,
|
|
resolve_link_peer_hash: Optional[Callable[[Any], str]] = None,
|
|
local_destination_hash: Optional[Callable[[], str]] = None,
|
|
identify_link: Optional[Callable[[Any], None]] = None,
|
|
path_available: Optional[Callable[[bytes], bool]] = None,
|
|
):
|
|
self.emit = emit
|
|
self.log = log
|
|
self.resolve_peer = resolve_peer
|
|
self.resolve_identity = resolve_identity
|
|
self.build_destination = build_destination
|
|
self.link_id_bytes = link_id_bytes
|
|
self.enqueue = enqueue
|
|
self.refresh_path = refresh_path
|
|
self.resolve_link_peer_hash = resolve_link_peer_hash
|
|
self.local_destination_hash = local_destination_hash
|
|
self.identify_link = identify_link
|
|
self.path_available = path_available or RNS.Transport.has_path
|
|
self.lock = threading.RLock()
|
|
self.land_context: Optional[Dict[str, Any]] = None
|
|
self.matches: Dict[str, Dict[str, Any]] = {}
|
|
self.links_by_object: Dict[int, str] = {}
|
|
self.used_nonces: Dict[str, float] = {}
|
|
self.signature_challenges: Dict[str, Dict[str, Any]] = {}
|
|
self.socket = None
|
|
self.socket_lock = threading.Lock()
|
|
self.socket_send_lock = threading.Lock()
|
|
self.socket_out: "queue.Queue[Optional[tuple[Any, Dict[str, Any]]]]" = queue.Queue(maxsize=256)
|
|
self.socket_media_out: "deque[tuple[Any, bytes, int, float]]" = deque()
|
|
self.socket_media_lock = threading.Lock()
|
|
self.socket_writer_wakeup = threading.Event()
|
|
self.server = None
|
|
self.server_thread: Optional[threading.Thread] = None
|
|
self.monitor_thread: Optional[threading.Thread] = None
|
|
self.stop_event = threading.Event()
|
|
self.token = os.environ.get("QORTAL_LAND_REALTIME_TOKEN") or os.environ.get("QORTAL_LAND_GAMES_TOKEN", "")
|
|
self.instance_id = os.environ.get("QORTAL_LAND_REALTIME_INSTANCE_ID") or os.environ.get("QORTAL_LAND_GAMES_INSTANCE_ID", "")
|
|
self.development = (os.environ.get("QORTAL_LAND_REALTIME_DEV") or os.environ.get("QORTAL_LAND_GAMES_DEV", "0")) == "1"
|
|
self.proximity = QortalLandProximityVoiceManager(
|
|
emit=self.send_event,
|
|
send_binary=self.send_binary,
|
|
log=log,
|
|
resolve_peer=resolve_peer,
|
|
resolve_identity=resolve_identity,
|
|
build_destination=build_destination,
|
|
link_id_bytes=link_id_bytes,
|
|
enqueue=enqueue,
|
|
enqueue_media=enqueue_proximity_media,
|
|
broadcast_discovery=broadcast_proximity or (lambda _wire: None),
|
|
verify_wallet=verify_signature,
|
|
derive_address=derive_qortal_address,
|
|
decode_base58=_b58decode,
|
|
resolve_link_peer_hash=resolve_link_peer_hash,
|
|
identify_link=identify_link,
|
|
path_available=self.path_available,
|
|
refresh_path=self.refresh_path,
|
|
)
|
|
|
|
def start_server(self) -> Optional[int]:
|
|
if self.server_thread and self.server_thread.is_alive():
|
|
return None
|
|
if serve is None or not self.token or not self.instance_id:
|
|
self.log("[qortalland-game] websocket unavailable: dependency or bootstrap missing")
|
|
return None
|
|
self.stop_event.clear()
|
|
self.socket_writer_wakeup.clear()
|
|
ready: "queue.Queue[int]" = queue.Queue(maxsize=1)
|
|
|
|
def run() -> None:
|
|
try:
|
|
with serve(
|
|
self._socket_handler,
|
|
"127.0.0.1",
|
|
0,
|
|
max_size=MAX_LOCAL_FRAME,
|
|
max_queue=128,
|
|
origins=None,
|
|
) as server:
|
|
self.server = server
|
|
ready.put(server.socket.getsockname()[1])
|
|
server.serve_forever()
|
|
except Exception as exc:
|
|
self.log(f"[qortalland-game] websocket failed code=server_error err={str(exc)[:120]}")
|
|
finally:
|
|
self.server = None
|
|
|
|
self.server_thread = threading.Thread(target=run, name="qortalland-game-ws", daemon=True)
|
|
self.server_thread.start()
|
|
try:
|
|
port = ready.get(timeout=5)
|
|
except queue.Empty:
|
|
return None
|
|
self.monitor_thread = threading.Thread(target=self._monitor, name="qortalland-game-monitor", daemon=True)
|
|
self.monitor_thread.start()
|
|
threading.Thread(target=self._socket_writer, name="qortalland-game-ws-writer", daemon=True).start()
|
|
return port
|
|
|
|
def stop(self) -> None:
|
|
self.stop_event.set()
|
|
self.socket_writer_wakeup.set()
|
|
if self.server is not None:
|
|
try:
|
|
self.server.shutdown()
|
|
except Exception:
|
|
pass
|
|
with self.socket_lock:
|
|
socket_client = self.socket
|
|
self.socket = None
|
|
if socket_client is not None:
|
|
try:
|
|
socket_client.close(1001, "bridge stopping")
|
|
except Exception:
|
|
pass
|
|
with self.lock:
|
|
states = list(self.matches.values())
|
|
self.matches.clear()
|
|
self.links_by_object.clear()
|
|
for state in states:
|
|
self._teardown(state.get("link"))
|
|
self.proximity.disable("bridge_stopping")
|
|
|
|
def _origin_allowed(self, origin: Optional[str]) -> bool:
|
|
if origin == "capacitor-electron://-":
|
|
return True
|
|
if self.development and isinstance(origin, str):
|
|
try:
|
|
parsed = urllib.parse.urlparse(origin)
|
|
port = parsed.port
|
|
return (
|
|
parsed.scheme in {"http", "https"}
|
|
and parsed.hostname in {"127.0.0.1", "localhost"}
|
|
and isinstance(port, int)
|
|
and 0 < port <= 65535
|
|
and parsed.username is None
|
|
and parsed.password is None
|
|
and parsed.path in {"", "/"}
|
|
and not parsed.params
|
|
and not parsed.query
|
|
and not parsed.fragment
|
|
)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return False
|
|
|
|
def _socket_handler(self, websocket) -> None:
|
|
origin = websocket.request.headers.get("Origin")
|
|
if not self._origin_allowed(origin):
|
|
websocket.close(1008, "invalid origin")
|
|
return
|
|
try:
|
|
raw = websocket.recv(timeout=AUTH_TIMEOUT)
|
|
auth = json.loads(raw) if isinstance(raw, str) else None
|
|
except Exception:
|
|
websocket.close(1008, "authentication required")
|
|
return
|
|
if (
|
|
not isinstance(auth, dict)
|
|
or set(auth.keys()) != {"type", "token", "instanceId"}
|
|
or auth.get("type") != "AUTH"
|
|
or auth.get("instanceId") != self.instance_id
|
|
or not secrets.compare_digest(str(auth.get("token") or ""), self.token)
|
|
):
|
|
websocket.close(1008, "authentication failed")
|
|
return
|
|
with self.socket_lock:
|
|
previous = self.socket
|
|
self.socket = websocket
|
|
if previous is not None and previous is not websocket:
|
|
try:
|
|
previous.close(1008, "application socket replaced")
|
|
except Exception:
|
|
pass
|
|
with self.socket_media_lock:
|
|
self.socket_media_out.clear()
|
|
if not self._send_direct(websocket, {"type": "TRANSPORT_STATE", "state": "ready", "instanceId": self.instance_id}):
|
|
with self.socket_lock:
|
|
if self.socket is websocket:
|
|
self.socket = None
|
|
websocket.close(1013, "transport ready delivery failed")
|
|
return
|
|
if not self.enqueue(self._proximity_renderer_connected, ()):
|
|
self.send_event("PROXIMITY_ERROR", {"code": "control_queue_full"})
|
|
with self.lock:
|
|
for state in self.matches.values():
|
|
state.pop("renderer_lost_at", None)
|
|
snapshot = self._active_snapshot()
|
|
if snapshot:
|
|
self._send_direct(websocket, {"type": "GAME_SNAPSHOT", **snapshot})
|
|
for history in self._chat_history_batches(snapshot["matchId"]):
|
|
self._send_direct(websocket, history)
|
|
state = self.matches.get(str(snapshot.get("matchId") or ""))
|
|
if state:
|
|
for pending_move in list(state.get("pendingInboundMoves", {}).values()):
|
|
self._send_direct(
|
|
websocket,
|
|
{
|
|
"type": "GAME_MESSAGE",
|
|
"matchId": state["matchId"],
|
|
"message": pending_move,
|
|
},
|
|
)
|
|
with self.lock:
|
|
pending_challenges = list(self.signature_challenges.items())
|
|
for challenge_id, challenge in pending_challenges:
|
|
self._send_direct(
|
|
websocket,
|
|
{
|
|
"type": "SIGNATURE_REQUIRED",
|
|
"challengeId": challenge_id,
|
|
"matchId": challenge.get("matchId"),
|
|
"handshakeType": challenge.get("kind"),
|
|
"fields": challenge.get("fields"),
|
|
},
|
|
)
|
|
try:
|
|
for raw in websocket:
|
|
if isinstance(raw, bytes):
|
|
if len(raw) > 2 * 1024 or not self.proximity.queue_local_audio(raw):
|
|
websocket.close(1009, "invalid media frame")
|
|
break
|
|
continue
|
|
if not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_LOCAL_FRAME:
|
|
websocket.close(1009, "frame too large")
|
|
break
|
|
try:
|
|
message = json.loads(raw)
|
|
except Exception:
|
|
websocket.close(1007, "invalid json")
|
|
break
|
|
if not isinstance(message, dict) or message.get("type") not in COMMANDS:
|
|
self._command_result(message.get("requestId") if isinstance(message, dict) else None, False, "invalid_command")
|
|
continue
|
|
if message.get("type") == "UPDATE_PROXIMITY_POSITION":
|
|
self.proximity.queue_position_command(message, self._command_result)
|
|
continue
|
|
if not self.enqueue(self.handle_command, (message,)):
|
|
self._command_result(message.get("requestId"), False, "command_queue_full")
|
|
websocket.close(1013, "command queue full")
|
|
break
|
|
finally:
|
|
lost_current_socket = False
|
|
with self.socket_lock:
|
|
if self.socket is websocket:
|
|
self.socket = None
|
|
lost_current_socket = True
|
|
if lost_current_socket:
|
|
now = time.time()
|
|
if not self.enqueue(self.proximity.renderer_lost, ()):
|
|
self.proximity.renderer_lost_at = now
|
|
self.proximity.transmitting = False
|
|
with self.lock:
|
|
for state in self.matches.values():
|
|
if state.get("phase") in {"active", "ending", "session_idle", "round_waiting", "round_incoming"}:
|
|
state["renderer_lost_at"] = now
|
|
|
|
def _send_direct(self, websocket, event: Dict[str, Any]) -> bool:
|
|
try:
|
|
with self.socket_send_lock:
|
|
websocket.send(json.dumps(event, separators=(",", ":")))
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def send_event(self, event_type: str, payload: Optional[Dict[str, Any]] = None) -> None:
|
|
event = {"type": event_type, **(payload or {})}
|
|
with self.socket_lock:
|
|
socket_client = self.socket
|
|
if socket_client is not None:
|
|
try:
|
|
self.socket_out.put_nowait((socket_client, event))
|
|
self.socket_writer_wakeup.set()
|
|
except queue.Full:
|
|
try:
|
|
socket_client.close(1013, "event queue stalled")
|
|
except Exception:
|
|
pass
|
|
|
|
def send_binary(self, frame: bytes, source_id: int) -> bool:
|
|
with self.socket_lock:
|
|
socket_client = self.socket
|
|
if socket_client is None:
|
|
return False
|
|
with self.socket_media_lock:
|
|
source_count = sum(
|
|
1 for _socket, _frame, queued_source, _queued_at in self.socket_media_out
|
|
if queued_source == source_id
|
|
)
|
|
if source_count >= 8 or (len(self.socket_media_out) >= 128 and source_count > 0):
|
|
for index, (_socket, _frame, queued_source, _queued_at) in enumerate(self.socket_media_out):
|
|
if queued_source == source_id:
|
|
del self.socket_media_out[index]
|
|
self.proximity.stats["queueDrops"] += 1
|
|
self.proximity.stats["rendererQueueDrops"] += 1
|
|
break
|
|
elif len(self.socket_media_out) >= 128:
|
|
self.socket_media_out.popleft()
|
|
self.proximity.stats["queueDrops"] += 1
|
|
self.proximity.stats["rendererQueueDrops"] += 1
|
|
self.socket_media_out.append((socket_client, bytes(frame), source_id, time.monotonic()))
|
|
self.socket_writer_wakeup.set()
|
|
return True
|
|
|
|
def _socket_writer(self) -> None:
|
|
control_burst = 0
|
|
while not self.stop_event.is_set():
|
|
item = None
|
|
if control_burst < 8:
|
|
try:
|
|
item = self.socket_out.get_nowait()
|
|
except queue.Empty:
|
|
pass
|
|
if item is not None:
|
|
socket_client, event = item
|
|
with self.socket_lock:
|
|
current = self.socket
|
|
if socket_client is current and not self._send_direct(socket_client, event):
|
|
try:
|
|
socket_client.close(1011, "event delivery failed")
|
|
except Exception:
|
|
pass
|
|
control_burst += 1
|
|
continue
|
|
with self.socket_media_lock:
|
|
media_item = self.socket_media_out.popleft() if self.socket_media_out else None
|
|
if media_item is None:
|
|
control_burst = 0
|
|
if not self.socket_out.empty():
|
|
continue
|
|
self.socket_writer_wakeup.wait(0.02)
|
|
self.socket_writer_wakeup.clear()
|
|
continue
|
|
socket_client, frame, _source_id, queued_at = media_item
|
|
control_burst = 0
|
|
if time.monotonic() - queued_at > 0.2:
|
|
continue
|
|
with self.socket_lock:
|
|
current = self.socket
|
|
if socket_client is current:
|
|
try:
|
|
with self.socket_send_lock:
|
|
socket_client.send(frame)
|
|
except Exception:
|
|
try:
|
|
socket_client.close(1011, "media delivery failed")
|
|
except Exception:
|
|
pass
|
|
|
|
def _proximity_renderer_connected(self) -> None:
|
|
self.proximity.renderer_connected()
|
|
self.proximity._emit_snapshot()
|
|
|
|
def _command_result(self, request_id: Any, ok: bool, error: str = "", payload: Optional[Dict[str, Any]] = None) -> None:
|
|
event: Dict[str, Any] = {"requestId": str(request_id or ""), "ok": ok}
|
|
if error:
|
|
event["error"] = error
|
|
if payload:
|
|
event["payload"] = payload
|
|
self.send_event("COMMAND_RESULT", event)
|
|
|
|
def handle_command(self, message: Dict[str, Any]) -> None:
|
|
command = message.get("type")
|
|
request_id = message.get("requestId")
|
|
try:
|
|
if command in PROXIMITY_COMMANDS:
|
|
self.proximity.handle_command(message, self._command_result)
|
|
return
|
|
if command == "SET_LAND_CONTEXT":
|
|
self._set_context(message)
|
|
elif command == "CLEAR_LAND_CONTEXT":
|
|
self._clear_context("land_context_cleared")
|
|
elif command == "OPEN_GAME_LINK":
|
|
self._open(message)
|
|
elif command == "SUBMIT_HANDSHAKE_SIGNATURE":
|
|
self._submit_signature(message)
|
|
elif command == "RESPOND_TO_INVITE":
|
|
self._respond(message)
|
|
elif command == "SEND_GAME_MESSAGE":
|
|
self._send_active(message)
|
|
elif command == "RESIGN_GAME":
|
|
self._send_active({**message, "message": {"type": "RESIGN", "messageId": str(uuid.uuid4())}})
|
|
elif command == "CLOSE_GAME_LINK":
|
|
self._cancel_or_close_match(
|
|
str(message.get("matchId") or ""),
|
|
completed=message.get("completed") is True,
|
|
)
|
|
elif command == "GET_ACTIVE_MATCH":
|
|
snapshot = self._active_snapshot()
|
|
if snapshot:
|
|
self.send_event("GAME_SNAPSHOT", snapshot)
|
|
for history in self._chat_history_batches(snapshot["matchId"]):
|
|
self.send_event("GAME_CHAT_HISTORY", {key: value for key, value in history.items() if key != "type"})
|
|
self._command_result(request_id, True)
|
|
except Exception as exc:
|
|
self._command_result(request_id, False, str(exc)[:160])
|
|
|
|
def _set_context(self, message: Dict[str, Any]) -> None:
|
|
required = (
|
|
"address", "publicKey", "groupId", "landSessionId", "roomId",
|
|
"localDestinationHash",
|
|
)
|
|
context = {key: str(message.get(key) or "").strip() for key in required}
|
|
if any(not context[key] for key in required):
|
|
raise ValueError("incomplete_land_context")
|
|
if derive_qortal_address(context["publicKey"]) != context["address"]:
|
|
raise ValueError("land_identity_mismatch")
|
|
if (
|
|
len(context["landSessionId"]) > 16
|
|
or len(context["localDestinationHash"]) != 32
|
|
or any(char not in "0123456789abcdef" for char in context["localDestinationHash"].lower())
|
|
or self.local_destination_hash is None
|
|
or context["localDestinationHash"].lower() != str(self.local_destination_hash() or "").lower()
|
|
):
|
|
raise ValueError("land_endpoint_mismatch")
|
|
context["localDestinationHash"] = context["localDestinationHash"].lower()
|
|
with self.lock:
|
|
previous = dict(self.land_context or {})
|
|
changed_session = bool(previous) and any(
|
|
previous.get(key) != context.get(key)
|
|
for key in ("address", "publicKey", "groupId", "landSessionId")
|
|
)
|
|
match_ids = list(self.matches) if changed_session else []
|
|
for match_id in match_ids:
|
|
self._close_match(match_id, "land_context_changed")
|
|
with self.lock:
|
|
self.land_context = context
|
|
self.proximity.set_context({**context, "instanceId": self.instance_id})
|
|
|
|
def _clear_context(self, reason: str) -> None:
|
|
with self.lock:
|
|
self.land_context = None
|
|
match_ids = list(self.matches)
|
|
for match_id in match_ids:
|
|
self._close_match(match_id, reason)
|
|
self.proximity.clear_context()
|
|
|
|
def _busy(self, except_match: str = "") -> bool:
|
|
return any(mid != except_match and state.get("phase") not in {"ended", "closed"} for mid, state in self.matches.items())
|
|
|
|
def _open(self, message: Dict[str, Any]) -> None:
|
|
with self.lock:
|
|
context = dict(self.land_context or {})
|
|
if not context:
|
|
raise ValueError("land_context_required")
|
|
if self._busy():
|
|
raise ValueError("game_busy")
|
|
match_id = str(message.get("matchId") or "")
|
|
recipient = str(message.get("recipientAddress") or "").strip()
|
|
target_session_id = str(message.get("targetSessionId") or "").strip()
|
|
target_destination_hash = str(message.get("targetDestinationHash") or "").strip().lower()
|
|
nonce = str(message.get("requesterNonce") or "").strip().lower()
|
|
game = str(message.get("game") or "connect-four")
|
|
config = GAME_CONFIGS.get(game)
|
|
if not config:
|
|
raise ValueError("unsupported_game")
|
|
uuid.UUID(match_id)
|
|
if (
|
|
len(bytes.fromhex(nonce)) != 16
|
|
or not recipient
|
|
or recipient == context["address"]
|
|
or not target_session_id
|
|
or len(target_session_id) > 16
|
|
or len(target_destination_hash) != 32
|
|
or any(char not in "0123456789abcdef" for char in target_destination_hash)
|
|
):
|
|
raise ValueError("invalid_game_invitation")
|
|
# The selected endpoint comes from the account-signed Land session
|
|
# route. The general presence peer cache is only a useful fast-path;
|
|
# it can legitimately be asymmetric or lag behind Land state. The
|
|
# remote endpoint still has to prove the exact target session and the
|
|
# resulting link is cryptographically bound during the handshake.
|
|
peer_hash = (
|
|
self.resolve_peer(recipient, target_destination_hash)
|
|
or target_destination_hash
|
|
)
|
|
state = {
|
|
"matchId": match_id,
|
|
"roundId": match_id,
|
|
"requester": context["address"],
|
|
"recipient": recipient,
|
|
"sourceSessionId": context["landSessionId"],
|
|
"targetSessionId": target_session_id,
|
|
"sourceDestinationHash": context["localDestinationHash"],
|
|
"targetDestinationHash": target_destination_hash,
|
|
"requesterPublicKey": context["publicKey"],
|
|
"requesterNonce": nonce,
|
|
"groupId": context["groupId"],
|
|
"game": game,
|
|
"gameVersion": config["gameVersion"],
|
|
"rulesVersion": config["rulesVersion"],
|
|
"peerHash": peer_hash,
|
|
"phase": "establishing",
|
|
"outbound": True,
|
|
"establishDeadline": time.time() + LINK_TIMEOUT,
|
|
"openAttempts": 0,
|
|
"createdAt": int(time.time() * 1000),
|
|
"expiresAt": int((time.time() + INVITE_TTL) * 1000),
|
|
"transcript": [],
|
|
"lastActivity": time.time(),
|
|
"lastRx": time.time(),
|
|
"initialStateHash": str(message.get("initialStateHash") or ""),
|
|
}
|
|
with self.lock:
|
|
self.matches[match_id] = state
|
|
timer = threading.Timer(LINK_TIMEOUT, self._queue_establish_timeout, args=(match_id,))
|
|
timer.daemon = True
|
|
state["establishTimer"] = timer
|
|
timer.start()
|
|
self.send_event("GAME_LINK_STATE", {"matchId": match_id, "state": "establishing"})
|
|
self.log(
|
|
f"[qortalland-game] link opening match={match_id[:8]} peer={str(peer_hash)[:8]}"
|
|
)
|
|
self._attempt_open(match_id)
|
|
|
|
def _queue_establish_timeout(self, match_id: str) -> None:
|
|
self.enqueue(self._establish_timeout, (match_id,))
|
|
|
|
def _schedule_open_retry(self, match_id: str, delay: float = 1.0) -> None:
|
|
state = self.matches.get(match_id)
|
|
if not state or state.get("phase") != "establishing" or state.get("openRetryTimer"):
|
|
return
|
|
|
|
def fire() -> None:
|
|
current = self.matches.get(match_id)
|
|
if current:
|
|
current.pop("openRetryTimer", None)
|
|
self.enqueue(self._attempt_open, (match_id,))
|
|
|
|
timer = threading.Timer(delay, fire)
|
|
timer.daemon = True
|
|
state["openRetryTimer"] = timer
|
|
timer.start()
|
|
|
|
def _attempt_open(self, match_id: str) -> None:
|
|
state = self.matches.get(match_id)
|
|
if (
|
|
not state
|
|
or state.get("phase") != "establishing"
|
|
or state.get("link") is not None
|
|
or time.time() >= float(state.get("establishDeadline") or 0)
|
|
):
|
|
return
|
|
try:
|
|
identity = self.resolve_identity(state["peerHash"])
|
|
if identity is None:
|
|
refreshed = False
|
|
if self.refresh_path is not None:
|
|
try:
|
|
refreshed = self.refresh_path(
|
|
state["peerHash"],
|
|
"game_identity_unavailable",
|
|
) is True
|
|
except Exception as exc:
|
|
self.log(
|
|
f"[qortalland-game] identity refresh failed match={match_id[:8]} "
|
|
f"peer={str(state['peerHash'])[:8]} code={str(exc)[:80]}"
|
|
)
|
|
if not refreshed:
|
|
RNS.Transport.request_path(bytes.fromhex(state["peerHash"]))
|
|
self.log(
|
|
f"[qortalland-game] identity refresh requested match={match_id[:8]} "
|
|
f"peer={str(state['peerHash'])[:8]} hard={str(self.refresh_path is not None).lower()}"
|
|
)
|
|
self._schedule_open_retry(match_id)
|
|
return
|
|
destination = self.build_destination(identity)
|
|
destination_hash = bytes(destination.hash)
|
|
if destination_hash.hex() != str(state["peerHash"]).lower():
|
|
raise ValueError("recipient_destination_mismatch")
|
|
if not self.path_available(destination_hash):
|
|
refreshed = False
|
|
if self.refresh_path is not None:
|
|
try:
|
|
refreshed = self.refresh_path(
|
|
state["peerHash"],
|
|
"game_link_no_path",
|
|
) is True
|
|
except Exception as exc:
|
|
self.log(
|
|
f"[qortalland-game] path refresh failed match={match_id[:8]} "
|
|
f"peer={str(state['peerHash'])[:8]} code={str(exc)[:80]}"
|
|
)
|
|
if not refreshed:
|
|
RNS.Transport.request_path(destination_hash)
|
|
self.log(
|
|
f"[qortalland-game] path refresh requested match={match_id[:8]} "
|
|
f"peer={str(state['peerHash'])[:8]} hard={str(self.refresh_path is not None).lower()}"
|
|
)
|
|
self._schedule_open_retry(match_id)
|
|
return
|
|
state["openAttempts"] = int(state.get("openAttempts") or 0) + 1
|
|
link = RNS.Link(
|
|
destination,
|
|
established_callback=self._outbound_established,
|
|
closed_callback=self._link_closed,
|
|
)
|
|
state["link"] = link
|
|
with self.lock:
|
|
self.links_by_object[id(link)] = match_id
|
|
self.log(
|
|
f"[qortalland-game] link attempt match={match_id[:8]} peer={str(state['peerHash'])[:8]} attempt={state['openAttempts']}"
|
|
)
|
|
except Exception as exc:
|
|
self.log(
|
|
f"[qortalland-game] link attempt failed match={match_id[:8]} code={str(exc)[:80]}"
|
|
)
|
|
self._schedule_open_retry(match_id)
|
|
|
|
def _outbound_established(self, link) -> None:
|
|
state = self._state_for_link(link)
|
|
if not state:
|
|
return
|
|
timer = state.pop("establishTimer", None)
|
|
if timer:
|
|
timer.cancel()
|
|
retry_timer = state.pop("openRetryTimer", None)
|
|
if retry_timer:
|
|
retry_timer.cancel()
|
|
self.log(
|
|
f"[qortalland-game] link established match={state['matchId'][:8]} peer={str(state.get('peerHash') or '')[:8]} attempt={int(state.get('openAttempts') or 0)}"
|
|
)
|
|
if not self._identify_outbound_link(state, link):
|
|
return
|
|
self._configure_channel(state)
|
|
state["phase"] = "awaiting_invite_signature"
|
|
state["linkId"] = self.link_id_bytes(link).hex()
|
|
fields = self._invite_fields(state)
|
|
self._require_signature(state, "QORTAL_LAND_GAME_INVITE", fields)
|
|
self.send_event("GAME_LINK_STATE", {"matchId": state["matchId"], "state": "established"})
|
|
|
|
def _invite_fields(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
return {
|
|
"type": "QORTAL_LAND_GAME_INVITE",
|
|
"protocolVersion": PROTOCOL_VERSION,
|
|
"game": state["game"],
|
|
"gameVersion": state["gameVersion"],
|
|
"rulesVersion": state["rulesVersion"],
|
|
"matchId": state["matchId"],
|
|
"groupId": state["groupId"],
|
|
"requesterAddress": state["requester"],
|
|
"recipientAddress": state["recipient"],
|
|
"sourceSessionId": state["sourceSessionId"],
|
|
"targetSessionId": state["targetSessionId"],
|
|
"sourceDestinationHash": state["sourceDestinationHash"],
|
|
"targetDestinationHash": state["targetDestinationHash"],
|
|
"signerPublicKey": state["requesterPublicKey"],
|
|
"requesterNonce": state["requesterNonce"],
|
|
"linkId": state["linkId"],
|
|
"createdAt": state["createdAt"],
|
|
"expiresAt": state["expiresAt"],
|
|
}
|
|
|
|
def _require_signature(self, state: Dict[str, Any], kind: str, fields: Dict[str, Any]) -> None:
|
|
challenge_id = str(uuid.uuid4())
|
|
self.signature_challenges[challenge_id] = {"matchId": state["matchId"], "kind": kind, "fields": fields, "created": time.time()}
|
|
self.send_event("SIGNATURE_REQUIRED", {"challengeId": challenge_id, "matchId": state["matchId"], "handshakeType": kind, "fields": fields})
|
|
|
|
def _submit_signature(self, message: Dict[str, Any]) -> None:
|
|
challenge_id = str(message.get("challengeId") or "")
|
|
challenge = self.signature_challenges.get(challenge_id)
|
|
if not challenge or time.time() - challenge["created"] > INVITE_TTL:
|
|
self.signature_challenges.pop(challenge_id, None)
|
|
raise ValueError("signature_challenge_expired")
|
|
public_key = str(message.get("publicKey") or "")
|
|
signature = str(message.get("signature") or "")
|
|
fields = challenge["fields"]
|
|
if fields.get("signerPublicKey") != public_key or not verify_signature(fields, public_key, signature):
|
|
raise ValueError("invalid_handshake_signature")
|
|
signer = derive_qortal_address(public_key)
|
|
expected = (
|
|
fields.get("requesterAddress")
|
|
if challenge["kind"] in {
|
|
"QORTAL_LAND_GAME_INVITE",
|
|
"QORTAL_LAND_GAME_CONFIRM",
|
|
"QORTAL_LAND_GAME_RESUME_REQUEST",
|
|
"QORTAL_LAND_GAME_RESUME_CONFIRM",
|
|
}
|
|
else fields.get("responderAddress")
|
|
)
|
|
if signer != expected:
|
|
raise ValueError("handshake_signer_mismatch")
|
|
self.signature_challenges.pop(challenge_id, None)
|
|
state = self.matches.get(challenge["matchId"])
|
|
if not state:
|
|
raise ValueError("match_not_found")
|
|
envelope = {"fields": fields, "publicKey": public_key, "signature": signature}
|
|
kind = challenge["kind"]
|
|
if kind == "QORTAL_LAND_GAME_INVITE":
|
|
raw = MAGIC + umsgpack.packb(self._encode_handshake(envelope))
|
|
if len(raw) > MAX_CHANNEL_PAYLOAD:
|
|
raise ValueError("invite_payload_too_large")
|
|
if not self._send_raw(state["link"], raw):
|
|
raise ValueError("invite_send_failed")
|
|
state["inviteEnvelope"] = envelope
|
|
state["inviteHash"] = hashlib.sha256(canonical_bytes(envelope)).hexdigest()
|
|
state["phase"] = "awaiting_response"
|
|
self.send_event("GAME_LINK_STATE", {"matchId": state["matchId"], "state": "waiting_response"})
|
|
elif kind == "QORTAL_LAND_GAME_RESUME_REQUEST":
|
|
raw = MAGIC + umsgpack.packb(self._encode_handshake(envelope))
|
|
if len(raw) > MAX_CHANNEL_PAYLOAD or not self._send_raw(state["link"], raw):
|
|
raise ValueError("resume_send_failed")
|
|
state["phase"] = "awaiting_resume_accept"
|
|
else:
|
|
self._send_channel(state, {"k": "handshake", "e": self._encode_handshake(envelope)})
|
|
if kind.endswith("DECLINE"):
|
|
close_timer = threading.Timer(
|
|
3,
|
|
self._close_match,
|
|
args=(state["matchId"], "declined"),
|
|
)
|
|
close_timer.daemon = True
|
|
close_timer.start()
|
|
elif kind == "QORTAL_LAND_GAME_ACCEPT":
|
|
state["acceptEnvelope"] = envelope
|
|
state["phase"] = "awaiting_confirm"
|
|
elif kind == "QORTAL_LAND_GAME_RESUME_ACCEPT":
|
|
state["phase"] = "awaiting_resume_confirm"
|
|
elif kind.endswith("CONFIRM"):
|
|
state["phase"] = "awaiting_start_ack"
|
|
|
|
def _encode_handshake(self, envelope: Dict[str, Any]) -> Dict[str, Any]:
|
|
# Field names are compacted by position; canonical verification rebuilds the exact object.
|
|
fields = envelope["fields"]
|
|
kind = str(fields.get("type") or "")
|
|
order = HANDSHAKE_FIELD_ORDER.get(kind)
|
|
if order is None or set(fields) != set(order):
|
|
raise ValueError("invalid_handshake_schema")
|
|
values = []
|
|
for key in order:
|
|
if key in {"type", "signerPublicKey"}:
|
|
continue
|
|
value = fields[key]
|
|
if key in {"matchId", "roundId"}:
|
|
value = uuid.UUID(str(value)).bytes
|
|
elif key in HEX_FIELD_LENGTHS:
|
|
value = bytes.fromhex(str(value))
|
|
expected = HEX_FIELD_LENGTHS[key]
|
|
if expected is not None and len(value) != expected:
|
|
raise ValueError("invalid_handshake_binary_field")
|
|
elif key in ADDRESS_FIELDS:
|
|
value = _b58decode(str(value))
|
|
values.append(value)
|
|
return {"t": HANDSHAKE_TYPE_CODES[kind], "v": values, "p": _b58decode(envelope["publicKey"]), "s": _b58decode(envelope["signature"])}
|
|
|
|
def _decode_handshake(self, packed: Dict[str, Any]) -> Dict[str, Any]:
|
|
kind = HANDSHAKE_TYPES_BY_CODE.get(packed.get("t"), "")
|
|
order = HANDSHAKE_FIELD_ORDER.get(kind)
|
|
values = packed.get("v")
|
|
wire_order = [key for key in (order or ()) if key not in {"type", "signerPublicKey"}]
|
|
if order is None or not isinstance(values, list) or len(values) != len(wire_order):
|
|
raise ValueError("invalid_handshake_fields")
|
|
public_key = _b58encode(bytes(packed.get("p") or b""))
|
|
fields: Dict[str, Any] = {"type": kind, "signerPublicKey": public_key}
|
|
for key, value in zip(wire_order, values):
|
|
if key in {"matchId", "roundId"}:
|
|
value = str(uuid.UUID(bytes=bytes(value)))
|
|
elif key in HEX_FIELD_LENGTHS:
|
|
value = bytes(value).hex()
|
|
elif key in ADDRESS_FIELDS:
|
|
value = _b58encode(bytes(value))
|
|
fields[key] = value
|
|
return {"fields": fields, "publicKey": public_key, "signature": _b58encode(bytes(packed.get("s") or b""))}
|
|
|
|
def handle_classifier(self, link, raw: bytes) -> bool:
|
|
if not isinstance(raw, (bytes, bytearray)) or not bytes(raw).startswith(MAGIC):
|
|
return False
|
|
if len(raw) > MAX_CHANNEL_PAYLOAD:
|
|
self._teardown(link)
|
|
return True
|
|
try:
|
|
envelope = self._decode_handshake(umsgpack.unpackb(bytes(raw)[len(MAGIC):]))
|
|
fields = envelope["fields"]
|
|
if fields.get("type") == "QORTAL_LAND_GAME_RESUME_REQUEST":
|
|
self._adopt_resume_link(link, envelope)
|
|
return True
|
|
if fields.get("linkId") != self.link_id_bytes(link).hex():
|
|
raise ValueError("wrong_link_identifier")
|
|
self._validate_invite(fields, envelope, link)
|
|
match_id = fields["matchId"]
|
|
with self.lock:
|
|
context = dict(self.land_context or {})
|
|
existing = next((s for s in self.matches.values() if s.get("phase") not in {"closed", "ended"}), None)
|
|
if existing:
|
|
# Deterministically resolve crossed invitations.
|
|
if existing.get("outbound") and uuid.UUID(match_id).bytes < uuid.UUID(existing["matchId"]).bytes:
|
|
self._close_match(existing["matchId"], "superseded")
|
|
else:
|
|
self._prepare_signed_busy_decline(
|
|
link,
|
|
envelope,
|
|
"superseded" if existing.get("outbound") else "busy",
|
|
)
|
|
return True
|
|
state = {
|
|
"matchId": match_id,
|
|
"roundId": match_id,
|
|
"requester": fields["requesterAddress"],
|
|
"recipient": fields["recipientAddress"],
|
|
"sourceSessionId": fields["sourceSessionId"],
|
|
"targetSessionId": fields["targetSessionId"],
|
|
"sourceDestinationHash": fields["sourceDestinationHash"],
|
|
"targetDestinationHash": fields["targetDestinationHash"],
|
|
"requesterPublicKey": fields["signerPublicKey"],
|
|
"requesterNonce": fields["requesterNonce"],
|
|
"groupId": fields["groupId"],
|
|
"game": fields["game"],
|
|
"gameVersion": fields["gameVersion"],
|
|
"rulesVersion": fields["rulesVersion"],
|
|
"linkId": fields["linkId"],
|
|
"link": link,
|
|
"outbound": False,
|
|
"phase": "invited",
|
|
"inviteEnvelope": envelope,
|
|
"inviteHash": hashlib.sha256(canonical_bytes(envelope)).hexdigest(),
|
|
"createdAt": fields["createdAt"],
|
|
"expiresAt": fields["expiresAt"],
|
|
"transcript": [],
|
|
"lastActivity": time.time(),
|
|
"lastRx": time.time(),
|
|
}
|
|
with self.lock:
|
|
self.matches[match_id] = state
|
|
self.links_by_object[id(link)] = match_id
|
|
self._configure_channel(state)
|
|
self.send_event(
|
|
"GAME_INVITE_RECEIVED",
|
|
{
|
|
"matchId": match_id,
|
|
"requesterAddress": state["requester"],
|
|
"recipientAddress": state["recipient"],
|
|
"sourceSessionId": state["sourceSessionId"],
|
|
"targetSessionId": state["targetSessionId"],
|
|
"sourceDestinationHash": state["sourceDestinationHash"],
|
|
"targetDestinationHash": state["targetDestinationHash"],
|
|
"requesterNonce": state["requesterNonce"],
|
|
"game": state["game"],
|
|
"gameVersion": state["gameVersion"],
|
|
"rulesVersion": state["rulesVersion"],
|
|
"expiresAt": state["expiresAt"],
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
self.log(f"[qortalland-game] invite rejected code={str(exc)[:80]}")
|
|
self._teardown(link)
|
|
return True
|
|
|
|
def _prepare_signed_busy_decline(self, link, envelope: Dict[str, Any], reason: str) -> None:
|
|
fields = envelope["fields"]
|
|
context = self.land_context
|
|
if not context or self.socket is None:
|
|
self._teardown(link)
|
|
return
|
|
match_id = fields["matchId"]
|
|
state = {
|
|
"matchId": match_id,
|
|
"roundId": match_id,
|
|
"requester": fields["requesterAddress"],
|
|
"recipient": fields["recipientAddress"],
|
|
"sourceSessionId": fields["sourceSessionId"],
|
|
"targetSessionId": fields["targetSessionId"],
|
|
"sourceDestinationHash": fields["sourceDestinationHash"],
|
|
"targetDestinationHash": fields["targetDestinationHash"],
|
|
"requesterPublicKey": fields["signerPublicKey"],
|
|
"requesterNonce": fields["requesterNonce"],
|
|
"groupId": fields["groupId"],
|
|
"game": fields["game"],
|
|
"gameVersion": fields["gameVersion"],
|
|
"rulesVersion": fields["rulesVersion"],
|
|
"linkId": fields["linkId"],
|
|
"link": link,
|
|
"outbound": False,
|
|
"phase": "busy_decline",
|
|
"inviteEnvelope": envelope,
|
|
"inviteHash": hashlib.sha256(canonical_bytes(envelope)).hexdigest(),
|
|
"createdAt": fields["createdAt"],
|
|
"expiresAt": fields["expiresAt"],
|
|
"transcript": [],
|
|
"lastActivity": time.time(),
|
|
"lastRx": time.time(),
|
|
}
|
|
with self.lock:
|
|
self.matches[match_id] = state
|
|
self.links_by_object[id(link)] = match_id
|
|
self._configure_channel(state)
|
|
decline_fields = {
|
|
"type": "QORTAL_LAND_GAME_DECLINE",
|
|
"inviteHash": state["inviteHash"],
|
|
"matchId": match_id,
|
|
"responderAddress": context["address"],
|
|
"signerPublicKey": context["publicKey"],
|
|
"reason": reason,
|
|
"linkId": state["linkId"],
|
|
"createdAt": int(time.time() * 1000),
|
|
}
|
|
self._require_signature(state, decline_fields["type"], decline_fields)
|
|
|
|
def _resume_state_fields(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
return self._transcript_summary(state, len(state.get("transcript") or []))
|
|
|
|
def _transcript_summary(self, state: Dict[str, Any], ply: int) -> Dict[str, Any]:
|
|
transcript = list(state.get("transcript") or [])[:max(0, int(ply))]
|
|
transcript_hash = hashlib.sha256(
|
|
json.dumps(transcript, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
).hexdigest()
|
|
state_hash = (
|
|
str(transcript[-1].get("resultingStateHash") or "")
|
|
if transcript
|
|
else self._initial_state_hash(state)
|
|
)
|
|
return {
|
|
"lastAcknowledgedPly": len(transcript),
|
|
"stateHash": state_hash,
|
|
"transcriptHash": transcript_hash,
|
|
}
|
|
|
|
def _adopt_resume_link(self, link, envelope: Dict[str, Any]) -> None:
|
|
fields = envelope["fields"]
|
|
match_id = str(fields.get("matchId") or "")
|
|
state = self.matches.get(match_id)
|
|
context = self.land_context or {}
|
|
if not state or state.get("phase") != "recovering" or state.get("outbound"):
|
|
raise ValueError("resume_match_unavailable")
|
|
# The signed source hash must equal the identity that owns this exact
|
|
# Reticulum link. Do not additionally depend on the global presence
|
|
# address cache, which may lag or retain another account for the node.
|
|
if (
|
|
fields.get("requesterAddress") != state["requester"]
|
|
or fields.get("roundId") != (state.get("roundId") or state["matchId"])
|
|
or context.get("address") != state["recipient"]
|
|
or fields.get("signerPublicKey") != envelope["publicKey"]
|
|
or derive_qortal_address(envelope["publicKey"]) != state["requester"]
|
|
or fields.get("linkId") != self.link_id_bytes(link).hex()
|
|
or fields.get("sourceSessionId") != state.get("sourceSessionId")
|
|
or fields.get("targetSessionId") != state.get("targetSessionId")
|
|
or fields.get("sourceDestinationHash") != state.get("sourceDestinationHash")
|
|
or fields.get("targetDestinationHash") != state.get("targetDestinationHash")
|
|
or context.get("landSessionId") != state.get("targetSessionId")
|
|
or context.get("localDestinationHash") != state.get("targetDestinationHash")
|
|
or self.resolve_link_peer_hash is None
|
|
or str(self.resolve_link_peer_hash(link) or "").strip().lower() != state.get("sourceDestinationHash")
|
|
or not verify_signature(fields, envelope["publicKey"], envelope["signature"])
|
|
):
|
|
raise ValueError("invalid_resume_request")
|
|
created_at = fields.get("createdAt")
|
|
if (
|
|
not isinstance(created_at, int)
|
|
or isinstance(created_at, bool)
|
|
or abs(int(time.time() * 1000) - created_at) > RECOVERY_WINDOW * 1000
|
|
):
|
|
raise ValueError("stale_resume_request")
|
|
if (
|
|
not valid_hex(fields.get("requesterNonce"), 16)
|
|
or not valid_hex(fields.get("stateHash"), 32)
|
|
or not valid_hex(fields.get("transcriptHash"), 32)
|
|
or not isinstance(fields.get("lastAcknowledgedPly"), int)
|
|
or isinstance(fields.get("lastAcknowledgedPly"), bool)
|
|
):
|
|
raise ValueError("invalid_resume_fields")
|
|
nonce_key = f"resume:{state['requester']}:{fields.get('requesterNonce')}"
|
|
if self.used_nonces.get(nonce_key, 0) > time.time():
|
|
raise ValueError("replayed_resume_nonce")
|
|
local = self._resume_state_fields(state)
|
|
remote_ply = int(fields.get("lastAcknowledgedPly") or 0)
|
|
local_ply = int(local["lastAcknowledgedPly"])
|
|
if remote_ply < 0 or remote_ply > int(GAME_CONFIGS[state["game"]]["maxPly"]):
|
|
raise ValueError("invalid_resume_ply")
|
|
if remote_ply <= local_ply:
|
|
expected = self._transcript_summary(state, remote_ply)
|
|
if fields.get("stateHash") != expected["stateHash"] or fields.get("transcriptHash") != expected["transcriptHash"]:
|
|
raise ValueError("resume_state_conflict")
|
|
state["peerResumePly"] = remote_ply
|
|
self.used_nonces[nonce_key] = time.time() + 5 * 60
|
|
old_link = state.get("link")
|
|
with self.lock:
|
|
if old_link is not None:
|
|
self.links_by_object.pop(id(old_link), None)
|
|
self.links_by_object[id(link)] = match_id
|
|
state["link"] = link
|
|
state["linkId"] = fields["linkId"]
|
|
state["resumeRequesterNonce"] = fields["requesterNonce"]
|
|
state["lastActivity"] = time.time()
|
|
state["lastRx"] = time.time()
|
|
self._configure_channel(state)
|
|
responder_nonce = secrets.token_hex(16)
|
|
state["resumeRecipientNonce"] = responder_nonce
|
|
accept_fields = {
|
|
"type": "QORTAL_LAND_GAME_RESUME_ACCEPT",
|
|
"matchId": match_id,
|
|
"roundId": state.get("roundId") or match_id,
|
|
"responderAddress": state["recipient"],
|
|
"signerPublicKey": context.get("publicKey"),
|
|
"linkId": state["linkId"],
|
|
"sourceSessionId": state.get("sourceSessionId"),
|
|
"targetSessionId": state.get("targetSessionId"),
|
|
"sourceDestinationHash": state.get("sourceDestinationHash"),
|
|
"targetDestinationHash": state.get("targetDestinationHash"),
|
|
"requesterNonce": state["resumeRequesterNonce"],
|
|
"recipientNonce": responder_nonce,
|
|
**local,
|
|
"createdAt": int(time.time() * 1000),
|
|
}
|
|
state["phase"] = "awaiting_resume_confirm"
|
|
self._require_signature(state, accept_fields["type"], accept_fields)
|
|
|
|
def owns_link(self, link) -> bool:
|
|
with self.lock:
|
|
return id(link) in self.links_by_object
|
|
|
|
def _validate_invite(self, fields: Dict[str, Any], envelope: Dict[str, Any], link) -> None:
|
|
context = self.land_context
|
|
if not context:
|
|
raise ValueError("unavailable")
|
|
config = GAME_CONFIGS.get(str(fields.get("game") or ""))
|
|
if (
|
|
fields.get("type") != "QORTAL_LAND_GAME_INVITE"
|
|
or fields.get("protocolVersion") != PROTOCOL_VERSION
|
|
or not config
|
|
or fields.get("gameVersion") != config["gameVersion"]
|
|
or fields.get("rulesVersion") != config["rulesVersion"]
|
|
):
|
|
raise ValueError("unsupported")
|
|
if (
|
|
fields.get("recipientAddress") != context.get("address")
|
|
or fields.get("groupId") != context.get("groupId")
|
|
or fields.get("targetSessionId") != context.get("landSessionId")
|
|
or fields.get("targetDestinationHash") != context.get("localDestinationHash")
|
|
):
|
|
raise ValueError("wrong_recipient")
|
|
now_ms = int(time.time() * 1000)
|
|
created_at = fields.get("createdAt")
|
|
expires_at = fields.get("expiresAt")
|
|
if (
|
|
not isinstance(created_at, int)
|
|
or isinstance(created_at, bool)
|
|
or not isinstance(expires_at, int)
|
|
or isinstance(expires_at, bool)
|
|
or expires_at <= now_ms
|
|
or created_at > now_ms + 30_000
|
|
or created_at < now_ms - INVITE_TTL * 1000
|
|
or expires_at <= created_at
|
|
or expires_at - created_at > INVITE_TTL * 1000
|
|
):
|
|
raise ValueError("expired")
|
|
uuid.UUID(str(fields.get("matchId") or ""))
|
|
if len(bytes.fromhex(str(fields.get("requesterNonce") or ""))) != 16:
|
|
raise ValueError("invalid_nonce")
|
|
nonce_key = f"{fields.get('requesterAddress')}:{fields.get('requesterNonce')}"
|
|
if self.used_nonces.get(nonce_key, 0) > time.time():
|
|
raise ValueError("replayed_nonce")
|
|
public_key = envelope["publicKey"]
|
|
if fields.get("signerPublicKey") != public_key or derive_qortal_address(public_key) != fields.get("requesterAddress") or not verify_signature(fields, public_key, envelope["signature"]):
|
|
raise ValueError("invalid_signature")
|
|
source_hash = str(fields.get("sourceDestinationHash") or "").strip().lower()
|
|
# The wallet signature binds the Qortal address to source_hash, while
|
|
# the live link identity proves that the sender controls source_hash.
|
|
# This remains authoritative if the separate presence cache is stale.
|
|
if (
|
|
not source_hash
|
|
or self.resolve_link_peer_hash is None
|
|
or str(self.resolve_link_peer_hash(link) or "").strip().lower() != source_hash
|
|
or not isinstance(fields.get("sourceSessionId"), str)
|
|
or not 0 < len(str(fields.get("sourceSessionId"))) <= 16
|
|
):
|
|
raise ValueError("unverified_peer")
|
|
self.used_nonces[nonce_key] = time.time() + 5 * 60
|
|
|
|
def _respond(self, message: Dict[str, Any]) -> None:
|
|
match_id = str(message.get("matchId") or "")
|
|
decision = str(message.get("decision") or "").lower()
|
|
state = self.matches.get(match_id)
|
|
context = self.land_context or {}
|
|
if not state or state.get("phase") != "invited" or decision not in {"accept", "decline"}:
|
|
raise ValueError("invalid_invite_response")
|
|
now = int(time.time() * 1000)
|
|
if now >= int(state.get("expiresAt") or 0):
|
|
self._close_match(match_id, "expired")
|
|
raise ValueError("invite_expired")
|
|
if decision == "accept":
|
|
recipient_nonce = str(message.get("recipientNonce") or secrets.token_hex(16)).lower()
|
|
if len(bytes.fromhex(recipient_nonce)) != 16:
|
|
raise ValueError("invalid_nonce")
|
|
state["recipientNonce"] = recipient_nonce
|
|
fields = {
|
|
"type": "QORTAL_LAND_GAME_ACCEPT",
|
|
"inviteHash": state["inviteHash"],
|
|
"matchId": match_id,
|
|
"requesterNonce": state["requesterNonce"],
|
|
"recipientNonce": recipient_nonce,
|
|
"responderAddress": context["address"],
|
|
"signerPublicKey": context["publicKey"],
|
|
"linkId": state["linkId"],
|
|
"createdAt": now,
|
|
}
|
|
self._require_signature(state, fields["type"], fields)
|
|
else:
|
|
fields = {
|
|
"type": "QORTAL_LAND_GAME_DECLINE",
|
|
"inviteHash": state["inviteHash"],
|
|
"matchId": match_id,
|
|
"responderAddress": context["address"],
|
|
"signerPublicKey": context["publicKey"],
|
|
"reason": str(message.get("reason") or "declined")[:32],
|
|
"linkId": state["linkId"],
|
|
"createdAt": now,
|
|
}
|
|
self._require_signature(state, fields["type"], fields)
|
|
|
|
def _configure_channel(self, state: Dict[str, Any]) -> None:
|
|
link = state["link"]
|
|
link.set_link_closed_callback(self._link_closed)
|
|
channel = link.get_channel()
|
|
channel.register_message_type(GameMessage)
|
|
channel.add_message_handler(lambda message: self._on_channel(state["matchId"], message))
|
|
state["channel"] = channel
|
|
|
|
def _send_channel(self, state: Dict[str, Any], payload: Dict[str, Any]) -> None:
|
|
if len(umsgpack.packb(payload)) > MAX_CHANNEL_PAYLOAD:
|
|
raise ValueError("channel_payload_too_large")
|
|
envelope = state["channel"].send(GameMessage(payload))
|
|
state["lastActivity"] = time.time()
|
|
if envelope is None:
|
|
raise ValueError("channel_send_failed")
|
|
|
|
@staticmethod
|
|
def _is_channel_temporarily_unavailable(exc: Exception) -> bool:
|
|
return (
|
|
isinstance(exc, ChannelException)
|
|
and getattr(exc, "type", None) == CEType.ME_LINK_NOT_READY
|
|
) or str(exc).lower() in {
|
|
"channel_send_failed",
|
|
"('link is not ready',)",
|
|
"('outlet did not transmit packet',)",
|
|
}
|
|
|
|
@staticmethod
|
|
def _game_send_key(payload: Dict[str, Any]) -> str:
|
|
message = payload.get("m") if isinstance(payload, dict) else None
|
|
if not isinstance(message, dict):
|
|
return ""
|
|
message_type = str(message.get("type") or "")
|
|
message_id = str(message.get("messageId") or "")
|
|
if not message_type:
|
|
return ""
|
|
if message_type == "START_ACK":
|
|
return message_type
|
|
if not message_id:
|
|
return ""
|
|
if message_type == "CHAT_CHUNK":
|
|
return f"{message_type}:{message_id}:{message.get('index')}"
|
|
return f"{message_type}:{message_id}"
|
|
|
|
@staticmethod
|
|
def _is_ephemeral_game_payload(payload: Dict[str, Any]) -> bool:
|
|
message = payload.get("m") if isinstance(payload, dict) else None
|
|
return (
|
|
isinstance(message, dict)
|
|
and str(message.get("type") or "") in EPHEMERAL_GAME_MESSAGE_TYPES
|
|
)
|
|
|
|
def _queue_game_payload(
|
|
self,
|
|
state: Dict[str, Any],
|
|
payload: Dict[str, Any],
|
|
recovery_priority: bool = False,
|
|
) -> bool:
|
|
if self._is_ephemeral_game_payload(payload):
|
|
return False
|
|
with self.lock:
|
|
priority_queue = state.setdefault("recoveryGameSendQueue", deque())
|
|
normal_queue = state.setdefault("gameSendQueue", deque())
|
|
key = self._game_send_key(payload)
|
|
if key and any(
|
|
self._game_send_key(queued) == key
|
|
for queued in (*priority_queue, *normal_queue)
|
|
):
|
|
return True
|
|
if len(priority_queue) + len(normal_queue) >= GAME_SEND_QUEUE_MAX:
|
|
raise ValueError("game_send_queue_full")
|
|
(priority_queue if recovery_priority else normal_queue).append(payload)
|
|
return True
|
|
|
|
def _send_game_payload(
|
|
self,
|
|
state: Dict[str, Any],
|
|
message: Dict[str, Any],
|
|
recovery_priority: bool = False,
|
|
) -> bool:
|
|
payload = {"k": "game", "m": message}
|
|
with self.lock:
|
|
priority_queue = state.setdefault("recoveryGameSendQueue", deque())
|
|
normal_queue = state.setdefault("gameSendQueue", deque())
|
|
has_queued_payload = bool(priority_queue or normal_queue)
|
|
if has_queued_payload:
|
|
queued = self._queue_game_payload(
|
|
state,
|
|
payload,
|
|
recovery_priority=recovery_priority,
|
|
)
|
|
self._flush_game_send_queue(state)
|
|
return queued
|
|
try:
|
|
self._send_channel(state, payload)
|
|
return True
|
|
except Exception as exc:
|
|
if not self._is_channel_temporarily_unavailable(exc):
|
|
raise
|
|
queued = self._queue_game_payload(
|
|
state,
|
|
payload,
|
|
recovery_priority=recovery_priority,
|
|
)
|
|
now = time.time()
|
|
if queued and now - float(state.get("lastGameSendDeferredLog") or 0) >= 2:
|
|
state["lastGameSendDeferredLog"] = now
|
|
self.log(
|
|
f"[qortalland-game] send deferred match={state['matchId'][:8]} "
|
|
f"queued={len(priority_queue) + len(normal_queue)} code=channel_not_ready"
|
|
)
|
|
return queued
|
|
|
|
def _flush_game_send_queue(self, state: Dict[str, Any]) -> None:
|
|
if state.get("phase") not in {"active", "ending", *ROUND_PHASES}:
|
|
return
|
|
with self.lock:
|
|
if state.get("gameSendFlushActive"):
|
|
return
|
|
state["gameSendFlushActive"] = True
|
|
try:
|
|
sent = 0
|
|
while sent < GAME_SEND_FLUSH_BUDGET:
|
|
with self.lock:
|
|
priority_queue = state.setdefault("recoveryGameSendQueue", deque())
|
|
normal_queue = state.setdefault("gameSendQueue", deque())
|
|
queue_to_use = priority_queue if priority_queue else normal_queue
|
|
if not queue_to_use:
|
|
return
|
|
payload = queue_to_use[0]
|
|
queued_message = payload.get("m") if isinstance(payload, dict) else None
|
|
if (
|
|
isinstance(queued_message, dict)
|
|
and queued_message.get("type") == "MOVE"
|
|
and queued_message.get("roundId") == state.get("roundId")
|
|
and queued_message.get("messageId")
|
|
):
|
|
state.setdefault("pendingOutboundMoves", {}).setdefault(
|
|
str(queued_message["messageId"]),
|
|
{**queued_message, "type": "MOVE"},
|
|
)
|
|
try:
|
|
self._send_channel(state, payload)
|
|
except Exception as exc:
|
|
if not self._is_channel_temporarily_unavailable(exc):
|
|
self.log(
|
|
f"[qortalland-game] queued send failed match={state['matchId'][:8]} "
|
|
f"code={str(exc)[:80]}"
|
|
)
|
|
return
|
|
with self.lock:
|
|
if queue_to_use and queue_to_use[0] is payload:
|
|
queue_to_use.popleft()
|
|
sent += 1
|
|
finally:
|
|
with self.lock:
|
|
state["gameSendFlushActive"] = False
|
|
|
|
def _flush_game_send_queues(self) -> None:
|
|
with self.lock:
|
|
states = list(self.matches.values())
|
|
for state in states:
|
|
self._flush_game_send_queue(state)
|
|
|
|
def _current_state_hash(self, state: Dict[str, Any]) -> str:
|
|
transcript = state.get("transcript") or []
|
|
return (
|
|
str(transcript[-1].get("resultingStateHash") or "")
|
|
if transcript
|
|
else self._initial_state_hash(state)
|
|
)
|
|
|
|
def _validate_move_shape(self, state: Dict[str, Any], move: Dict[str, Any]) -> None:
|
|
try:
|
|
uuid.UUID(str(move.get("messageId") or ""))
|
|
except Exception as exc:
|
|
raise ValueError("invalid_move_message_id") from exc
|
|
ply = move.get("ply")
|
|
game = str(state.get("game") or "connect-four")
|
|
max_ply = int(GAME_CONFIGS.get(game, {}).get("maxPly") or 0)
|
|
if not isinstance(ply, int) or isinstance(ply, bool) or ply < 1 or ply > max_ply:
|
|
raise ValueError("invalid_move_ply")
|
|
if game == "connect-four":
|
|
column = move.get("column")
|
|
if not isinstance(column, int) or isinstance(column, bool) or column < 0 or column > 6:
|
|
raise ValueError("invalid_move_column")
|
|
elif game == "checkers":
|
|
origin = move.get("from")
|
|
path = move.get("path")
|
|
if (
|
|
not isinstance(origin, int) or isinstance(origin, bool) or origin < 0 or origin > 63
|
|
or not isinstance(path, list) or not 1 <= len(path) <= 12
|
|
or any(not isinstance(square, int) or isinstance(square, bool) or square < 0 or square > 63 for square in path)
|
|
):
|
|
raise ValueError("invalid_checkers_move")
|
|
elif game == "chess":
|
|
origin = move.get("from")
|
|
destination = move.get("to")
|
|
promotion = move.get("promotion")
|
|
if (
|
|
not isinstance(origin, int) or isinstance(origin, bool) or origin < 0 or origin > 63
|
|
or not isinstance(destination, int) or isinstance(destination, bool) or destination < 0 or destination > 63
|
|
or (
|
|
promotion is not None
|
|
and (
|
|
not isinstance(promotion, int)
|
|
or isinstance(promotion, bool)
|
|
or promotion not in {2, 3, 4, 5}
|
|
)
|
|
)
|
|
):
|
|
raise ValueError("invalid_chess_move")
|
|
else:
|
|
raise ValueError("unsupported_game")
|
|
if ply != len(state.get("transcript") or []) + 1:
|
|
raise ValueError("unexpected_move_ply")
|
|
previous_hash = str(move.get("previousStateHash") or "")
|
|
resulting_hash = str(move.get("resultingStateHash") or "")
|
|
if len(previous_hash) != 64 or len(resulting_hash) != 64:
|
|
raise ValueError("invalid_move_hash")
|
|
bytes.fromhex(previous_hash)
|
|
bytes.fromhex(resulting_hash)
|
|
if previous_hash != self._current_state_hash(state):
|
|
raise ValueError("move_previous_hash_mismatch")
|
|
|
|
@staticmethod
|
|
def _same_move(left: Dict[str, Any], right: Dict[str, Any]) -> bool:
|
|
keys = ("roundId", "messageId", "ply", "column", "from", "to", "path", "promotion", "previousStateHash", "resultingStateHash")
|
|
return all(left.get(key) == right.get(key) for key in keys)
|
|
|
|
def _reset_round(
|
|
self, state: Dict[str, Any], round_id: str, requester_nonce: str,
|
|
recipient_nonce: str, game: Optional[str] = None
|
|
) -> None:
|
|
uuid.UUID(round_id)
|
|
if not valid_hex(requester_nonce, 16) or not valid_hex(recipient_nonce, 16):
|
|
raise ValueError("invalid_round_nonce")
|
|
selected_game = game or str((state.get("pendingRound") or {}).get("game") or state.get("game") or "")
|
|
config = GAME_CONFIGS.get(selected_game)
|
|
if not config:
|
|
raise ValueError("unsupported_round")
|
|
state.update({
|
|
"roundId": round_id,
|
|
"game": selected_game,
|
|
"gameVersion": config["gameVersion"],
|
|
"rulesVersion": config["rulesVersion"],
|
|
"roundRequesterNonce": requester_nonce,
|
|
"roundRecipientNonce": recipient_nonce,
|
|
"phase": "active",
|
|
"transcript": [],
|
|
"pendingOutboundMoves": {},
|
|
"pendingInboundMoves": {},
|
|
"moveAcks": {},
|
|
"seen": set(),
|
|
"lastRx": time.time(),
|
|
})
|
|
state.pop("pendingRound", None)
|
|
|
|
def _finish_round(self, state: Dict[str, Any]) -> None:
|
|
state["phase"] = "session_idle"
|
|
state.pop("pendingRound", None)
|
|
state.setdefault("pendingInboundMoves", {}).clear()
|
|
state.setdefault("pendingOutboundMoves", {}).clear()
|
|
self.send_event("GAME_LINK_STATE", {
|
|
"matchId": state["matchId"], "roundId": state.get("roundId"), "state": "session_idle"
|
|
})
|
|
|
|
@staticmethod
|
|
def _validate_chat_message(message: Dict[str, Any]) -> tuple[str, str, int]:
|
|
message_id = str(message.get("messageId") or "")
|
|
uuid.UUID(message_id)
|
|
text = message.get("text")
|
|
created_at = message.get("createdAt")
|
|
if not isinstance(text, str) or not text.strip() or len(text) > CHAT_MAX_CHARS:
|
|
raise ValueError("invalid_chat_text")
|
|
raw = text.encode("utf-8")
|
|
if len(raw) > CHAT_MAX_BYTES:
|
|
raise ValueError("chat_message_too_large")
|
|
now_ms = int(time.time() * 1000)
|
|
if (
|
|
not isinstance(created_at, int) or isinstance(created_at, bool) or
|
|
created_at <= 0 or abs(created_at - now_ms) > 24 * 60 * 60 * 1000
|
|
):
|
|
raise ValueError("invalid_chat_timestamp")
|
|
return message_id, text, created_at
|
|
|
|
@staticmethod
|
|
def _remember_chat(state: Dict[str, Any], record: Dict[str, Any]) -> None:
|
|
history = state.setdefault("chatMessages", [])
|
|
existing = next((item for item in history if item.get("messageId") == record.get("messageId")), None)
|
|
if existing:
|
|
existing.update(record)
|
|
return
|
|
history.append(record)
|
|
if len(history) > CHAT_HISTORY_LIMIT:
|
|
del history[:-CHAT_HISTORY_LIMIT]
|
|
|
|
def _send_chat_chunks(self, state: Dict[str, Any], message_id: str, text_value: str, created_at: int) -> None:
|
|
raw = text_value.encode("utf-8")
|
|
chunks = [raw[index:index + CHAT_CHUNK_BYTES] for index in range(0, len(raw), CHAT_CHUNK_BYTES)]
|
|
for index, chunk in enumerate(chunks):
|
|
self._send_game_payload(state, {
|
|
"type": "CHAT_CHUNK", "matchId": state["matchId"], "messageId": message_id,
|
|
"createdAt": created_at, "index": index, "total": len(chunks), "data": chunk,
|
|
})
|
|
|
|
def _receive_chat_chunk(self, state: Dict[str, Any], message: Dict[str, Any]) -> None:
|
|
message_id = str(message.get("messageId") or "")
|
|
uuid.UUID(message_id)
|
|
index = message.get("index")
|
|
total = message.get("total")
|
|
created_at = message.get("createdAt")
|
|
data = message.get("data")
|
|
max_chunks = (CHAT_MAX_BYTES + CHAT_CHUNK_BYTES - 1) // CHAT_CHUNK_BYTES
|
|
if (
|
|
not isinstance(index, int) or isinstance(index, bool) or
|
|
not isinstance(total, int) or isinstance(total, bool) or
|
|
index < 0 or total < 1 or total > max_chunks or index >= total or
|
|
not isinstance(created_at, int) or isinstance(created_at, bool) or
|
|
not isinstance(data, bytes) or len(data) > CHAT_CHUNK_BYTES
|
|
):
|
|
raise ValueError("invalid_chat_chunk")
|
|
if any(item.get("messageId") == message_id for item in state.get("chatMessages", [])):
|
|
self._send_game_payload(state, {"type": "CHAT_ACK", "matchId": state["matchId"], "messageId": message_id})
|
|
return
|
|
assemblies = state.setdefault("inboundChatChunks", {})
|
|
if message_id not in assemblies and len(assemblies) >= 16:
|
|
raise ValueError("too_many_partial_chat_messages")
|
|
assembly = assemblies.setdefault(message_id, {"total": total, "createdAt": created_at, "chunks": {}, "started": time.time()})
|
|
if assembly["total"] != total or assembly["createdAt"] != created_at:
|
|
raise ValueError("conflicting_chat_chunk")
|
|
if index in assembly["chunks"] and assembly["chunks"][index] != data:
|
|
raise ValueError("conflicting_chat_chunk")
|
|
assembly["chunks"][index] = data
|
|
if len(assembly["chunks"]) != total:
|
|
return
|
|
raw = b"".join(assembly["chunks"][part] for part in range(total))
|
|
assemblies.pop(message_id, None)
|
|
try:
|
|
text_value = raw.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise ValueError("invalid_chat_encoding") from exc
|
|
self._validate_chat_message({"messageId": message_id, "text": text_value, "createdAt": created_at})
|
|
author = state["recipient"] if state.get("outbound") else state["requester"]
|
|
record = {"messageId": message_id, "text": text_value, "createdAt": created_at, "authorAddress": author, "delivered": True}
|
|
self._remember_chat(state, record)
|
|
self.send_event("GAME_MESSAGE", {
|
|
"matchId": state["matchId"],
|
|
"message": {"type": "CHAT_MESSAGE", "matchId": state["matchId"], **record},
|
|
})
|
|
self._send_game_payload(state, {"type": "CHAT_ACK", "matchId": state["matchId"], "messageId": message_id})
|
|
|
|
def _validate_round_control(self, state: Dict[str, Any], message: Dict[str, Any]) -> None:
|
|
round_id = str(message.get("roundId") or "")
|
|
uuid.UUID(round_id)
|
|
if round_id == state.get("roundId"):
|
|
raise ValueError("round_already_used")
|
|
game = str(message.get("game") or "")
|
|
config = GAME_CONFIGS.get(game)
|
|
if not config or message.get("gameVersion") != config["gameVersion"] or message.get("rulesVersion") != config["rulesVersion"]:
|
|
raise ValueError("unsupported_round")
|
|
|
|
def _chat_error(self, state: Dict[str, Any], reason: str) -> None:
|
|
self.log(f"[qortalland-game] chat rejected match={state['matchId'][:8]} code={reason[:48]}")
|
|
self.send_event("GAME_ERROR", {
|
|
"matchId": state["matchId"], "code": "chat_error", "message": reason[:120]
|
|
})
|
|
|
|
def _append_accepted_move(self, state: Dict[str, Any], move: Dict[str, Any]) -> None:
|
|
self._validate_move_shape(state, move)
|
|
state.setdefault("transcript", []).append({**move, "type": "MOVE"})
|
|
|
|
def _on_channel(self, match_id: str, message: MessageBase) -> bool:
|
|
state = self.matches.get(match_id)
|
|
payload = message.payload if isinstance(message, GameMessage) else None
|
|
if not state or not isinstance(payload, dict):
|
|
return False
|
|
state["lastActivity"] = time.time()
|
|
state["lastRx"] = time.time()
|
|
if payload.get("k") == "handshake":
|
|
try:
|
|
self._handle_handshake(state, self._decode_handshake(payload.get("e") or {}))
|
|
except Exception as exc:
|
|
self._protocol_error(state, str(exc))
|
|
return True
|
|
if payload.get("k") == "cancel":
|
|
if (
|
|
state.get("phase") not in {"invited", "awaiting_response"}
|
|
or payload.get("matchId") != match_id
|
|
):
|
|
self._protocol_error(state, "unexpected_cancel")
|
|
else:
|
|
self._close_match(match_id, "cancelled")
|
|
return True
|
|
if payload.get("k") != "game" or state.get("phase") not in {"active", "ending", "awaiting_start_ack", *ROUND_PHASES}:
|
|
self._protocol_error(state, "unexpected_message")
|
|
return True
|
|
game_message = payload.get("m")
|
|
if not isinstance(game_message, dict) or game_message.get("type") not in ACTIVE_TYPES or game_message.get("matchId") not in {None, match_id}:
|
|
self._protocol_error(state, "invalid_game_message")
|
|
return True
|
|
message_type = str(game_message.get("type") or "")
|
|
message_id = str(game_message.get("messageId") or "")
|
|
if message_type == "CHAT_CHUNK":
|
|
try:
|
|
self._receive_chat_chunk(state, game_message)
|
|
except Exception as exc:
|
|
state.setdefault("inboundChatChunks", {}).pop(message_id, None)
|
|
self._chat_error(state, str(exc))
|
|
return True
|
|
if message_type == "CHAT_MESSAGE":
|
|
self._chat_error(state, "unexpected_chat_message")
|
|
return True
|
|
if message_type == "CHAT_ACK":
|
|
record = next((item for item in state.get("chatMessages", []) if item.get("messageId") == message_id), None)
|
|
local_author = state["requester"] if state.get("outbound") else state["recipient"]
|
|
if record and record.get("authorAddress") == local_author:
|
|
record["delivered"] = True
|
|
self.send_event("GAME_MESSAGE", {"matchId": match_id, "message": game_message})
|
|
return True
|
|
if message_type == "CHAT_TYPING":
|
|
if not isinstance(game_message.get("active"), bool):
|
|
self._chat_error(state, "invalid_chat_typing")
|
|
else:
|
|
now = time.time()
|
|
if game_message["active"] and now - float(state.get("lastRemoteTyping") or 0) < 0.2:
|
|
return True
|
|
state["lastRemoteTyping"] = now
|
|
self.send_event("GAME_MESSAGE", {"matchId": match_id, "message": game_message})
|
|
return True
|
|
if message_type == "ROUND_REQUEST":
|
|
try:
|
|
self._validate_round_control(state, game_message)
|
|
if not valid_hex(game_message.get("requesterNonce"), 16):
|
|
raise ValueError("round_not_available")
|
|
if state.get("phase") == "ending":
|
|
self._finish_round(state)
|
|
if state.get("phase") == "round_waiting":
|
|
pending = state.get("pendingRound") or {}
|
|
incoming_id = str(game_message["roundId"])
|
|
pending_id = str(pending.get("roundId") or "")
|
|
if uuid.UUID(incoming_id).bytes > uuid.UUID(pending_id).bytes:
|
|
self._send_game_payload(state, {
|
|
"type": "ROUND_RESPONSE", "matchId": match_id,
|
|
"messageId": str(uuid.uuid4()), "roundId": incoming_id,
|
|
"accepted": False, "reason": "superseded",
|
|
})
|
|
return True
|
|
if incoming_id == pending_id:
|
|
return True
|
|
elif state.get("phase") != "session_idle":
|
|
raise ValueError("round_not_available")
|
|
state["pendingRound"] = {
|
|
"roundId": game_message["roundId"],
|
|
"requesterNonce": game_message["requesterNonce"],
|
|
"game": game_message["game"],
|
|
"requestedByRemote": True,
|
|
}
|
|
state["phase"] = "round_incoming"
|
|
self.send_event("GAME_MESSAGE", {"matchId": match_id, "message": game_message})
|
|
except Exception as exc:
|
|
self._protocol_error(state, str(exc))
|
|
return True
|
|
if message_type == "ROUND_RESPONSE":
|
|
pending = state.get("pendingRound") or {}
|
|
if state.get("phase") != "round_waiting" or game_message.get("roundId") != pending.get("roundId"):
|
|
# This may be the response to the losing request in a crossed-rematch race.
|
|
return True
|
|
if game_message.get("accepted") is True:
|
|
try:
|
|
self._reset_round(state, pending["roundId"], pending["requesterNonce"], str(game_message.get("recipientNonce") or ""))
|
|
except Exception as exc:
|
|
self._protocol_error(state, str(exc))
|
|
return True
|
|
else:
|
|
state["phase"] = "session_idle"
|
|
state.pop("pendingRound", None)
|
|
self.send_event("GAME_MESSAGE", {"matchId": match_id, "message": game_message})
|
|
return True
|
|
if message_type == "ROUND_CANCEL":
|
|
pending = state.get("pendingRound") or {}
|
|
if state.get("phase") == "round_incoming" and game_message.get("roundId") == pending.get("roundId"):
|
|
state["phase"] = "session_idle"
|
|
state.pop("pendingRound", None)
|
|
self.send_event("GAME_MESSAGE", {"matchId": match_id, "message": game_message})
|
|
return True
|
|
if message_type in ROUND_BOUND_TYPES and game_message.get("roundId") != state.get("roundId"):
|
|
# A delayed packet from a completed round must not poison the reusable session.
|
|
return True
|
|
if message_type in {"MOVE", "SYNC_MOVE"}:
|
|
normalized_move = {**game_message, "type": "MOVE"}
|
|
pending_inbound = state.setdefault("pendingInboundMoves", {})
|
|
existing_pending = pending_inbound.get(message_id)
|
|
if existing_pending is not None:
|
|
if not self._same_move(existing_pending, normalized_move):
|
|
self._protocol_error(state, "conflicting_duplicate_move")
|
|
return True
|
|
cached_ack = state.setdefault("moveAcks", {}).get(message_id)
|
|
if cached_ack is not None:
|
|
accepted = next(
|
|
(move for move in state.get("transcript") or [] if move.get("messageId") == message_id),
|
|
None,
|
|
)
|
|
if accepted is None or not self._same_move(accepted, normalized_move):
|
|
self._protocol_error(state, "conflicting_accepted_move")
|
|
else:
|
|
self._send_game_payload(state, cached_ack)
|
|
return True
|
|
try:
|
|
self._validate_move_shape(state, normalized_move)
|
|
except Exception as exc:
|
|
self._protocol_error(state, str(exc))
|
|
return True
|
|
pending_inbound[message_id] = normalized_move
|
|
self.send_event("GAME_MESSAGE", {"matchId": match_id, "message": game_message})
|
|
return True
|
|
if message_type == "MOVE_ACK":
|
|
pending_outbound = state.setdefault("pendingOutboundMoves", {})
|
|
pending_move = pending_outbound.get(message_id)
|
|
if pending_move is None:
|
|
already_accepted = any(
|
|
move.get("messageId") == message_id
|
|
for move in state.get("transcript") or []
|
|
)
|
|
if already_accepted:
|
|
return True
|
|
self._protocol_error(state, "unexpected_move_ack")
|
|
return True
|
|
if (
|
|
game_message.get("ply") != pending_move.get("ply")
|
|
or game_message.get("stateHash") != pending_move.get("resultingStateHash")
|
|
):
|
|
self._protocol_error(state, "conflicting_move_ack")
|
|
return True
|
|
try:
|
|
self._append_accepted_move(state, pending_move)
|
|
except Exception as exc:
|
|
self._protocol_error(state, str(exc))
|
|
return True
|
|
pending_outbound.pop(message_id, None)
|
|
self.send_event("GAME_MESSAGE", {"matchId": match_id, "message": game_message})
|
|
return True
|
|
seen = state.setdefault("seen", set())
|
|
if message_id and message_id in seen:
|
|
cached_ack = state.setdefault("moveAcks", {}).get(message_id)
|
|
if cached_ack is not None:
|
|
self._send_game_payload(state, cached_ack)
|
|
return True
|
|
if message_id:
|
|
if len(seen) >= 256:
|
|
seen.pop()
|
|
seen.add(message_id)
|
|
if game_message.get("type") == "MATCH_PING":
|
|
self._send_game_payload(state, {"type": "MATCH_PONG", "matchId": match_id, "messageId": message_id})
|
|
elif game_message.get("type") == "START_ACK":
|
|
resume_phase = str(state.pop("resumeReturnPhase", "active"))
|
|
state["phase"] = resume_phase if resume_phase in {"active", "ending", "round_waiting", "round_incoming"} else "active"
|
|
state.pop("disconnectedAt", None)
|
|
if state["phase"] in {"active", "ending"}:
|
|
self._send_missing_sync_moves(state)
|
|
self._flush_game_send_queue(state)
|
|
self.send_event("GAME_STARTED", self._public_state(state))
|
|
else:
|
|
self.send_event("GAME_MESSAGE", {"matchId": match_id, "message": game_message})
|
|
if message_type in {"RESIGN_ACK", "GAME_OVER_ACK"}:
|
|
self._finish_round(state)
|
|
return True
|
|
|
|
def _schedule_terminal_close(self, match_id: str, delay: float = 2.0) -> None:
|
|
state = self.matches.get(match_id)
|
|
round_id = state.get("roundId") if state else None
|
|
timer = threading.Timer(delay, self._finish_round_by_id, args=(match_id, round_id))
|
|
timer.daemon = True
|
|
timer.start()
|
|
|
|
def _finish_round_by_id(self, match_id: str, round_id: Optional[str]) -> None:
|
|
state = self.matches.get(match_id)
|
|
if state and state.get("phase") == "ending" and state.get("roundId") == round_id:
|
|
self._finish_round(state)
|
|
|
|
def _send_missing_sync_moves(self, state: Dict[str, Any]) -> None:
|
|
transcript = list(state.get("transcript") or [])
|
|
peer_ply = int(state.pop("peerResumePly", len(transcript)) or 0)
|
|
for move in transcript[max(0, peer_ply):]:
|
|
self._send_game_payload(
|
|
state,
|
|
{**move, "type": "SYNC_MOVE"},
|
|
recovery_priority=True,
|
|
)
|
|
|
|
def _handle_handshake(self, state: Dict[str, Any], envelope: Dict[str, Any]) -> None:
|
|
fields = envelope["fields"]
|
|
public_key = envelope["publicKey"]
|
|
kind = fields.get("type")
|
|
if kind not in HANDSHAKE_TYPES or fields.get("matchId") != state["matchId"] or fields.get("linkId") != state["linkId"] or fields.get("signerPublicKey") != public_key or not verify_signature(fields, public_key, envelope["signature"]):
|
|
raise ValueError("invalid_handshake")
|
|
created_at = fields.get("createdAt")
|
|
if (
|
|
not isinstance(created_at, int)
|
|
or isinstance(created_at, bool)
|
|
or abs(int(time.time() * 1000) - created_at) > INVITE_TTL * 1000
|
|
):
|
|
raise ValueError("stale_handshake")
|
|
signer = derive_qortal_address(public_key)
|
|
if kind == "QORTAL_LAND_GAME_ACCEPT":
|
|
if (
|
|
state.get("phase") != "awaiting_response"
|
|
or not state.get("outbound")
|
|
or signer != state["recipient"]
|
|
or fields.get("responderAddress") != state["recipient"]
|
|
or fields.get("inviteHash") != state["inviteHash"]
|
|
or fields.get("requesterNonce") != state["requesterNonce"]
|
|
or not valid_hex(fields.get("recipientNonce"), 16)
|
|
):
|
|
raise ValueError("invalid_accept")
|
|
state["recipientNonce"] = fields["recipientNonce"]
|
|
state["acceptEnvelope"] = envelope
|
|
accept_hash = hashlib.sha256(canonical_bytes(envelope)).hexdigest()
|
|
starter = self._starter(state)
|
|
fields_out = {
|
|
"type": "QORTAL_LAND_GAME_CONFIRM",
|
|
"acceptHash": accept_hash,
|
|
"matchId": state["matchId"],
|
|
"requesterNonce": state["requesterNonce"],
|
|
"recipientNonce": state["recipientNonce"],
|
|
"starter": starter,
|
|
"initialStateHash": self._initial_state_hash(state),
|
|
"requesterAddress": state["requester"],
|
|
"signerPublicKey": state["requesterPublicKey"],
|
|
"linkId": state["linkId"],
|
|
"createdAt": int(time.time() * 1000),
|
|
}
|
|
self.send_event(
|
|
"GAME_INVITE_RESPONSE",
|
|
{
|
|
"matchId": state["matchId"],
|
|
"accepted": True,
|
|
"recipientNonce": state["recipientNonce"],
|
|
},
|
|
)
|
|
self._require_signature(state, fields_out["type"], fields_out)
|
|
elif kind == "QORTAL_LAND_GAME_DECLINE":
|
|
if (
|
|
state.get("phase") != "awaiting_response"
|
|
or not state.get("outbound")
|
|
or signer != state["recipient"]
|
|
or fields.get("responderAddress") != state["recipient"]
|
|
or fields.get("inviteHash") != state["inviteHash"]
|
|
or fields.get("reason") not in {"declined", "busy", "superseded"}
|
|
):
|
|
raise ValueError("invalid_decline")
|
|
self.send_event("GAME_INVITE_RESPONSE", {"matchId": state["matchId"], "accepted": False, "reason": fields.get("reason")})
|
|
self._close_match(state["matchId"], "declined")
|
|
elif kind == "QORTAL_LAND_GAME_CONFIRM":
|
|
expected_accept_hash = hashlib.sha256(canonical_bytes(state.get("acceptEnvelope") or {})).hexdigest()
|
|
if (
|
|
state.get("phase") != "awaiting_confirm"
|
|
or state.get("outbound")
|
|
or signer != state["requester"]
|
|
or fields.get("requesterAddress") != state["requester"]
|
|
or fields.get("starter") != self._starter(state)
|
|
or fields.get("acceptHash") != expected_accept_hash
|
|
or fields.get("initialStateHash") != self._initial_state_hash(state)
|
|
or fields.get("requesterNonce") != state["requesterNonce"]
|
|
or fields.get("recipientNonce") != state["recipientNonce"]
|
|
):
|
|
raise ValueError("invalid_confirm")
|
|
state["phase"] = "active"
|
|
self._send_game_payload(
|
|
state,
|
|
{"type": "START_ACK", "matchId": state["matchId"], "messageId": str(uuid.uuid4())},
|
|
recovery_priority=True,
|
|
)
|
|
self.send_event("GAME_STARTED", self._public_state(state))
|
|
elif kind == "QORTAL_LAND_GAME_RESUME_ACCEPT":
|
|
local = self._resume_state_fields(state)
|
|
remote_ply_value = fields.get("lastAcknowledgedPly")
|
|
remote_ply = (
|
|
remote_ply_value
|
|
if isinstance(remote_ply_value, int) and not isinstance(remote_ply_value, bool)
|
|
else -1
|
|
)
|
|
local_ply = int(local["lastAcknowledgedPly"])
|
|
compatible = 0 <= remote_ply <= int(GAME_CONFIGS[state["game"]]["maxPly"])
|
|
if compatible and remote_ply <= local_ply:
|
|
expected = self._transcript_summary(state, remote_ply)
|
|
compatible = (
|
|
fields.get("stateHash") == expected["stateHash"]
|
|
and fields.get("transcriptHash") == expected["transcriptHash"]
|
|
)
|
|
if (
|
|
state.get("phase") != "awaiting_resume_accept"
|
|
or not state.get("outbound")
|
|
or signer != state["recipient"]
|
|
or fields.get("responderAddress") != state["recipient"]
|
|
or fields.get("roundId") != (state.get("roundId") or state["matchId"])
|
|
or fields.get("requesterNonce") != state.get("resumeRequesterNonce")
|
|
or not valid_hex(fields.get("recipientNonce"), 16)
|
|
or not valid_hex(fields.get("stateHash"), 32)
|
|
or not valid_hex(fields.get("transcriptHash"), 32)
|
|
or not compatible
|
|
):
|
|
raise ValueError("invalid_resume_accept")
|
|
state["resumeRecipientNonce"] = fields["recipientNonce"]
|
|
state["peerResumePly"] = remote_ply
|
|
context = self.land_context or {}
|
|
confirm_fields = {
|
|
"type": "QORTAL_LAND_GAME_RESUME_CONFIRM",
|
|
"matchId": state["matchId"],
|
|
"roundId": state.get("roundId") or state["matchId"],
|
|
"requesterAddress": state["requester"],
|
|
"signerPublicKey": context.get("publicKey"),
|
|
"linkId": state["linkId"],
|
|
"requesterNonce": state["resumeRequesterNonce"],
|
|
"recipientNonce": state["resumeRecipientNonce"],
|
|
"lastAcknowledgedPly": local["lastAcknowledgedPly"],
|
|
"stateHash": local["stateHash"],
|
|
"transcriptHash": local["transcriptHash"],
|
|
"createdAt": int(time.time() * 1000),
|
|
}
|
|
self._require_signature(state, confirm_fields["type"], confirm_fields)
|
|
elif kind == "QORTAL_LAND_GAME_RESUME_CONFIRM":
|
|
local = self._resume_state_fields(state)
|
|
remote_ply_value = fields.get("lastAcknowledgedPly")
|
|
remote_ply = (
|
|
remote_ply_value
|
|
if isinstance(remote_ply_value, int) and not isinstance(remote_ply_value, bool)
|
|
else -1
|
|
)
|
|
local_ply = int(local["lastAcknowledgedPly"])
|
|
compatible = 0 <= remote_ply <= int(GAME_CONFIGS[state["game"]]["maxPly"])
|
|
if compatible and remote_ply <= local_ply:
|
|
expected = self._transcript_summary(state, remote_ply)
|
|
compatible = fields.get("stateHash") == expected["stateHash"] and fields.get("transcriptHash") == expected["transcriptHash"]
|
|
if (
|
|
state.get("phase") != "awaiting_resume_confirm"
|
|
or state.get("outbound")
|
|
or signer != state["requester"]
|
|
or fields.get("requesterAddress") != state["requester"]
|
|
or fields.get("roundId") != (state.get("roundId") or state["matchId"])
|
|
or fields.get("requesterNonce") != state.get("resumeRequesterNonce")
|
|
or fields.get("recipientNonce") != state.get("resumeRecipientNonce")
|
|
or not valid_hex(fields.get("stateHash"), 32)
|
|
or not valid_hex(fields.get("transcriptHash"), 32)
|
|
or not compatible
|
|
):
|
|
raise ValueError("invalid_resume_confirm")
|
|
state["peerResumePly"] = remote_ply
|
|
resume_phase = str(state.pop("resumeReturnPhase", "active"))
|
|
state["phase"] = resume_phase if resume_phase in {"active", "ending", "round_waiting", "round_incoming"} else "active"
|
|
state.pop("disconnectedAt", None)
|
|
self._send_game_payload(
|
|
state,
|
|
{"type": "START_ACK", "matchId": state["matchId"], "messageId": str(uuid.uuid4())},
|
|
recovery_priority=True,
|
|
)
|
|
if state["phase"] in {"active", "ending"}:
|
|
self._send_missing_sync_moves(state)
|
|
self._flush_game_send_queue(state)
|
|
self.send_event("GAME_STARTED", self._public_state(state))
|
|
|
|
def _starter(self, state: Dict[str, Any]) -> str:
|
|
round_id = state.get("roundId") or state["matchId"]
|
|
requester_nonce = state.get("roundRequesterNonce") or state["requesterNonce"]
|
|
recipient_nonce = state.get("roundRecipientNonce") or state["recipientNonce"]
|
|
prefix = f"qortalland-game:v2:{state.get('game') or 'connect-four'}:".encode("utf-8")
|
|
digest = hashlib.sha256(prefix + uuid.UUID(round_id).bytes + bytes.fromhex(requester_nonce) + bytes.fromhex(recipient_nonce)).digest()
|
|
return "requester" if digest[-1] & 1 == 0 else "recipient"
|
|
|
|
def _initial_state_hash(self, state: Dict[str, Any]) -> str:
|
|
next_seat = 1 if self._starter(state) == "requester" else 2
|
|
if state.get("game") == "checkers":
|
|
board = [0] * 64
|
|
for row in range(3):
|
|
for column in range(8):
|
|
if (row + column) % 2 == 1:
|
|
board[row * 8 + column] = 2
|
|
for row in range(5, 8):
|
|
for column in range(8):
|
|
if (row + column) % 2 == 1:
|
|
board[row * 8 + column] = 1
|
|
value = {
|
|
"board": board,
|
|
"game": "checkers",
|
|
"nextSeat": next_seat,
|
|
"outcome": None,
|
|
"ply": 0,
|
|
"protocolVersion": PROTOCOL_VERSION,
|
|
"quietPly": 0,
|
|
"rulesVersion": state.get("rulesVersion") or 1,
|
|
}
|
|
elif state.get("game") == "chess":
|
|
white_seat = 1 if self._starter(state) == "requester" else 2
|
|
black_seat = 2 if white_seat == 1 else 1
|
|
signed = lambda seat, kind: kind if seat == 1 else -kind
|
|
back_rank = [4, 2, 3, 5, 6, 3, 2, 4]
|
|
board = [0] * 64
|
|
for column, kind in enumerate(back_rank):
|
|
board[column] = signed(black_seat, kind)
|
|
board[8 + column] = signed(black_seat, 1)
|
|
board[48 + column] = signed(white_seat, 1)
|
|
board[56 + column] = signed(white_seat, kind)
|
|
value = {
|
|
"board": board,
|
|
"castlingRights": [True, True, True, True],
|
|
"enPassant": None,
|
|
"game": "chess",
|
|
"halfmoveClock": 0,
|
|
"nextSeat": white_seat,
|
|
"outcome": None,
|
|
"ply": 0,
|
|
"protocolVersion": PROTOCOL_VERSION,
|
|
"rulesVersion": state.get("rulesVersion") or 1,
|
|
"whiteSeat": white_seat,
|
|
}
|
|
else:
|
|
value = {
|
|
"board": [0] * 42,
|
|
"game": "connect-four",
|
|
"nextSeat": next_seat,
|
|
"outcome": None,
|
|
"ply": 0,
|
|
"protocolVersion": PROTOCOL_VERSION,
|
|
"rulesVersion": state.get("rulesVersion") or 1,
|
|
}
|
|
canonical = json.dumps(
|
|
value,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
return hashlib.sha256(canonical).hexdigest()
|
|
|
|
def _send_active(self, message: Dict[str, Any]) -> None:
|
|
match_id = str(message.get("matchId") or "")
|
|
state = self.matches.get(match_id)
|
|
game_message = message.get("message")
|
|
if not state or state.get("phase") not in {"active", "ending", *ROUND_PHASES} or not isinstance(game_message, dict) or game_message.get("type") not in ACTIVE_TYPES:
|
|
raise ValueError("match_not_active")
|
|
game_message = {**game_message, "matchId": match_id}
|
|
message_type = str(game_message.get("type") or "")
|
|
message_id = str(game_message.get("messageId") or "")
|
|
if message_type == "CHAT_MESSAGE":
|
|
message_id, text_value, created_at = self._validate_chat_message(game_message)
|
|
if any(item.get("messageId") == message_id for item in state.get("chatMessages", [])):
|
|
raise ValueError("duplicate_chat_message")
|
|
author = state["requester"] if state.get("outbound") else state["recipient"]
|
|
record = {
|
|
"messageId": message_id, "text": text_value, "createdAt": created_at,
|
|
"authorAddress": author, "delivered": False,
|
|
}
|
|
self._remember_chat(state, record)
|
|
try:
|
|
self._send_chat_chunks(state, message_id, text_value, created_at)
|
|
except Exception:
|
|
state["chatMessages"] = [item for item in state.get("chatMessages", []) if item.get("messageId") != message_id]
|
|
raise
|
|
return
|
|
if message_type in {"CHAT_CHUNK", "CHAT_ACK"}:
|
|
raise ValueError("internal_chat_message")
|
|
if message_type == "CHAT_TYPING":
|
|
if not isinstance(game_message.get("active"), bool):
|
|
raise ValueError("invalid_chat_typing")
|
|
now = time.time()
|
|
if game_message["active"] and now - float(state.get("lastLocalTyping") or 0) < 0.2:
|
|
return
|
|
state["lastLocalTyping"] = now
|
|
self._send_game_payload(state, {
|
|
"type": "CHAT_TYPING", "matchId": match_id, "active": game_message["active"]
|
|
})
|
|
return
|
|
if message_type == "ROUND_REQUEST":
|
|
self._validate_round_control(state, game_message)
|
|
if state.get("phase") == "ending":
|
|
self._finish_round(state)
|
|
if state.get("phase") != "session_idle" or not valid_hex(game_message.get("requesterNonce"), 16):
|
|
raise ValueError("round_not_available")
|
|
state["pendingRound"] = {
|
|
"roundId": game_message["roundId"],
|
|
"requesterNonce": game_message["requesterNonce"],
|
|
"game": game_message["game"],
|
|
"requestedByRemote": False,
|
|
}
|
|
state["phase"] = "round_waiting"
|
|
self._send_game_payload(state, game_message)
|
|
return
|
|
if message_type == "ROUND_RESPONSE":
|
|
pending = state.get("pendingRound") or {}
|
|
if state.get("phase") != "round_incoming" or game_message.get("roundId") != pending.get("roundId"):
|
|
raise ValueError("unexpected_round_response")
|
|
if game_message.get("accepted") is True:
|
|
if not valid_hex(game_message.get("recipientNonce"), 16):
|
|
raise ValueError("invalid_round_nonce")
|
|
self._send_game_payload(state, game_message)
|
|
if game_message.get("accepted") is True:
|
|
self._reset_round(state, pending["roundId"], pending["requesterNonce"], str(game_message.get("recipientNonce") or ""))
|
|
else:
|
|
state["phase"] = "session_idle"
|
|
state.pop("pendingRound", None)
|
|
return
|
|
if message_type == "ROUND_CANCEL":
|
|
pending = state.get("pendingRound") or {}
|
|
if state.get("phase") != "round_waiting" or game_message.get("roundId") != pending.get("roundId"):
|
|
raise ValueError("unexpected_round_cancel")
|
|
self._send_game_payload(state, game_message)
|
|
state["phase"] = "session_idle"
|
|
state.pop("pendingRound", None)
|
|
return
|
|
game_message = {**game_message, "roundId": state.get("roundId")}
|
|
if message_type in {"MOVE", "SYNC_MOVE"}:
|
|
normalized_move = {**game_message, "type": "MOVE"}
|
|
self._validate_move_shape(state, normalized_move)
|
|
if state.setdefault("pendingOutboundMoves", {}) or state.setdefault("pendingInboundMoves", {}):
|
|
raise ValueError("move_already_pending")
|
|
state["pendingOutboundMoves"][message_id] = normalized_move
|
|
try:
|
|
self._send_game_payload(state, game_message)
|
|
except Exception:
|
|
state["pendingOutboundMoves"].pop(message_id, None)
|
|
raise
|
|
return
|
|
if message_type == "MOVE_ACK":
|
|
pending_move = state.setdefault("pendingInboundMoves", {}).get(message_id)
|
|
if pending_move is None:
|
|
cached = state.setdefault("moveAcks", {}).get(message_id)
|
|
if cached == game_message:
|
|
self._send_game_payload(state, cached)
|
|
return
|
|
raise ValueError("unexpected_move_ack")
|
|
if (
|
|
game_message.get("ply") != pending_move.get("ply")
|
|
or game_message.get("stateHash") != pending_move.get("resultingStateHash")
|
|
):
|
|
raise ValueError("conflicting_move_ack")
|
|
transcript = state.setdefault("transcript", [])
|
|
transcript_length = len(transcript)
|
|
self._append_accepted_move(state, pending_move)
|
|
try:
|
|
self._send_game_payload(state, game_message)
|
|
except Exception:
|
|
del transcript[transcript_length:]
|
|
raise
|
|
state["pendingInboundMoves"].pop(message_id, None)
|
|
state.setdefault("moveAcks", {})[message_id] = game_message
|
|
return
|
|
self._send_game_payload(state, game_message)
|
|
if message_type in {"RESIGN", "GAME_OVER"}:
|
|
state["phase"] = "ending"
|
|
self._schedule_terminal_close(match_id, 5.0)
|
|
elif message_type in {"RESIGN_ACK", "GAME_OVER_ACK"}:
|
|
self._finish_round(state)
|
|
|
|
def _protocol_error(self, state: Dict[str, Any], reason: str) -> None:
|
|
self.send_event("GAME_ERROR", {"matchId": state["matchId"], "code": "protocol_error", "message": reason[:120]})
|
|
try:
|
|
self._send_channel(state, {"k": "game", "m": {"type": "PROTOCOL_ERROR", "matchId": state["matchId"], "messageId": str(uuid.uuid4()), "reason": reason[:32]}})
|
|
except Exception:
|
|
pass
|
|
self._close_match(state["matchId"], "protocol_error")
|
|
|
|
def _cancel_or_close_match(self, match_id: str, completed: bool = False) -> None:
|
|
state = self.matches.get(match_id)
|
|
if not state:
|
|
return
|
|
if completed and state.get("phase") == "ending":
|
|
self._close_match(match_id, "completed")
|
|
return
|
|
if state.get("phase") == "awaiting_response" and state.get("channel") is not None:
|
|
try:
|
|
self._send_channel(
|
|
state,
|
|
{"k": "cancel", "matchId": match_id, "reason": "cancelled"},
|
|
)
|
|
except Exception:
|
|
pass
|
|
self._close_match(match_id, "cancelled")
|
|
|
|
def _monitor(self) -> None:
|
|
while not self.stop_event.wait(1):
|
|
self.enqueue(self.proximity.tick, ())
|
|
self.enqueue(self._flush_game_send_queues, ())
|
|
now = time.time()
|
|
for nonce_key, expiry in list(self.used_nonces.items()):
|
|
if expiry <= now:
|
|
self.used_nonces.pop(nonce_key, None)
|
|
for challenge_id, challenge in list(self.signature_challenges.items()):
|
|
if now - float(challenge.get("created") or 0) >= INVITE_TTL:
|
|
self.signature_challenges.pop(challenge_id, None)
|
|
with self.lock:
|
|
states = list(self.matches.values())
|
|
for state in states:
|
|
assemblies = state.get("inboundChatChunks") or {}
|
|
for message_id, assembly in list(assemblies.items()):
|
|
if now - float(assembly.get("started") or now) >= RECOVERY_WINDOW:
|
|
assemblies.pop(message_id, None)
|
|
if (
|
|
state.get("phase") not in {
|
|
"active",
|
|
"recovering",
|
|
"awaiting_resume_accept",
|
|
"awaiting_resume_confirm", "session_idle", "round_waiting", "round_incoming", "ending",
|
|
}
|
|
and now * 1000 >= state.get("expiresAt", 0)
|
|
):
|
|
self._close_match(state["matchId"], "expired")
|
|
continue
|
|
if state.get("phase") in {"active", "session_idle", "round_waiting", "round_incoming", "ending"}:
|
|
last_rx = float(state.get("lastRx") or now)
|
|
if now - last_rx >= HEARTBEAT_INTERVAL and now - float(state.get("lastPing") or 0) >= HEARTBEAT_INTERVAL:
|
|
try:
|
|
ping_id = str(uuid.uuid4())
|
|
self._send_game_payload(state, {"type": "MATCH_PING", "matchId": state["matchId"], "messageId": ping_id})
|
|
state["lastPing"] = now
|
|
except Exception:
|
|
pass
|
|
if now - last_rx >= RECOVERY_WINDOW:
|
|
if state.get("outbound"):
|
|
self._teardown(state.get("link"))
|
|
else:
|
|
state["phase"] = "recovering"
|
|
state["disconnectedAt"] = now
|
|
self.send_event("GAME_LINK_STATE", {"matchId": state["matchId"], "state": "recovering", "deadlineAt": int((now + RECOVERY_WINDOW) * 1000)})
|
|
elif state.get("renderer_lost_at") and now - state["renderer_lost_at"] >= RECOVERY_WINDOW:
|
|
self._close_match(state["matchId"], "abandoned")
|
|
elif state.get("phase") in {"recovering", "awaiting_resume_accept", "awaiting_resume_confirm"}:
|
|
if now - float(state.get("disconnectedAt") or now) >= RECOVERY_WINDOW:
|
|
self._close_match(state["matchId"], "abandoned")
|
|
|
|
def _establish_timeout(self, match_id: str) -> None:
|
|
state = self.matches.get(match_id)
|
|
if state and state.get("phase") == "establishing":
|
|
self.log(
|
|
f"[qortalland-game] link timeout match={match_id[:8]} peer={str(state.get('peerHash') or '')[:8]} attempts={int(state.get('openAttempts') or 0)}"
|
|
)
|
|
self._close_match(match_id, "establishment_timeout")
|
|
|
|
def _link_closed(self, link) -> None:
|
|
state = self._state_for_link(link)
|
|
if not state:
|
|
return
|
|
if state.get("phase") == "establishing" and state.get("link") is link:
|
|
with self.lock:
|
|
self.links_by_object.pop(id(link), None)
|
|
state["link"] = None
|
|
self.log(
|
|
f"[qortalland-game] link attempt closed match={state['matchId'][:8]} peer={str(state.get('peerHash') or '')[:8]} attempt={int(state.get('openAttempts') or 0)}"
|
|
)
|
|
if time.time() < float(state.get("establishDeadline") or 0):
|
|
try:
|
|
refreshed = False
|
|
if self.refresh_path is not None:
|
|
refreshed = self.refresh_path(
|
|
state["peerHash"],
|
|
"game_link_attempt_closed",
|
|
) is True
|
|
if not refreshed:
|
|
RNS.Transport.request_path(bytes.fromhex(str(state["peerHash"])))
|
|
except Exception:
|
|
pass
|
|
self._schedule_open_retry(state["matchId"])
|
|
else:
|
|
self._close_match(state["matchId"], "establishment_timeout", teardown=False)
|
|
return
|
|
if state.get("phase") == "session_idle":
|
|
self._close_match(state["matchId"], "link_closed", teardown=False)
|
|
return
|
|
if state.get("phase") == "recovering" and state.get("link") is link:
|
|
with self.lock:
|
|
self.links_by_object.pop(id(link), None)
|
|
state["link"] = None
|
|
if state.get("outbound") and time.time() - float(state.get("disconnectedAt") or 0) < RECOVERY_WINDOW:
|
|
if self.refresh_path is not None:
|
|
try:
|
|
self.refresh_path(state["peerHash"], "game_resume_link_closed")
|
|
except Exception:
|
|
pass
|
|
timer = threading.Timer(1.0, self._reopen_for_resume, args=(state["matchId"],))
|
|
timer.daemon = True
|
|
timer.start()
|
|
else:
|
|
self._close_match(state["matchId"], "link_closed", teardown=False)
|
|
return
|
|
if state.get("phase") in {"active", "ending", "round_waiting", "round_incoming", "awaiting_resume_accept", "awaiting_resume_confirm", "awaiting_start_ack"}:
|
|
with self.lock:
|
|
self.links_by_object.pop(id(link), None)
|
|
state["link"] = None
|
|
state["resumeReturnPhase"] = state.get("resumeReturnPhase") or state.get("phase")
|
|
state["phase"] = "recovering"
|
|
state.setdefault("disconnectedAt", time.time())
|
|
# The renderer rebuilds unacknowledged state from the authenticated
|
|
# resume snapshot. Deferred MOVE payloads remain queued and restore
|
|
# their pending entry immediately before retransmission.
|
|
state.setdefault("pendingInboundMoves", {}).clear()
|
|
state.setdefault("pendingOutboundMoves", {}).clear()
|
|
self.send_event("GAME_LINK_STATE", {"matchId": state["matchId"], "state": "recovering", "deadlineAt": int((time.time() + RECOVERY_WINDOW) * 1000)})
|
|
# Resume authentication is intentionally surfaced, never silently trusted.
|
|
self.send_event("GAME_ERROR", {"matchId": state["matchId"], "code": "resume_required", "message": "Private link interrupted"})
|
|
if state.get("outbound"):
|
|
if self.refresh_path is not None:
|
|
try:
|
|
self.refresh_path(state["peerHash"], "game_resume_link_closed")
|
|
except Exception:
|
|
pass
|
|
timer = threading.Timer(1.0, self._reopen_for_resume, args=(state["matchId"],))
|
|
timer.daemon = True
|
|
timer.start()
|
|
else:
|
|
self._close_match(state["matchId"], "link_closed", teardown=False)
|
|
|
|
def _reopen_for_resume(self, match_id: str) -> None:
|
|
state = self.matches.get(match_id)
|
|
if (
|
|
not state
|
|
or state.get("phase") != "recovering"
|
|
or state.get("link") is not None
|
|
or time.time() - float(state.get("disconnectedAt") or 0) >= RECOVERY_WINDOW
|
|
):
|
|
return
|
|
try:
|
|
identity = self.resolve_identity(state["peerHash"])
|
|
if identity is None:
|
|
raise ValueError("resume_identity_unavailable")
|
|
destination = self.build_destination(identity)
|
|
destination_hash = bytes(destination.hash)
|
|
if destination_hash.hex() != str(state["peerHash"]).lower():
|
|
raise ValueError("resume_destination_mismatch")
|
|
if not self.path_available(destination_hash):
|
|
refreshed = False
|
|
if self.refresh_path is not None:
|
|
refreshed = self.refresh_path(
|
|
state["peerHash"],
|
|
"game_resume_no_path",
|
|
) is True
|
|
if not refreshed:
|
|
RNS.Transport.request_path(destination_hash)
|
|
raise ValueError("resume_path_unavailable")
|
|
link = RNS.Link(destination, established_callback=self._resume_outbound_established, closed_callback=self._link_closed)
|
|
old_link = state.get("link")
|
|
with self.lock:
|
|
if old_link is not None:
|
|
self.links_by_object.pop(id(old_link), None)
|
|
self.links_by_object[id(link)] = match_id
|
|
state["link"] = link
|
|
except Exception:
|
|
timer = threading.Timer(2.0, self._reopen_for_resume, args=(match_id,))
|
|
timer.daemon = True
|
|
timer.start()
|
|
|
|
def _resume_outbound_established(self, link) -> None:
|
|
state = self._state_for_link(link)
|
|
if not state or state.get("phase") != "recovering":
|
|
return
|
|
if not self._identify_outbound_link(state, link):
|
|
return
|
|
state["linkId"] = self.link_id_bytes(link).hex()
|
|
state["resumeRequesterNonce"] = secrets.token_hex(16)
|
|
state["lastActivity"] = time.time()
|
|
self._configure_channel(state)
|
|
context = self.land_context or {}
|
|
fields = {
|
|
"type": "QORTAL_LAND_GAME_RESUME_REQUEST",
|
|
"matchId": state["matchId"],
|
|
"roundId": state.get("roundId") or state["matchId"],
|
|
"requesterAddress": state["requester"],
|
|
"signerPublicKey": context.get("publicKey"),
|
|
"linkId": state["linkId"],
|
|
"requesterNonce": state["resumeRequesterNonce"],
|
|
**self._resume_state_fields(state),
|
|
"createdAt": int(time.time() * 1000),
|
|
}
|
|
self._require_signature(state, fields["type"], fields)
|
|
|
|
def _identify_outbound_link(self, state: Dict[str, Any], link) -> bool:
|
|
"""Prove the initiating Reticulum endpoint before signed game traffic."""
|
|
try:
|
|
if self.identify_link is None:
|
|
raise RuntimeError("link_identity_unavailable")
|
|
self.identify_link(link)
|
|
return True
|
|
except Exception as exc:
|
|
match_id = str(state.get("matchId") or "")
|
|
self.log(
|
|
f"[qortalland-game] link identify failed match={match_id[:8]} "
|
|
f"peer={str(state.get('peerHash') or '')[:8]} code={str(exc)[:80]}"
|
|
)
|
|
self.send_event(
|
|
"GAME_ERROR",
|
|
{
|
|
"matchId": match_id,
|
|
"code": "link_identity_failed",
|
|
"message": "Could not authenticate the private game link",
|
|
},
|
|
)
|
|
self._close_match(match_id, "link_identity_failed")
|
|
return False
|
|
|
|
def _state_for_link(self, link) -> Optional[Dict[str, Any]]:
|
|
with self.lock:
|
|
match_id = self.links_by_object.get(id(link))
|
|
return self.matches.get(match_id) if match_id else None
|
|
|
|
def _public_state(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
pending_outbound = list(state.get("pendingOutboundMoves", {}).values())
|
|
return {
|
|
"matchId": state["matchId"],
|
|
"game": state.get("game") or "connect-four",
|
|
"gameVersion": state.get("gameVersion") or 1,
|
|
"rulesVersion": state.get("rulesVersion") or 1,
|
|
"requesterAddress": state["requester"],
|
|
"recipientAddress": state["recipient"],
|
|
"requesterSessionId": state.get("sourceSessionId"),
|
|
"recipientSessionId": state.get("targetSessionId"),
|
|
"requesterDestinationHash": state.get("sourceDestinationHash"),
|
|
"recipientDestinationHash": state.get("targetDestinationHash"),
|
|
"requesterNonce": state.get("roundRequesterNonce") or state["requesterNonce"],
|
|
"recipientNonce": state.get("roundRecipientNonce") or state.get("recipientNonce"),
|
|
"starter": self._starter(state) if state.get("recipientNonce") else None,
|
|
"phase": state.get("phase"),
|
|
"expiresAt": state.get("expiresAt"),
|
|
"transcript": list(state.get("transcript") or []),
|
|
"pendingOutboundMove": pending_outbound[0] if pending_outbound else None,
|
|
"pendingRound": dict(state.get("pendingRound") or {}),
|
|
}
|
|
|
|
def _chat_history_batches(self, match_id: str) -> list[Dict[str, Any]]:
|
|
state = self.matches.get(match_id)
|
|
history = [dict(item) for item in (state or {}).get("chatMessages", [])]
|
|
return [
|
|
{"type": "GAME_CHAT_HISTORY", "matchId": match_id, "messages": history[index:index + 2]}
|
|
for index in range(0, len(history), 2)
|
|
]
|
|
|
|
def _active_snapshot(self) -> Optional[Dict[str, Any]]:
|
|
with self.lock:
|
|
state = next((s for s in self.matches.values() if s.get("phase") not in {"closed", "ended"}), None)
|
|
if not state:
|
|
return None
|
|
return self._public_state(state)
|
|
|
|
def _close_match(self, match_id: str, reason: str, teardown: bool = True) -> None:
|
|
with self.lock:
|
|
state = self.matches.pop(match_id, None)
|
|
if not state:
|
|
return
|
|
link = state.get("link")
|
|
if link is not None:
|
|
self.links_by_object.pop(id(link), None)
|
|
for challenge_id, challenge in list(self.signature_challenges.items()):
|
|
if challenge.get("matchId") == match_id:
|
|
self.signature_challenges.pop(challenge_id, None)
|
|
timer = state.get("establishTimer")
|
|
if timer:
|
|
timer.cancel()
|
|
retry_timer = state.get("openRetryTimer")
|
|
if retry_timer:
|
|
retry_timer.cancel()
|
|
if teardown:
|
|
self._teardown(link)
|
|
self.send_event("GAME_ENDED", {"matchId": match_id, "outcome": reason})
|
|
|
|
@staticmethod
|
|
def _send_raw(link, raw: bytes) -> bool:
|
|
try:
|
|
packet = RNS.Packet(link, raw)
|
|
return packet.send() is not None
|
|
except Exception:
|
|
return False
|
|
|
|
@staticmethod
|
|
def _teardown(link) -> None:
|
|
if link is not None:
|
|
try:
|
|
link.teardown()
|
|
except Exception:
|
|
pass
|