Files
GO-3.0/electron/resources/qortalland_games_test.py

1251 lines
48 KiB
Python

import json
import threading
import time
import unittest
from collections import deque
from unittest import mock
import RNS
from RNS.Channel import CEType, ChannelException
from qortalland_games import (
GameMessage,
MAGIC,
MAX_CHANNEL_PAYLOAD,
QortalLandGameManager,
_b58encode,
canonical_bytes,
derive_qortal_address,
umsgpack,
verify_signature,
)
class QortalLandGameProtocolTest(unittest.TestCase):
def setUp(self):
self.manager = object.__new__(QortalLandGameManager)
self.private_key = RNS.Cryptography.Ed25519PrivateKey.generate()
self.public_key = _b58encode(self.private_key.public_key().public_bytes())
self.address = derive_qortal_address(self.public_key)
def make_manager(self, identify_link=lambda _link: None):
events = []
manager = QortalLandGameManager(
emit=lambda *_args: None,
log=lambda *_args: None,
resolve_peer=lambda _address, preferred="": preferred or "11" * 16,
resolve_identity=lambda _peer: object(),
build_destination=lambda identity: identity,
link_id_bytes=lambda _link: b"\x22" * 16,
enqueue=lambda fn, args: bool(fn(*args) is None or True),
resolve_link_peer_hash=lambda _link: "11" * 16,
local_destination_hash=lambda: "aa" * 16,
identify_link=identify_link,
)
manager.send_event = lambda event, payload=None: events.append((event, payload or {}))
return manager, events
def test_missing_path_uses_configured_route_recovery(self):
refreshes = []
class Destination:
hash = bytes.fromhex("11" * 16)
manager, _events = self.make_manager()
manager.build_destination = lambda _identity: Destination()
manager.refresh_path = lambda peer, reason: refreshes.append((peer, reason)) or False
match_id = "00112233-4455-6677-8899-aabbccddeeff"
manager.matches[match_id] = {
"matchId": match_id,
"peerHash": "11" * 16,
"phase": "establishing",
"establishDeadline": time.time() + 30,
"openAttempts": 0,
}
manager._schedule_open_retry = mock.Mock()
with mock.patch.object(RNS.Transport, "has_path", return_value=False), mock.patch.object(
RNS.Transport, "request_path"
) as request_path:
manager._attempt_open(match_id)
self.assertEqual(refreshes, [("11" * 16, "game_link_no_path")])
request_path.assert_called_once_with(bytes.fromhex("11" * 16))
manager._schedule_open_retry.assert_called_once_with(match_id)
def test_authoritative_shared_path_starts_link_without_refresh(self):
refreshes = []
class Destination:
hash = bytes.fromhex("11" * 16)
manager, _events = self.make_manager()
manager.build_destination = lambda _identity: Destination()
manager.path_available = lambda _destination_hash: True
manager.refresh_path = lambda peer, reason: refreshes.append((peer, reason)) or False
match_id = "00112233-4455-6677-8899-aabbccddeeff"
manager.matches[match_id] = {
"matchId": match_id,
"peerHash": "11" * 16,
"phase": "establishing",
"establishDeadline": time.time() + 30,
"openAttempts": 0,
}
link = object()
with mock.patch.object(RNS.Transport, "has_path", return_value=False), mock.patch.object(
RNS, "Link", return_value=link
) as open_link:
manager._attempt_open(match_id)
open_link.assert_called_once()
self.assertEqual(manager.matches[match_id]["link"], link)
self.assertEqual(manager.matches[match_id]["openAttempts"], 1)
self.assertEqual(refreshes, [])
def test_missing_identity_refreshes_route_and_retries(self):
refreshes = []
manager, _events = self.make_manager()
manager.resolve_identity = lambda _peer: None
manager.refresh_path = lambda peer, reason: refreshes.append((peer, reason)) or True
match_id = "00112233-4455-6677-8899-aabbccddeeff"
manager.matches[match_id] = {
"matchId": match_id,
"peerHash": "11" * 16,
"phase": "establishing",
"establishDeadline": time.time() + 30,
"openAttempts": 0,
}
manager._schedule_open_retry = mock.Mock()
with mock.patch.object(RNS.Transport, "request_path") as request_path:
manager._attempt_open(match_id)
self.assertEqual(refreshes, [("11" * 16, "game_identity_unavailable")])
request_path.assert_not_called()
manager._schedule_open_retry.assert_called_once_with(match_id)
def test_outbound_game_link_identifies_before_signed_handshake(self):
order = []
manager, _events = self.make_manager(
identify_link=lambda _link: order.append("identify")
)
link = object()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
manager.matches[match_id] = {
"matchId": match_id,
"peerHash": "11" * 16,
"phase": "establishing",
"openAttempts": 1,
}
manager.links_by_object[id(link)] = match_id
manager._configure_channel = lambda _state: order.append("channel")
manager._invite_fields = lambda _state: {}
manager._require_signature = lambda *_args: order.append("signature")
manager._outbound_established(link)
self.assertEqual(order, ["identify", "channel", "signature"])
def test_outbound_game_link_identity_failure_stops_handshake(self):
manager, events = self.make_manager(
identify_link=lambda _link: (_ for _ in ()).throw(RuntimeError("no identity"))
)
link = object()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
manager.matches[match_id] = {
"matchId": match_id,
"peerHash": "11" * 16,
"phase": "establishing",
"openAttempts": 1,
"link": link,
}
manager.links_by_object[id(link)] = match_id
manager._configure_channel = mock.Mock()
manager._teardown = mock.Mock()
manager._outbound_established(link)
manager._configure_channel.assert_not_called()
manager._teardown.assert_called_once_with(link)
self.assertNotIn(match_id, manager.matches)
self.assertTrue(
any(
event == "GAME_ENDED" and payload.get("outcome") == "link_identity_failed"
for event, payload in events
)
)
def test_socket_writer_drains_media_without_waiting_on_empty_control_queue(self):
manager, _events = self.make_manager()
class Socket:
def __init__(self):
self.frames = []
def send(self, frame):
self.frames.append(frame)
def close(self, *_args):
pass
socket = Socket()
manager.socket = socket
for index in range(8):
self.assertTrue(manager.send_binary(bytes([index]), source_id=1))
writer = threading.Thread(target=manager._socket_writer, daemon=True)
writer.start()
deadline = time.monotonic() + 0.1
while len(socket.frames) < 8 and time.monotonic() < deadline:
time.sleep(0.001)
manager.stop_event.set()
manager.socket_writer_wakeup.set()
writer.join(timeout=0.2)
self.assertEqual(socket.frames, [bytes([index]) for index in range(8)])
self.assertEqual(manager.proximity.stats["rendererQueueDrops"], 0)
def test_failed_link_attempt_hard_refreshes_before_retry(self):
refreshes = []
manager, _events = self.make_manager()
manager.refresh_path = lambda peer, reason: refreshes.append((peer, reason)) or True
match_id = "00112233-4455-6677-8899-aabbccddeeff"
link = object()
manager.matches[match_id] = {
"matchId": match_id,
"peerHash": "11" * 16,
"phase": "establishing",
"establishDeadline": time.time() + 30,
"openAttempts": 1,
"link": link,
}
manager.links_by_object[id(link)] = match_id
manager._schedule_open_retry = mock.Mock()
with mock.patch.object(RNS.Transport, "request_path") as request_path:
manager._link_closed(link)
self.assertEqual(refreshes, [("11" * 16, "game_link_attempt_closed")])
request_path.assert_not_called()
manager._schedule_open_retry.assert_called_once_with(match_id)
def test_qortal_address_cross_language_fixture(self):
self.assertEqual(
derive_qortal_address("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"),
"QhxqB8rvXYDguai48oNNjfRCUigaXHmf8Q",
)
def test_signature_verification_rejects_altered_fields(self):
fields = {"address": self.address, "matchId": "match", "type": "fixture"}
signature = _b58encode(self.private_key.sign(canonical_bytes(fields)))
self.assertTrue(verify_signature(fields, self.public_key, signature))
self.assertFalse(verify_signature({**fields, "matchId": "other"}, self.public_key, signature))
def test_initial_state_hash_matches_typescript_fixture(self):
state = {
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"requesterNonce": "11" * 16,
"recipientNonce": "22" * 15 + "00",
}
# This seed selects the requester (seat 1).
self.assertEqual(self.manager._starter(state), "requester")
self.assertEqual(
self.manager._initial_state_hash(state),
"c095c107701a8a8137e036b8e917173b93663115cde740dd29500c600ca77aaf",
)
def test_checkers_initial_state_hash_matches_typescript_fixture(self):
state = {
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"roundId": "00112233-4455-6677-8899-aabbccddeeff",
"requesterNonce": "11" * 16,
"recipientNonce": "22" * 15 + "00",
"game": "checkers",
"rulesVersion": 1,
}
self.assertEqual(self.manager._starter(state), "requester")
self.assertEqual(
self.manager._initial_state_hash(state),
"d8f380b461ea12fe5c662de0ba7c5707de3afdf36ec1f9d4222719b6b22cad7e",
)
def test_chess_initial_state_hash_matches_typescript_fixture(self):
state = {
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"roundId": "00112233-4455-6677-8899-aabbccddeeff",
"requesterNonce": "11" * 16,
"recipientNonce": "22" * 15 + "00",
"game": "chess",
"rulesVersion": 1,
}
self.assertEqual(self.manager._starter(state), "recipient")
self.assertEqual(
self.manager._initial_state_hash(state),
"cc48133f2305d376d6d48e9e858239ae9ea6db7af7692c607d68b0af8a70a8bb",
)
def test_checkers_move_shape_supports_paths_and_rejects_columns(self):
state = {
"game": "checkers", "roundId": "00112233-4455-6677-8899-aabbccddeeff",
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"requesterNonce": "11" * 16, "recipientNonce": "22" * 15 + "00",
"transcript": [],
}
move = {
"messageId": "11112233-4455-4677-8899-aabbccddeeff", "ply": 1,
"from": 42, "path": [24, 10],
"previousStateHash": self.manager._initial_state_hash(state),
"resultingStateHash": "33" * 32,
}
self.manager._validate_move_shape(state, move)
with self.assertRaisesRegex(ValueError, "invalid_checkers_move"):
self.manager._validate_move_shape(state, {**move, "path": []})
def test_chess_move_shape_supports_promotion(self):
state = {
"game": "chess", "roundId": "00112233-4455-6677-8899-aabbccddeeff",
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"requesterNonce": "11" * 16, "recipientNonce": "22" * 15 + "00",
"transcript": [],
}
move = {
"messageId": "11112233-4455-4677-8899-aabbccddeeff", "ply": 1,
"from": 8, "to": 0, "promotion": 5,
"previousStateHash": self.manager._initial_state_hash(state),
"resultingStateHash": "33" * 32,
}
self.manager._validate_move_shape(state, move)
with self.assertRaisesRegex(ValueError, "invalid_chess_move"):
self.manager._validate_move_shape(state, {**move, "promotion": 6})
with self.assertRaisesRegex(ValueError, "invalid_chess_move"):
self.manager._validate_move_shape(state, {**move, "promotion": []})
with self.assertRaisesRegex(ValueError, "invalid_chess_move"):
self.manager._validate_move_shape(state, {**move, "promotion": True})
def test_compact_invite_round_trip_fits_classifier_packet(self):
fields = {
"type": "QORTAL_LAND_GAME_INVITE",
"protocolVersion": 2,
"game": "connect-four",
"gameVersion": 1,
"rulesVersion": 1,
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"groupId": "123",
"requesterAddress": self.address,
"recipientAddress": self.address,
"sourceSessionId": "source-session",
"targetSessionId": "target-session",
"sourceDestinationHash": "11" * 16,
"targetDestinationHash": "aa" * 16,
"signerPublicKey": self.public_key,
"requesterNonce": "11" * 16,
"linkId": "22" * 16,
"createdAt": 1_760_000_000_000,
"expiresAt": 1_760_000_060_000,
}
envelope = {
"fields": fields,
"publicKey": self.public_key,
"signature": _b58encode(self.private_key.sign(canonical_bytes(fields))),
}
packed = self.manager._encode_handshake(envelope)
raw = MAGIC + umsgpack.packb(packed)
self.assertLessEqual(len(raw), MAX_CHANNEL_PAYLOAD)
self.assertEqual(self.manager._decode_handshake(umsgpack.unpackb(raw[4:])), envelope)
def test_open_game_link_uses_selected_land_endpoint_when_peer_cache_lags(self):
manager, _events = self.make_manager()
manager.land_context = {
"address": self.address,
"publicKey": self.public_key,
"groupId": "123",
"landSessionId": "source-session",
"roomId": "lounge",
"localDestinationHash": "aa" * 16,
}
manager.resolve_peer = mock.Mock(return_value=None)
manager.resolve_identity = mock.Mock(return_value=object())
manager._attempt_open = mock.Mock()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
manager._open({
"matchId": match_id,
"recipientAddress": "QRecipientAddress11111111111111111",
"targetSessionId": "target-session",
"targetDestinationHash": "22" * 16,
"requesterNonce": "33" * 16,
"game": "connect-four",
})
manager.resolve_peer.assert_called_once_with(
"QRecipientAddress11111111111111111", "22" * 16
)
state = manager.matches[match_id]
self.assertEqual(state["sourceSessionId"], "source-session")
self.assertEqual(state["targetSessionId"], "target-session")
self.assertEqual(state["sourceDestinationHash"], "aa" * 16)
self.assertEqual(state["targetDestinationHash"], "22" * 16)
state["establishTimer"].cancel()
def test_invite_validation_binds_both_land_sessions_and_link_endpoints(self):
manager, _events = self.make_manager()
requester_key = RNS.Cryptography.Ed25519PrivateKey.generate()
requester_public = _b58encode(requester_key.public_key().public_bytes())
requester_address = derive_qortal_address(requester_public)
now = int(time.time() * 1000)
manager.land_context = {
"address": self.address,
"publicKey": self.public_key,
"groupId": "123",
"landSessionId": "target-session",
"roomId": "lounge",
"localDestinationHash": "aa" * 16,
}
manager.resolve_peer = lambda _address, preferred="": None
manager.resolve_link_peer_hash = lambda _link: "11" * 16
fields = {
"type": "QORTAL_LAND_GAME_INVITE",
"protocolVersion": 2,
"game": "connect-four",
"gameVersion": 1,
"rulesVersion": 1,
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"groupId": "123",
"requesterAddress": requester_address,
"recipientAddress": self.address,
"sourceSessionId": "source-session",
"targetSessionId": "target-session",
"sourceDestinationHash": "11" * 16,
"targetDestinationHash": "aa" * 16,
"signerPublicKey": requester_public,
"requesterNonce": "33" * 16,
"linkId": "22" * 16,
"createdAt": now,
"expiresAt": now + 60_000,
}
def envelope(candidate):
return {
"fields": candidate,
"publicKey": requester_public,
"signature": _b58encode(
requester_key.sign(canonical_bytes(candidate))
),
}
manager._validate_invite(fields, envelope(fields), object())
manager.used_nonces.clear()
wrong_session = {**fields, "targetSessionId": "other-session"}
with self.assertRaisesRegex(ValueError, "wrong_recipient"):
manager._validate_invite(
wrong_session, envelope(wrong_session), object()
)
manager.used_nonces.clear()
wrong_endpoint = {**fields, "sourceDestinationHash": "33" * 16}
with self.assertRaisesRegex(ValueError, "unverified_peer"):
manager._validate_invite(
wrong_endpoint, envelope(wrong_endpoint), object()
)
def test_compact_resume_request_binds_round_and_fits_classifier_packet(self):
fields = {
"type": "QORTAL_LAND_GAME_RESUME_REQUEST",
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"roundId": "11112233-4455-4677-8899-aabbccddeeff",
"requesterAddress": self.address,
"signerPublicKey": self.public_key,
"linkId": "22" * 16,
"sourceSessionId": "source-session",
"targetSessionId": "target-session",
"sourceDestinationHash": "11" * 16,
"targetDestinationHash": "aa" * 16,
"requesterNonce": "33" * 16,
"lastAcknowledgedPly": 4,
"stateHash": "44" * 32,
"transcriptHash": "55" * 32,
"createdAt": int(time.time() * 1000),
}
envelope = {
"fields": fields,
"publicKey": self.public_key,
"signature": _b58encode(self.private_key.sign(canonical_bytes(fields))),
}
raw = MAGIC + umsgpack.packb(self.manager._encode_handshake(envelope))
self.assertLessEqual(len(raw), MAX_CHANNEL_PAYLOAD)
self.assertEqual(self.manager._decode_handshake(umsgpack.unpackb(raw[4:])), envelope)
def test_signed_resume_accept_keeps_resume_confirmation_phase(self):
manager, _events = self.make_manager()
class Channel:
def send(self, message):
message.pack()
return object()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
round_id = "11112233-4455-4677-8899-aabbccddeeff"
fields = {
"type": "QORTAL_LAND_GAME_RESUME_ACCEPT",
"matchId": match_id,
"roundId": round_id,
"responderAddress": self.address,
"signerPublicKey": self.public_key,
"linkId": "22" * 16,
"requesterNonce": "33" * 16,
"recipientNonce": "44" * 16,
"lastAcknowledgedPly": 0,
"stateHash": "55" * 32,
"transcriptHash": "66" * 32,
"createdAt": int(time.time() * 1000),
}
challenge_id = "resume-challenge"
manager.matches[match_id] = {
"matchId": match_id,
"roundId": round_id,
"phase": "awaiting_resume_confirm",
"channel": Channel(),
"lastActivity": time.time(),
}
manager.signature_challenges[challenge_id] = {
"matchId": match_id,
"kind": fields["type"],
"fields": fields,
"created": time.time(),
}
manager._submit_signature({
"challengeId": challenge_id,
"publicKey": self.public_key,
"signature": _b58encode(self.private_key.sign(canonical_bytes(fields))),
})
self.assertEqual(manager.matches[match_id]["phase"], "awaiting_resume_confirm")
def test_outgoing_move_enters_transcript_only_after_remote_ack(self):
manager, events = self.make_manager()
class Channel:
def send(self, _message):
return object()
state = {
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"requester": self.address,
"recipient": self.address,
"requesterNonce": "11" * 16,
"recipientNonce": "22" * 15 + "00",
"phase": "active",
"channel": Channel(),
"transcript": [],
"lastActivity": 0,
}
manager.matches[state["matchId"]] = state
move = {
"type": "MOVE",
"messageId": "10000000-0000-4000-8000-000000000001",
"ply": 1,
"column": 3,
"previousStateHash": manager._initial_state_hash(state),
"resultingStateHash": "aa" * 32,
}
manager._send_active({"matchId": state["matchId"], "message": move})
self.assertEqual(state["transcript"], [])
manager._on_channel(
state["matchId"],
GameMessage({
"k": "game",
"m": {
"type": "MOVE_ACK",
"matchId": state["matchId"],
"messageId": move["messageId"],
"ply": 1,
"stateHash": move["resultingStateHash"],
},
}),
)
self.assertEqual(len(state["transcript"]), 1)
self.assertEqual(events[-1][0], "GAME_MESSAGE")
def test_incoming_move_enters_transcript_only_after_local_ack(self):
manager, _events = self.make_manager()
class Channel:
def send(self, _message):
return object()
state = {
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"requester": self.address,
"recipient": self.address,
"requesterNonce": "11" * 16,
"recipientNonce": "22" * 15 + "00",
"phase": "active",
"channel": Channel(),
"transcript": [],
"lastActivity": 0,
}
manager.matches[state["matchId"]] = state
move = {
"type": "MOVE",
"matchId": state["matchId"],
"messageId": "10000000-0000-4000-8000-000000000002",
"ply": 1,
"column": 2,
"previousStateHash": manager._initial_state_hash(state),
"resultingStateHash": "bb" * 32,
}
manager._on_channel(state["matchId"], GameMessage({"k": "game", "m": move}))
self.assertEqual(state["transcript"], [])
manager._send_active({
"matchId": state["matchId"],
"message": {
"type": "MOVE_ACK",
"messageId": move["messageId"],
"ply": 1,
"stateHash": move["resultingStateHash"],
},
})
self.assertEqual(len(state["transcript"]), 1)
def test_websocket_authenticates_before_accepting_context(self):
manager, _events = self.make_manager()
manager.token = "token"
manager.instance_id = "instance"
class Request:
headers = {"Origin": "capacitor-electron://-"}
class Socket:
request = Request()
def __init__(self, token):
self.token = token
self.closed = None
self.sent = []
def recv(self, timeout=None):
self.timeout = timeout
return json.dumps({"type": "AUTH", "token": self.token, "instanceId": "instance"})
def __iter__(self):
return iter([json.dumps({
"type": "SET_LAND_CONTEXT",
"requestId": "request",
"address": self_address,
"publicKey": self_public_key,
"groupId": "1",
"landSessionId": "land",
"roomId": "room",
"localDestinationHash": "aa" * 16,
})])
def send(self, value):
self.sent.append(value)
def close(self, code, reason):
self.closed = (code, reason)
self_address = self.address
self_public_key = self.public_key
invalid = Socket("wrong")
manager._socket_handler(invalid)
self.assertIsNotNone(invalid.closed)
self.assertIsNone(manager.land_context)
valid = Socket("token")
manager._socket_handler(valid)
self.assertEqual(manager.land_context["address"], self.address)
self.assertEqual(valid.timeout, 2.0)
def test_websocket_closes_when_ready_state_cannot_be_delivered(self):
manager, _events = self.make_manager()
manager.token = "token"
manager.instance_id = "instance"
class Request:
headers = {"Origin": "capacitor-electron://-"}
class Socket:
request = Request()
def __init__(self):
self.closed = None
def recv(self, timeout=None):
return json.dumps({"type": "AUTH", "token": "token", "instanceId": "instance"})
def send(self, _value):
raise OSError("socket closed")
def close(self, code, reason):
self.closed = (code, reason)
socket = Socket()
manager._socket_handler(socket)
self.assertEqual(socket.closed, (1013, "transport ready delivery failed"))
self.assertIsNone(manager.socket)
def test_development_origin_parsing_rejects_non_loopback_userinfo(self):
manager, _events = self.make_manager()
manager.development = True
self.assertTrue(manager._origin_allowed("http://127.0.0.1:5173"))
self.assertTrue(manager._origin_allowed("http://localhost:5173"))
self.assertFalse(manager._origin_allowed("http://localhost:5173.example.test"))
self.assertFalse(manager._origin_allowed("http://localhost:5173@evil.example"))
manager.development = False
self.assertFalse(manager._origin_allowed("http://127.0.0.1:5173"))
def test_snapshot_preserves_an_unacknowledged_outbound_move(self):
manager, _events = self.make_manager()
pending = {"messageId": "10000000-0000-4000-8000-000000000003", "ply": 1}
state = {
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"requester": self.address,
"recipient": self.address,
"requesterNonce": "11" * 16,
"recipientNonce": "22" * 15 + "00",
"phase": "active",
"transcript": [],
"pendingOutboundMoves": {pending["messageId"]: pending},
}
self.assertEqual(manager._public_state(state)["pendingOutboundMove"], pending)
def test_busy_reliable_channel_defers_move_without_ending_match(self):
manager, events = self.make_manager()
class Channel:
def __init__(self):
self.ready = False
self.sent = []
def send(self, message):
if not self.ready:
raise ChannelException(CEType.ME_LINK_NOT_READY, "Link is not ready")
self.sent.append(message.payload)
return object()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
channel = Channel()
state = {
"matchId": match_id,
"roundId": match_id,
"game": "connect-four",
"rulesVersion": 1,
"requester": self.address,
"recipient": "Qopponent1111111111111111111111111111",
"requesterNonce": "11" * 16,
"recipientNonce": "22" * 16,
"phase": "active",
"channel": channel,
"transcript": [],
"pendingOutboundMoves": {},
"pendingInboundMoves": {},
}
manager.matches[match_id] = state
message_id = "10000000-0000-4000-8000-000000000020"
move = {
"type": "MOVE",
"messageId": message_id,
"ply": 1,
"column": 3,
"previousStateHash": manager._initial_state_hash(state),
"resultingStateHash": "bb" * 32,
}
manager._send_active({"matchId": match_id, "message": move})
self.assertEqual(state["phase"], "active")
self.assertIn(message_id, state["pendingOutboundMoves"])
self.assertEqual(len(state["gameSendQueue"]), 1)
self.assertFalse(any(event == "GAME_ENDED" for event, _payload in events))
channel.ready = True
manager._flush_game_send_queue(state)
self.assertEqual(len(state["gameSendQueue"]), 0)
self.assertEqual(channel.sent[0]["m"]["messageId"], message_id)
def test_recovery_sync_is_flushed_before_a_deferred_new_move(self):
manager, _events = self.make_manager()
class Channel:
def __init__(self):
self.ready = False
self.sent = []
def send(self, message):
if not self.ready:
raise ChannelException(CEType.ME_LINK_NOT_READY, "Link is not ready")
self.sent.append(message.payload)
return object()
channel = Channel()
state = {
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"phase": "active",
"channel": channel,
}
manager._send_game_payload(state, {
"type": "MOVE", "messageId": "10000000-0000-4000-8000-000000000021"
})
manager._send_game_payload(
state,
{"type": "SYNC_MOVE", "messageId": "10000000-0000-4000-8000-000000000022"},
recovery_priority=True,
)
channel.ready = True
manager._flush_game_send_queue(state)
self.assertEqual(
[payload["m"]["type"] for payload in channel.sent],
["SYNC_MOVE", "MOVE"],
)
def test_deferred_start_ack_stays_ahead_of_recovery_sync(self):
manager, _events = self.make_manager()
class Channel:
def __init__(self):
self.ready = False
self.sent = []
def send(self, message):
if not self.ready:
raise ChannelException(CEType.ME_LINK_NOT_READY, "Link is not ready")
self.sent.append(message.payload)
return object()
channel = Channel()
state = {
"matchId": "00112233-4455-6677-8899-aabbccddeeff",
"phase": "active",
"channel": channel,
}
manager._send_game_payload(
state,
{"type": "START_ACK", "messageId": "10000000-0000-4000-8000-000000000025"},
recovery_priority=True,
)
manager._send_game_payload(
state,
{"type": "START_ACK", "messageId": "10000000-0000-4000-8000-000000000027"},
recovery_priority=True,
)
manager._send_game_payload(
state,
{"type": "SYNC_MOVE", "messageId": "10000000-0000-4000-8000-000000000026"},
recovery_priority=True,
)
channel.ready = True
manager._flush_game_send_queue(state)
self.assertEqual(
[payload["m"]["type"] for payload in channel.sent],
["START_ACK", "SYNC_MOVE"],
)
def test_link_recovery_rolls_back_pending_state_but_preserves_deferred_moves(self):
manager, events = self.make_manager()
link = object()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
pending = {
"type": "MOVE",
"messageId": "10000000-0000-4000-8000-000000000023",
"ply": 1,
}
state = {
"matchId": match_id,
"phase": "active",
"outbound": False,
"link": link,
"pendingOutboundMoves": {pending["messageId"]: pending},
"pendingInboundMoves": {},
"gameSendQueue": deque([
{"k": "game", "m": pending}
]),
}
manager.matches[match_id] = state
manager.links_by_object[id(link)] = match_id
manager._link_closed(link)
self.assertEqual(state["phase"], "recovering")
self.assertEqual(state["pendingOutboundMoves"], {})
self.assertEqual(len(state["gameSendQueue"]), 1)
self.assertTrue(
any(
event == "GAME_LINK_STATE" and payload.get("state") == "recovering"
for event, payload in events
)
)
def test_deferred_move_restores_pending_state_when_retransmitted(self):
manager, _events = self.make_manager()
class Channel:
def __init__(self):
self.sent = []
def send(self, message):
self.sent.append(message.payload)
return object()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
message_id = "10000000-0000-4000-8000-000000000024"
move = {
"type": "MOVE", "matchId": match_id, "roundId": match_id,
"messageId": message_id, "ply": 1, "column": 3,
"previousStateHash": "aa" * 32, "resultingStateHash": "bb" * 32,
}
state = {
"matchId": match_id,
"roundId": match_id,
"phase": "active",
"channel": Channel(),
"pendingOutboundMoves": {},
"gameSendQueue": deque([{"k": "game", "m": move}]),
}
manager._flush_game_send_queue(state)
self.assertEqual(state["pendingOutboundMoves"][message_id], move)
self.assertEqual(len(state["gameSendQueue"]), 0)
def test_explicit_completed_close_releases_match(self):
manager, events = self.make_manager()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
manager.matches[match_id] = {
"matchId": match_id,
"phase": "ending",
"outbound": True,
"requester": self.address,
"recipient": "Qopponent1111111111111111111111111111",
}
manager._cancel_or_close_match(match_id, completed=True)
self.assertNotIn(match_id, manager.matches)
self.assertEqual(events[-1], ("GAME_ENDED", {"matchId": match_id, "outcome": "completed"}))
def test_round_completion_keeps_authenticated_channel(self):
manager, events = self.make_manager()
class Channel:
def __init__(self):
self.sent = []
def send(self, message):
self.sent.append(message.payload)
return object()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
channel = Channel()
manager.matches[match_id] = {
"matchId": match_id,
"roundId": match_id,
"phase": "ending",
"channel": channel,
"outbound": True,
"requester": self.address,
"recipient": "Qopponent1111111111111111111111111111",
"transcript": [],
}
manager._send_active({
"matchId": match_id,
"message": {"type": "GAME_OVER_ACK", "messageId": "10000000-0000-4000-8000-000000000010", "ply": 0, "stateHash": "aa" * 32},
})
self.assertIn(match_id, manager.matches)
self.assertIs(manager.matches[match_id]["channel"], channel)
self.assertEqual(manager.matches[match_id]["phase"], "session_idle")
self.assertEqual(events[-1][0], "GAME_LINK_STATE")
def test_next_game_reuses_channel_and_can_switch_game_type(self):
manager, _events = self.make_manager()
class Channel:
def __init__(self):
self.sent = []
def send(self, message):
self.sent.append(message.payload)
return object()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
round_id = "11112233-4455-4677-8899-aabbccddeeff"
channel = Channel()
state = {
"matchId": match_id,
"roundId": match_id,
"phase": "session_idle",
"channel": channel,
"outbound": True,
"requester": self.address,
"recipient": "Qopponent1111111111111111111111111111",
"requesterNonce": "11" * 16,
"recipientNonce": "22" * 16,
"game": "connect-four",
"gameVersion": 1,
"rulesVersion": 1,
"transcript": [{"ply": 1}],
}
manager.matches[match_id] = state
manager._send_active({
"matchId": match_id,
"message": {
"type": "ROUND_REQUEST", "messageId": "10000000-0000-4000-8000-000000000011",
"roundId": round_id, "requesterNonce": "33" * 16,
"game": "checkers", "gameVersion": 1, "rulesVersion": 1,
},
})
manager._on_channel(match_id, GameMessage({"k": "game", "m": {
"type": "ROUND_RESPONSE", "matchId": match_id,
"messageId": "10000000-0000-4000-8000-000000000012",
"roundId": round_id, "accepted": True, "recipientNonce": "44" * 16,
}}))
self.assertIs(state["channel"], channel)
self.assertEqual(state["phase"], "active")
self.assertEqual(state["roundId"], round_id)
self.assertEqual(state["game"], "checkers")
self.assertEqual(state["transcript"], [])
def test_largest_checkers_move_fits_channel_payload(self):
message = GameMessage({"k": "game", "m": {
"type": "MOVE", "matchId": "00112233-4455-6677-8899-aabbccddeeff",
"roundId": "11112233-4455-4677-8899-aabbccddeeff",
"messageId": "22222233-4455-4677-8899-aabbccddeeff", "ply": 200,
"from": 63, "path": list(range(12)),
"previousStateHash": "aa" * 32, "resultingStateHash": "bb" * 32,
}})
self.assertLessEqual(len(message.pack()), MAX_CHANNEL_PAYLOAD)
def test_chess_promotion_move_fits_channel_payload(self):
message = GameMessage({"k": "game", "m": {
"type": "MOVE", "matchId": "00112233-4455-6677-8899-aabbccddeeff",
"roundId": "11112233-4455-4677-8899-aabbccddeeff",
"messageId": "22222233-4455-4677-8899-aabbccddeeff", "ply": 600,
"from": 8, "to": 0, "promotion": 5,
"previousStateHash": "aa" * 32, "resultingStateHash": "bb" * 32,
}})
self.assertLessEqual(len(message.pack()), MAX_CHANNEL_PAYLOAD)
def test_reusable_round_accepts_each_supported_game(self):
manager, _events = self.make_manager()
state = {"roundId": "00112233-4455-6677-8899-aabbccddeeff"}
for index, game in enumerate(("connect-four", "checkers", "chess"), start=1):
manager._validate_round_control(state, {
"roundId": f"{index:08d}-4455-4677-8899-aabbccddeeff",
"game": game, "gameVersion": 1, "rulesVersion": 1,
})
def test_delayed_move_from_previous_round_is_ignored(self):
manager, events = self.make_manager()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
state = {
"matchId": match_id,
"roundId": "11112233-4455-4677-8899-aabbccddeeff",
"phase": "active", "transcript": [], "lastRx": time.time(),
}
manager.matches[match_id] = state
manager._on_channel(match_id, GameMessage({"k": "game", "m": {
"type": "MOVE", "matchId": match_id,
"roundId": "22222233-4455-4677-8899-aabbccddeeff",
"messageId": "10000000-0000-4000-8000-000000000013", "ply": 1, "column": 3,
"previousStateHash": "aa" * 32, "resultingStateHash": "bb" * 32,
}}))
self.assertEqual(state["transcript"], [])
self.assertFalse(any(event == "GAME_ERROR" for event, _payload in events))
def test_chat_chunks_reassemble_and_fit_channel_limit(self):
sender, _sender_events = self.make_manager()
receiver, receiver_events = self.make_manager()
class Channel:
def __init__(self):
self.messages = []
def send(self, message):
self.assert_payload_fits(message)
self.messages.append(message.payload)
return object()
@staticmethod
def assert_payload_fits(message):
if len(message.pack()) > MAX_CHANNEL_PAYLOAD:
raise AssertionError("chat chunk exceeded channel payload")
match_id = "00112233-4455-6677-8899-aabbccddeeff"
sender_channel = Channel()
receiver_channel = Channel()
sender_state = {
"matchId": match_id, "roundId": match_id, "phase": "active",
"channel": sender_channel, "outbound": True,
"requester": self.address, "recipient": "Qremote111111111111111111111111111111",
"chatMessages": [],
}
receiver_state = {
"matchId": match_id, "roundId": match_id, "phase": "active",
"channel": receiver_channel, "outbound": False,
"requester": self.address, "recipient": "Qremote111111111111111111111111111111",
"chatMessages": [], "lastRx": time.time(),
}
sender.matches[match_id] = sender_state
receiver.matches[match_id] = receiver_state
text_value = "🙂" * 500
message_id = "10000000-0000-4000-8000-000000000014"
sender._send_active({"matchId": match_id, "message": {
"type": "CHAT_MESSAGE", "messageId": message_id,
"text": text_value, "createdAt": int(time.time() * 1000),
}})
for payload in sender_channel.messages:
receiver._on_channel(match_id, GameMessage(payload))
self.assertGreater(len(sender_channel.messages), 1)
self.assertEqual(receiver_state["chatMessages"][0]["text"], text_value)
self.assertEqual(receiver_events[-1][0], "GAME_MESSAGE")
self.assertEqual(receiver_events[-1][1]["matchId"], match_id)
self.assertEqual(receiver_events[-1][1]["message"]["matchId"], match_id)
self.assertEqual(receiver_channel.messages[-1]["m"]["type"], "CHAT_ACK")
def test_chat_snapshot_history_is_batched_below_local_frame_limit(self):
manager, _events = self.make_manager()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
manager.matches[match_id] = {"chatMessages": [
{
"messageId": str(index).zfill(36), "authorAddress": self.address,
"text": "🙂" * 500, "createdAt": index + 1, "delivered": True,
}
for index in range(100)
]}
batches = manager._chat_history_batches(match_id)
self.assertEqual(sum(len(batch["messages"]) for batch in batches), 100)
self.assertTrue(all(len(json.dumps(batch).encode("utf-8")) <= 16 * 1024 for batch in batches))
def test_chat_allows_rapid_messages_in_both_directions(self):
sender, _sender_events = self.make_manager()
receiver, receiver_events = self.make_manager()
class Channel:
def __init__(self):
self.messages = []
def send(self, message):
self.messages.append(message.payload)
return object()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
sender_channel = Channel()
receiver_channel = Channel()
sender.matches[match_id] = {
"matchId": match_id, "roundId": match_id, "phase": "active",
"channel": sender_channel, "outbound": True,
"requester": self.address, "recipient": "Qremote111111111111111111111111111111",
"chatMessages": [],
}
receiver.matches[match_id] = {
"matchId": match_id, "roundId": match_id, "phase": "active",
"channel": receiver_channel, "outbound": False,
"requester": self.address, "recipient": "Qremote111111111111111111111111111111",
"chatMessages": [], "lastRx": time.time(),
}
for index in range(20):
sender._send_active({"matchId": match_id, "message": {
"type": "CHAT_MESSAGE",
"messageId": f"10000000-0000-4000-8000-{index:012d}",
"text": f"message {index}",
"createdAt": int(time.time() * 1000),
}})
for payload in sender_channel.messages:
receiver._on_channel(match_id, GameMessage(payload))
self.assertEqual(len(sender.matches[match_id]["chatMessages"]), 20)
self.assertEqual(len(receiver.matches[match_id]["chatMessages"]), 20)
self.assertEqual(
len([event for event, _payload in receiver_events if event == "GAME_MESSAGE"]),
20,
)
def test_typing_signal_never_tears_down_the_game(self):
manager, events = self.make_manager()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
state = {
"matchId": match_id, "roundId": match_id, "phase": "active",
"requester": self.address, "recipient": "Qremote111111111111111111111111111111",
"outbound": False, "lastRx": time.time(),
}
manager.matches[match_id] = state
manager._on_channel(match_id, GameMessage({"k": "game", "m": {
"type": "CHAT_TYPING", "matchId": match_id, "active": True,
}}))
self.assertEqual(state["phase"], "active")
self.assertEqual(events[-1][0], "GAME_MESSAGE")
manager._on_channel(match_id, GameMessage({"k": "game", "m": {
"type": "CHAT_TYPING", "matchId": match_id, "active": "yes",
}}))
self.assertIn(match_id, manager.matches)
self.assertEqual(state["phase"], "active")
self.assertEqual(events[-1][1]["code"], "chat_error")
def test_cancelled_invitation_releases_match_immediately(self):
manager, events = self.make_manager()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
opponent = "Qopponent1111111111111111111111111111"
manager.matches[match_id] = {
"matchId": match_id,
"phase": "awaiting_response",
"outbound": True,
"requester": self.address,
"recipient": opponent,
"channel": None,
}
manager._cancel_or_close_match(match_id)
self.assertNotIn(match_id, manager.matches)
self.assertEqual(events[-1], ("GAME_ENDED", {"matchId": match_id, "outcome": "cancelled"}))
def test_signed_accept_cannot_change_the_responder_identity(self):
manager, _events = self.make_manager()
match_id = "00112233-4455-6677-8899-aabbccddeeff"
state = {
"matchId": match_id,
"phase": "awaiting_response",
"outbound": True,
"requester": "Qrequester111111111111111111111111111",
"recipient": self.address,
"requesterNonce": "11" * 16,
"inviteHash": "33" * 32,
"linkId": "22" * 16,
}
fields = {
"type": "QORTAL_LAND_GAME_ACCEPT",
"inviteHash": state["inviteHash"],
"matchId": match_id,
"requesterNonce": state["requesterNonce"],
"recipientNonce": "44" * 16,
"responderAddress": state["requester"],
"signerPublicKey": self.public_key,
"linkId": state["linkId"],
"createdAt": int(time.time() * 1000),
}
envelope = {
"fields": fields,
"publicKey": self.public_key,
"signature": _b58encode(self.private_key.sign(canonical_bytes(fields))),
}
with self.assertRaisesRegex(ValueError, "invalid_accept"):
manager._handle_handshake(state, envelope)
if __name__ == "__main__":
unittest.main()