|
|
@@ -1,15 +1,443 @@
|
|
|
+import asyncio
|
|
|
+import json
|
|
|
+
|
|
|
import pytest
|
|
|
|
|
|
-from cq2_sdk.login.game import GameLogin
|
|
|
+from cq2_sdk.login.game import GameLogin, GameLoginError
|
|
|
from cq2_sdk.login.models import BoxSession, ServerInfo
|
|
|
+from cq2_sdk.messages import constants as C
|
|
|
+from cq2_sdk.transport.framing import (
|
|
|
+ WIRE_HEADER_SIZE,
|
|
|
+ FrameHeader,
|
|
|
+ decode_header,
|
|
|
+ encode_frame,
|
|
|
+)
|
|
|
+from cq2_sdk.transport.tcp import TcpClient
|
|
|
+
|
|
|
+
|
|
|
+async def _read_frame(reader):
|
|
|
+ header = decode_header(await reader.readexactly(WIRE_HEADER_SIZE))
|
|
|
+ body = await reader.readexactly(header.body_len) if header.body_len else b""
|
|
|
+ return header, body
|
|
|
+
|
|
|
+
|
|
|
+def _response(msg_id, *, recog=0, param1=0, param2=0, param3=0, body=b"", sequence=0):
|
|
|
+ return encode_frame(
|
|
|
+ msg_id,
|
|
|
+ recog,
|
|
|
+ param1,
|
|
|
+ param2,
|
|
|
+ param3,
|
|
|
+ body,
|
|
|
+ sequence=sequence,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _header(msg_id, *, recog=0, param1=0, param2=0, param3=0):
|
|
|
+ return FrameHeader(
|
|
|
+ param3=param3,
|
|
|
+ msg_id=msg_id,
|
|
|
+ recog=recog,
|
|
|
+ param1=param1,
|
|
|
+ param2=param2,
|
|
|
+ sdata=0,
|
|
|
+ codec=0,
|
|
|
+ body_len=0,
|
|
|
+ sequence=0,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class _ScriptedTcp:
|
|
|
+ def __init__(self, responses):
|
|
|
+ self.responses = list(responses)
|
|
|
+ self.sent = []
|
|
|
+ self.reconnections = []
|
|
|
+ self.sdata = 0
|
|
|
+
|
|
|
+ async def send(self, *args):
|
|
|
+ self.sent.append(args)
|
|
|
+
|
|
|
+ async def recv(self, timeout):
|
|
|
+ if not self.responses:
|
|
|
+ await asyncio.sleep(timeout)
|
|
|
+ raise asyncio.TimeoutError
|
|
|
+ return self.responses.pop(0)
|
|
|
+
|
|
|
+ def set_sdata(self, value):
|
|
|
+ self.sdata = value
|
|
|
+
|
|
|
+ async def reconnect(self, host, port):
|
|
|
+ self.reconnections.append((host, port))
|
|
|
+
|
|
|
+
|
|
|
+def _server(**changes):
|
|
|
+ values = {
|
|
|
+ "server_id": 7,
|
|
|
+ "main_server_id": 9,
|
|
|
+ "name": "Seven",
|
|
|
+ "host": "game.example",
|
|
|
+ "port": 8041,
|
|
|
+ "channel_id": "110585",
|
|
|
+ "game_id": 206923,
|
|
|
+ "platform": 2,
|
|
|
+ "package_name": "pkg.name",
|
|
|
+ "screen_width": 1136,
|
|
|
+ "screen_height": 639,
|
|
|
+ "device_code": 98395,
|
|
|
+ }
|
|
|
+ values.update(changes)
|
|
|
+ return ServerInfo(**values)
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_new_login_existing_role_enters_world():
|
|
|
+ role_frames = []
|
|
|
+ world_frames = []
|
|
|
+ world_done = asyncio.Event()
|
|
|
+
|
|
|
+ async def world_handler(reader, writer):
|
|
|
+ world_frames.append(await _read_frame(reader))
|
|
|
+ writer.write(
|
|
|
+ _response(
|
|
|
+ C.SC_658,
|
|
|
+ body=b'{"UserID":"role-123","Msg":"hello","ver":"v1"}',
|
|
|
+ )
|
|
|
+ )
|
|
|
+ await writer.drain()
|
|
|
+ world_frames.append(await _read_frame(reader))
|
|
|
+ writer.write(_response(C.SC_2, body=b'{"MapID":3}', sequence=1))
|
|
|
+ await writer.drain()
|
|
|
+ world_done.set()
|
|
|
+ writer.close()
|
|
|
+ await writer.wait_closed()
|
|
|
+
|
|
|
+ world_server = await asyncio.start_server(world_handler, "127.0.0.1", 0)
|
|
|
+ world_port = world_server.sockets[0].getsockname()[1]
|
|
|
+
|
|
|
+ async def role_handler(reader, writer):
|
|
|
+ responses = [
|
|
|
+ _response(C.SC_529, recog=1, body=b"login-key/user-42"),
|
|
|
+ _response(C.SC_530, recog=149, sequence=1),
|
|
|
+ _response(
|
|
|
+ C.SC_520,
|
|
|
+ body=json.dumps(
|
|
|
+ {
|
|
|
+ "list": [
|
|
|
+ {
|
|
|
+ "chr": "Hero",
|
|
|
+ "UserID": "role-123",
|
|
|
+ "last": 1,
|
|
|
+ "deny_ip": 0,
|
|
|
+ "deny_account": 0,
|
|
|
+ "deny_uid": 0,
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ separators=(",", ":"),
|
|
|
+ ).encode(),
|
|
|
+ sequence=2,
|
|
|
+ ),
|
|
|
+ _response(C.SC_525, body=f"ignored/{world_port}".encode(), sequence=3),
|
|
|
+ ]
|
|
|
+ for response in responses:
|
|
|
+ role_frames.append(await _read_frame(reader))
|
|
|
+ writer.write(response)
|
|
|
+ await writer.drain()
|
|
|
+ writer.close()
|
|
|
+ await writer.wait_closed()
|
|
|
+
|
|
|
+ role_server = await asyncio.start_server(role_handler, "127.0.0.1", 0)
|
|
|
+ role_port = role_server.sockets[0].getsockname()[1]
|
|
|
+ tcp = TcpClient("127.0.0.1", role_port)
|
|
|
+ await tcp.connect()
|
|
|
+ stamps = iter([111, 222, 333])
|
|
|
+ login = GameLogin(tcp, randint=lambda _low, _high: next(stamps))
|
|
|
+ server = ServerInfo(
|
|
|
+ server_id=7,
|
|
|
+ main_server_id=9,
|
|
|
+ name="Seven",
|
|
|
+ host="127.0.0.1",
|
|
|
+ port=role_port,
|
|
|
+ channel_id="110585",
|
|
|
+ game_id=206923,
|
|
|
+ platform=2,
|
|
|
+ package_name="pkg.name",
|
|
|
+ screen_width=1136,
|
|
|
+ screen_height=639,
|
|
|
+ device_code=98395,
|
|
|
+ config_versions={"cfg_stditem": "hash"},
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ result = await login.login(
|
|
|
+ BoxSession(uid="account-1", token="token-xyz", sdkid="", signkey=""),
|
|
|
+ server,
|
|
|
+ timeout=2.0,
|
|
|
+ )
|
|
|
+ await asyncio.wait_for(world_done.wait(), timeout=1.0)
|
|
|
+ finally:
|
|
|
+ await tcp.close()
|
|
|
+ role_server.close()
|
|
|
+ world_server.close()
|
|
|
+ await role_server.wait_closed()
|
|
|
+ await world_server.wait_closed()
|
|
|
+
|
|
|
+ assert result.char_id == "role-123"
|
|
|
+ assert result.char_name == "Hero"
|
|
|
+ assert result.session_id == 149
|
|
|
+ assert result.user_id == "user-42"
|
|
|
+ assert result.world_ready is True
|
|
|
+
|
|
|
+ (cs2001, body2001), (cs104, body104), (cs100, body100), (cs103, body103) = role_frames
|
|
|
+ assert (cs2001.msg_id, cs2001.recog, cs2001.param1) == (C.CS_2001, 900000, 111)
|
|
|
+ assert (cs2001.param2, cs2001.param3, cs2001.sdata, cs2001.sequence) == (
|
|
|
+ 9,
|
|
|
+ role_port,
|
|
|
+ 0,
|
|
|
+ 0,
|
|
|
+ )
|
|
|
+ assert body2001 == b"account-1/pGgZXJ?^LvhA/PaDP<wt?/1/0"
|
|
|
+ assert (cs104.msg_id, cs104.sdata, cs104.sequence) == (C.CS_104, 0, 1)
|
|
|
+ assert body104 == b"VwgIVZ?^sc>g"
|
|
|
+ assert (cs100.msg_id, cs100.recog, cs100.param1) == (C.CS_100, 900000, 222)
|
|
|
+ assert (cs100.param2, cs100.param3, cs100.sdata, cs100.sequence) == (
|
|
|
+ 9,
|
|
|
+ role_port,
|
|
|
+ 149,
|
|
|
+ 2,
|
|
|
+ )
|
|
|
+ assert body100 == b"account-1/149/7/110585/206923/login-key"
|
|
|
+ assert (cs103.msg_id, cs103.sdata, cs103.sequence) == (C.CS_103, 149, 3)
|
|
|
+ assert body103 == b"account-1/Hero"
|
|
|
+
|
|
|
+ (cs20, body20), (cs1018, body1018) = world_frames
|
|
|
+ assert (cs20.msg_id, cs20.recog, cs20.param1) == (C.CS_20, 900000, 333)
|
|
|
+ assert (cs20.param2, cs20.param3, cs20.sdata, cs20.sequence) == (
|
|
|
+ 9,
|
|
|
+ world_port,
|
|
|
+ 149,
|
|
|
+ 0,
|
|
|
+ )
|
|
|
+ assert body20 == b"*#account-1/Hero/role-123/149/2/0/110585/0/0/0/1/0"
|
|
|
+ assert (cs1018.msg_id, cs1018.recog, cs1018.param1) == (C.CS_1018, 2, 0)
|
|
|
+ assert (cs1018.param2, cs1018.param3, cs1018.sdata, cs1018.sequence) == (
|
|
|
+ 41_878_640,
|
|
|
+ 98_395,
|
|
|
+ 149,
|
|
|
+ 1,
|
|
|
+ )
|
|
|
+ assert json.loads(body1018) == {
|
|
|
+ "cfg_stditem": "hash",
|
|
|
+ "gameid": 206923,
|
|
|
+ "channelId": "110585",
|
|
|
+ "packageName": "pkg.name",
|
|
|
+ "tripartite": 0,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_empty_role_list_requires_explicit_name_without_sending_cs101():
|
|
|
+ tcp = _ScriptedTcp(
|
|
|
+ [
|
|
|
+ (_header(C.SC_529, recog=1), b"login-key/user-42"),
|
|
|
+ (_header(C.SC_530, recog=149), b""),
|
|
|
+ (_header(C.SC_520), b'{"list":[]}'),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ login = GameLogin(tcp, randint=lambda _low, _high: 1)
|
|
|
+
|
|
|
+ with pytest.raises(GameLoginError, match="char_name is required"):
|
|
|
+ await login.login(
|
|
|
+ BoxSession(uid="account-1", token="token-xyz", sdkid="", signkey=""),
|
|
|
+ _server(),
|
|
|
+ timeout=1.0,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert [sent[0] for sent in tcp.sent] == [C.CS_2001, C.CS_104, C.CS_100]
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_empty_role_list_creates_named_role_then_enters_world():
|
|
|
+ tcp = _ScriptedTcp(
|
|
|
+ [
|
|
|
+ (_header(C.SC_529, recog=1), b"login-key/user-42"),
|
|
|
+ (_header(C.SC_530, recog=149), b""),
|
|
|
+ (_header(C.SC_520), b'{"list":[]}'),
|
|
|
+ (_header(C.SC_521), "新手/role-new".encode()),
|
|
|
+ (_header(C.SC_525), b"ignored/8043"),
|
|
|
+ (_header(C.SC_658), b'{"UserID":"role-new"}'),
|
|
|
+ (_header(C.SC_2), b"{}"),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ login = GameLogin(tcp, randint=lambda _low, _high: 1)
|
|
|
+
|
|
|
+ result = await login.login(
|
|
|
+ BoxSession(uid="account-1", token="token-xyz", sdkid="", signkey=""),
|
|
|
+ _server(),
|
|
|
+ char_name="新手",
|
|
|
+ job=1,
|
|
|
+ sex=0,
|
|
|
+ timeout=1.0,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result.char_id == "role-new"
|
|
|
+ assert result.char_name == "新手"
|
|
|
+ assert [sent[0] for sent in tcp.sent] == [
|
|
|
+ C.CS_2001,
|
|
|
+ C.CS_104,
|
|
|
+ C.CS_100,
|
|
|
+ C.CS_101,
|
|
|
+ C.CS_103,
|
|
|
+ C.CS_20,
|
|
|
+ C.CS_1018,
|
|
|
+ ]
|
|
|
+ create_body = tcp.sent[3][5]
|
|
|
+ assert json.loads(create_body) == {
|
|
|
+ "account": "account-1",
|
|
|
+ "chr": "新手",
|
|
|
+ "job": 1,
|
|
|
+ "sex": 0,
|
|
|
+ "server_name": 7,
|
|
|
+ "sdkid": "110585",
|
|
|
+ "secsdk": "0",
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_old_login_reconnects_from_sc530_before_role_lookup():
|
|
|
+ roles = {
|
|
|
+ "list": [
|
|
|
+ {
|
|
|
+ "chr": "Hero",
|
|
|
+ "UserID": "role-old",
|
|
|
+ "last": 1,
|
|
|
+ "deny_ip": 0,
|
|
|
+ "deny_account": 0,
|
|
|
+ "deny_uid": 0,
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ tcp = _ScriptedTcp(
|
|
|
+ [
|
|
|
+ (_header(C.SC_529, recog=0), b"unused/user-old"),
|
|
|
+ (_header(C.SC_530), b"ignored/7777/151"),
|
|
|
+ (_header(C.SC_520), json.dumps(roles).encode()),
|
|
|
+ (_header(C.SC_525), b"ignored/8888"),
|
|
|
+ (_header(C.SC_658), b"{}"),
|
|
|
+ (_header(C.SC_2), b"{}"),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ login = GameLogin(tcp, randint=lambda _low, _high: 1)
|
|
|
+
|
|
|
+ result = await login.login(
|
|
|
+ BoxSession(uid="account-1", token="token-xyz", sdkid="", signkey=""),
|
|
|
+ _server(),
|
|
|
+ timeout=1.0,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result.session_id == 151
|
|
|
+ assert tcp.reconnections == [("game.example", 7777), ("game.example", 8888)]
|
|
|
+ assert tcp.sent[1][0] == C.CS_104
|
|
|
+ assert tcp.sent[1][5] == b"9"
|
|
|
+ assert tcp.sent[2][0] == C.CS_100
|
|
|
+ assert tcp.sent[2][4] == 7777
|
|
|
+ assert tcp.sent[2][5] == b"account-1/151/7/110585/206923"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+@pytest.mark.parametrize(
|
|
|
+ ("responses", "char_name", "message_id", "error_code"),
|
|
|
+ [
|
|
|
+ ([(_header(C.SC_503, recog=-6), b"")], None, C.SC_503, -6),
|
|
|
+ (
|
|
|
+ [
|
|
|
+ (_header(C.SC_529, recog=1), b"key/user"),
|
|
|
+ (_header(C.SC_530, recog=149), b""),
|
|
|
+ (_header(C.SC_527, param1=3), b""),
|
|
|
+ ],
|
|
|
+ None,
|
|
|
+ C.SC_527,
|
|
|
+ 3,
|
|
|
+ ),
|
|
|
+ (
|
|
|
+ [
|
|
|
+ (_header(C.SC_529, recog=1), b"key/user"),
|
|
|
+ (_header(C.SC_530, recog=149), b""),
|
|
|
+ (_header(C.SC_520), b'{"list":[]}'),
|
|
|
+ (_header(C.SC_522, recog=-10), b""),
|
|
|
+ ],
|
|
|
+ "New",
|
|
|
+ C.SC_522,
|
|
|
+ -10,
|
|
|
+ ),
|
|
|
+ (
|
|
|
+ [
|
|
|
+ (_header(C.SC_529, recog=1), b"key/user"),
|
|
|
+ (_header(C.SC_530, recog=149), b""),
|
|
|
+ (
|
|
|
+ _header(C.SC_520),
|
|
|
+ b'{"list":[{"chr":"Hero","UserID":"r1","last":1}]}',
|
|
|
+ ),
|
|
|
+ (_header(C.SC_526, recog=99), b""),
|
|
|
+ ],
|
|
|
+ None,
|
|
|
+ C.SC_526,
|
|
|
+ 99,
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+)
|
|
|
+async def test_known_login_errors_preserve_message_and_error_code(
|
|
|
+ responses, char_name, message_id, error_code
|
|
|
+):
|
|
|
+ tcp = _ScriptedTcp(responses)
|
|
|
+ login = GameLogin(tcp, randint=lambda _low, _high: 1)
|
|
|
+
|
|
|
+ with pytest.raises(GameLoginError) as caught:
|
|
|
+ await login.login(
|
|
|
+ BoxSession(uid="u", token="t", sdkid="", signkey=""),
|
|
|
+ _server(),
|
|
|
+ char_name=char_name,
|
|
|
+ timeout=0.05,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert caught.value.message_id == message_id
|
|
|
+ assert caught.value.error_code == error_code
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+@pytest.mark.parametrize(
|
|
|
+ ("responses", "message"),
|
|
|
+ [
|
|
|
+ ([(_header(C.SC_529, recog=1), b"missing-separator")], "SC529"),
|
|
|
+ (
|
|
|
+ [
|
|
|
+ (_header(C.SC_529, recog=1), b"key/user"),
|
|
|
+ (_header(C.SC_530, recog=149), b""),
|
|
|
+ (_header(C.SC_520), b"{"),
|
|
|
+ ],
|
|
|
+ "SC520",
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+)
|
|
|
+async def test_malformed_login_responses_are_protocol_errors(responses, message):
|
|
|
+ tcp = _ScriptedTcp(responses)
|
|
|
+ login = GameLogin(tcp, randint=lambda _low, _high: 1)
|
|
|
+
|
|
|
+ with pytest.raises(GameLoginError, match=message):
|
|
|
+ await login.login(
|
|
|
+ BoxSession(uid="u", token="t", sdkid="", signkey=""),
|
|
|
+ _server(),
|
|
|
+ timeout=0.05,
|
|
|
+ )
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
-async def test_login_rejects_unverified_game_protocol():
|
|
|
- login = GameLogin(tcp=object()) # type: ignore[arg-type]
|
|
|
+async def test_unrelated_messages_are_ignored_until_shared_deadline():
|
|
|
+ tcp = _ScriptedTcp([(_header(9999), b"unrelated")])
|
|
|
+ login = GameLogin(tcp, randint=lambda _low, _high: 1)
|
|
|
|
|
|
- with pytest.raises(NotImplementedError, match="CS2001 body"):
|
|
|
+ with pytest.raises(TimeoutError, match="SC 529"):
|
|
|
await login.login(
|
|
|
- BoxSession(uid="u1", token="t1", sdkid="sdk1", signkey="sk1"),
|
|
|
- ServerInfo(server_id="s1", name="n", host="h", port=1),
|
|
|
+ BoxSession(uid="u", token="t", sdkid="", signkey=""),
|
|
|
+ _server(),
|
|
|
+ timeout=0.01,
|
|
|
)
|