From a4446050997df5a6231ae1fe9e2a8f4394434588 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sun, 14 May 2023 22:41:42 -0800 Subject: [PATCH 1/3] Add TypedDict definitions for all client messages --- Pipfile | 1 + Pipfile.lock | 363 +++++++-------- README.md | 7 +- server/__init__.py | 5 +- server/lobbyconnection.py | 83 ++-- server/{types.py => types/__init__.py} | 0 server/types/messages/__init__.py | 3 + server/types/messages/client.py | 589 +++++++++++++++++++++++++ 8 files changed, 834 insertions(+), 217 deletions(-) rename server/{types.py => types/__init__.py} (100%) create mode 100644 server/types/messages/__init__.py create mode 100644 server/types/messages/client.py diff --git a/Pipfile b/Pipfile index 8753910dd..fb62459d5 100644 --- a/Pipfile +++ b/Pipfile @@ -28,6 +28,7 @@ sortedcontainers = "*" sqlalchemy = ">=2.0.0" trueskill = "*" uvloop = {version = "*", markers = "sys_platform != 'win32'"} +typing-extensions = "*" [dev-packages] flaky = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 95592da66..4eb74334e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "327ec5e5c21e4d92d3e02a575346f41eeb8a4af000963686f49e12544a9eb570" + "sha256": "b85178602ed003fa52e757512693b9264af2a3b1c362459b1a222a9849d54580" }, "pipfile-spec": 6, "requires": { @@ -35,11 +35,11 @@ }, "aiohappyeyeballs": { "hashes": [ - "sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1", - "sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0" + "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", + "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" ], "markers": "python_version >= '3.9'", - "version": "==2.4.6" + "version": "==2.6.1" }, "aiohttp": { "hashes": [ @@ -160,11 +160,11 @@ }, "attrs": { "hashes": [ - "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", - "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a" + "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", + "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b" ], "markers": "python_version >= '3.8'", - "version": "==25.1.0" + "version": "==25.3.0" }, "cachetools": { "hashes": [ @@ -258,39 +258,43 @@ }, "cryptography": { "hashes": [ - "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", - "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", - "sha256:1f9a92144fa0c877117e9748c74501bea842f93d21ee00b0cf922846d9d0b183", - "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", - "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a", - "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", - "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", - "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", - "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", - "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", - "sha256:5fed5cd6102bb4eb843e3315d2bf25fede494509bddadb81e03a859c1bc17b83", - "sha256:610a83540765a8d8ce0f351ce42e26e53e1f774a6efb71eb1b41eb01d01c3d12", - "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", - "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", - "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", - "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", - "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", - "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", - "sha256:94f99f2b943b354a5b6307d7e8d19f5c423a794462bde2bf310c770ba052b1c4", - "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9", - "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008", - "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", - "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009", - "sha256:d9c5b9f698a83c8bd71e0f4d3f9f839ef244798e5ffe96febfa9714717db7af7", - "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", - "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", - "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f", - "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", - "sha256:f4daefc971c2d1f82f03097dc6f216744a6cd2ac0f04c68fb935ea2ba2a0d420", - "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", - "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00" + "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", + "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41", + "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", + "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5", + "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", + "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d", + "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", + "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", + "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", + "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", + "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", + "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", + "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", + "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", + "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", + "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", + "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562", + "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", + "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", + "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", + "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", + "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", + "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", + "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa", + "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb", + "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", + "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", + "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", + "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", + "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", + "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", + "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", + "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", + "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", + "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308" ], - "version": "==44.0.1" + "version": "==44.0.2" }, "docopt": { "hashes": [ @@ -928,67 +932,67 @@ }, "sqlalchemy": { "hashes": [ - "sha256:0398361acebb42975deb747a824b5188817d32b5c8f8aba767d51ad0cc7bb08d", - "sha256:0561832b04c6071bac3aad45b0d3bb6d2c4f46a8409f0a7a9c9fa6673b41bc03", - "sha256:07258341402a718f166618470cde0c34e4cec85a39767dce4e24f61ba5e667ea", - "sha256:0a826f21848632add58bef4f755a33d45105d25656a0c849f2dc2df1c71f6f50", - "sha256:1052723e6cd95312f6a6eff9a279fd41bbae67633415373fdac3c430eca3425d", - "sha256:12d5b06a1f3aeccf295a5843c86835033797fea292c60e72b07bcb5d820e6dd3", - "sha256:12f5c9ed53334c3ce719155424dc5407aaa4f6cadeb09c5b627e06abb93933a1", - "sha256:2a0ef3f98175d77180ffdc623d38e9f1736e8d86b6ba70bff182a7e68bed7727", - "sha256:2f2951dc4b4f990a4b394d6b382accb33141d4d3bd3ef4e2b27287135d6bdd68", - "sha256:3868acb639c136d98107c9096303d2d8e5da2880f7706f9f8c06a7f961961149", - "sha256:386b7d136919bb66ced64d2228b92d66140de5fefb3c7df6bd79069a269a7b06", - "sha256:3d3043375dd5bbcb2282894cbb12e6c559654c67b5fffb462fda815a55bf93f7", - "sha256:3e35d5565b35b66905b79ca4ae85840a8d40d31e0b3e2990f2e7692071b179ca", - "sha256:402c2316d95ed90d3d3c25ad0390afa52f4d2c56b348f212aa9c8d072a40eee5", - "sha256:40310db77a55512a18827488e592965d3dec6a3f1e3d8af3f8243134029daca3", - "sha256:40e9cdbd18c1f84631312b64993f7d755d85a3930252f6276a77432a2b25a2f3", - "sha256:49aa2cdd1e88adb1617c672a09bf4ebf2f05c9448c6dbeba096a3aeeb9d4d443", - "sha256:57dd41ba32430cbcc812041d4de8d2ca4651aeefad2626921ae2a23deb8cd6ff", - "sha256:5dba1cdb8f319084f5b00d41207b2079822aa8d6a4667c0f369fce85e34b0c86", - "sha256:5e1d9e429028ce04f187a9f522818386c8b076723cdbe9345708384f49ebcec6", - "sha256:63178c675d4c80def39f1febd625a6333f44c0ba269edd8a468b156394b27753", - "sha256:6493bc0eacdbb2c0f0d260d8988e943fee06089cd239bd7f3d0c45d1657a70e2", - "sha256:64aa8934200e222f72fcfd82ee71c0130a9c07d5725af6fe6e919017d095b297", - "sha256:665255e7aae5f38237b3a6eae49d2358d83a59f39ac21036413fab5d1e810578", - "sha256:6db316d6e340f862ec059dc12e395d71f39746a20503b124edc255973977b728", - "sha256:70065dfabf023b155a9c2a18f573e47e6ca709b9e8619b2e04c54d5bcf193178", - "sha256:8455aa60da49cb112df62b4721bd8ad3654a3a02b9452c783e651637a1f21fa2", - "sha256:8b0ac78898c50e2574e9f938d2e5caa8fe187d7a5b69b65faa1ea4648925b096", - "sha256:8bf312ed8ac096d674c6aa9131b249093c1b37c35db6a967daa4c84746bc1bc9", - "sha256:92f99f2623ff16bd4aaf786ccde759c1f676d39c7bf2855eb0b540e1ac4530c8", - "sha256:9c8bcad7fc12f0cc5896d8e10fdf703c45bd487294a986903fe032c72201596b", - "sha256:9cd136184dd5f58892f24001cdce986f5d7e96059d004118d5410671579834a4", - "sha256:9eb4fa13c8c7a2404b6a8e3772c17a55b1ba18bc711e25e4d6c0c9f5f541b02a", - "sha256:a2bc4e49e8329f3283d99840c136ff2cd1a29e49b5624a46a290f04dff48e079", - "sha256:a5645cd45f56895cfe3ca3459aed9ff2d3f9aaa29ff7edf557fa7a23515a3725", - "sha256:a9afbc3909d0274d6ac8ec891e30210563b2c8bdd52ebbda14146354e7a69373", - "sha256:aa498d1392216fae47eaf10c593e06c34476ced9549657fca713d0d1ba5f7248", - "sha256:afd776cf1ebfc7f9aa42a09cf19feadb40a26366802d86c1fba080d8e5e74bdd", - "sha256:b335a7c958bc945e10c522c069cd6e5804f4ff20f9a744dd38e748eb602cbbda", - "sha256:b3c4817dff8cef5697f5afe5fec6bc1783994d55a68391be24cb7d80d2dbc3a6", - "sha256:b79ee64d01d05a5476d5cceb3c27b5535e6bb84ee0f872ba60d9a8cd4d0e6579", - "sha256:b87a90f14c68c925817423b0424381f0e16d80fc9a1a1046ef202ab25b19a444", - "sha256:bf89e0e4a30714b357f5d46b6f20e0099d38b30d45fa68ea48589faf5f12f62d", - "sha256:c058b84c3b24812c859300f3b5abf300daa34df20d4d4f42e9652a4d1c48c8a4", - "sha256:c09a6ea87658695e527104cf857c70f79f14e9484605e205217aae0ec27b45fc", - "sha256:c57b8e0841f3fce7b703530ed70c7c36269c6d180ea2e02e36b34cb7288c50c7", - "sha256:c9cea5b756173bb86e2235f2f871b406a9b9d722417ae31e5391ccaef5348f2c", - "sha256:cb39ed598aaf102251483f3e4675c5dd6b289c8142210ef76ba24aae0a8f8aba", - "sha256:e036549ad14f2b414c725349cce0772ea34a7ab008e9cd67f9084e4f371d1f32", - "sha256:e185ea07a99ce8b8edfc788c586c538c4b1351007e614ceb708fd01b095ef33e", - "sha256:e5a4d82bdb4bf1ac1285a68eab02d253ab73355d9f0fe725a97e1e0fa689decb", - "sha256:eae27ad7580529a427cfdd52c87abb2dfb15ce2b7a3e0fc29fbb63e2ed6f8120", - "sha256:ecef029b69843b82048c5b347d8e6049356aa24ed644006c9a9d7098c3bd3bfd", - "sha256:ee3bee874cb1fadee2ff2b79fc9fc808aa638670f28b2145074538d4a6a5028e", - "sha256:f0d3de936b192980209d7b5149e3c98977c3810d401482d05fb6d668d53c1c63", - "sha256:f53c0d6a859b2db58332e0e6a921582a02c1677cc93d4cbb36fdf49709b327b2", - "sha256:f9d57f1b3061b3e21476b0ad5f0397b112b94ace21d1f439f2db472e568178ae" + "sha256:018ee97c558b499b58935c5a152aeabf6d36b3d55d91656abeb6d93d663c0c4c", + "sha256:01da15490c9df352fbc29859d3c7ba9cd1377791faeeb47c100832004c99472c", + "sha256:04545042969833cb92e13b0a3019549d284fd2423f318b6ba10e7aa687690a3c", + "sha256:06205eb98cb3dd52133ca6818bf5542397f1dd1b69f7ea28aa84413897380b06", + "sha256:08cf721bbd4391a0e765fe0fe8816e81d9f43cece54fdb5ac465c56efafecb3d", + "sha256:0d7e3866eb52d914aea50c9be74184a0feb86f9af8aaaa4daefe52b69378db0b", + "sha256:125a7763b263218a80759ad9ae2f3610aaf2c2fbbd78fff088d584edf81f3782", + "sha256:23c5aa33c01bd898f879db158537d7e7568b503b15aad60ea0c8da8109adf3e7", + "sha256:2600a50d590c22d99c424c394236899ba72f849a02b10e65b4c70149606408b5", + "sha256:2d7332868ce891eda48896131991f7f2be572d65b41a4050957242f8e935d5d7", + "sha256:2ed107331d188a286611cea9022de0afc437dd2d3c168e368169f27aa0f61338", + "sha256:3395e7ed89c6d264d38bea3bfb22ffe868f906a7985d03546ec7dc30221ea980", + "sha256:344cd1ec2b3c6bdd5dfde7ba7e3b879e0f8dd44181f16b895940be9b842fd2b6", + "sha256:34d5c49f18778a3665d707e6286545a30339ad545950773d43977e504815fa70", + "sha256:35e72518615aa5384ef4fae828e3af1b43102458b74a8c481f69af8abf7e802a", + "sha256:3eb14ba1a9d07c88669b7faf8f589be67871d6409305e73e036321d89f1d904e", + "sha256:412c6c126369ddae171c13987b38df5122cb92015cba6f9ee1193b867f3f1530", + "sha256:4600c7a659d381146e1160235918826c50c80994e07c5b26946a3e7ec6c99249", + "sha256:463ecfb907b256e94bfe7bcb31a6d8c7bc96eca7cbe39803e448a58bb9fcad02", + "sha256:4a06e6c8e31c98ddc770734c63903e39f1947c9e3e5e4bef515c5491b7737dde", + "sha256:4b2de1523d46e7016afc7e42db239bd41f2163316935de7c84d0e19af7e69538", + "sha256:4dabd775fd66cf17f31f8625fc0e4cfc5765f7982f94dc09b9e5868182cb71c0", + "sha256:4eff9c270afd23e2746e921e80182872058a7a592017b2713f33f96cc5f82e32", + "sha256:52607d0ebea43cf214e2ee84a6a76bc774176f97c5a774ce33277514875a718e", + "sha256:533e0f66c32093a987a30df3ad6ed21170db9d581d0b38e71396c49718fbb1ca", + "sha256:5493a8120d6fc185f60e7254fc056a6742f1db68c0f849cfc9ab46163c21df47", + "sha256:5d2d1fe548def3267b4c70a8568f108d1fed7cbbeccb9cc166e05af2abc25c22", + "sha256:5dfbc543578058c340360f851ddcecd7a1e26b0d9b5b69259b526da9edfa8875", + "sha256:66a40003bc244e4ad86b72abb9965d304726d05a939e8c09ce844d27af9e6d37", + "sha256:67de057fbcb04a066171bd9ee6bcb58738d89378ee3cabff0bffbf343ae1c787", + "sha256:6827f8c1b2f13f1420545bd6d5b3f9e0b85fe750388425be53d23c760dcf176b", + "sha256:6b35e07f1d57b79b86a7de8ecdcefb78485dab9851b9638c2c793c50203b2ae8", + "sha256:7399d45b62d755e9ebba94eb89437f80512c08edde8c63716552a3aade61eb42", + "sha256:788b6ff6728072b313802be13e88113c33696a9a1f2f6d634a97c20f7ef5ccce", + "sha256:78f1b79132a69fe8bd6b5d91ef433c8eb40688ba782b26f8c9f3d2d9ca23626f", + "sha256:79f4f502125a41b1b3b34449e747a6abfd52a709d539ea7769101696bdca6716", + "sha256:7a8517b6d4005facdbd7eb4e8cf54797dbca100a7df459fdaff4c5123265c1cd", + "sha256:7bd5c5ee1448b6408734eaa29c0d820d061ae18cb17232ce37848376dcfa3e92", + "sha256:7f5243357e6da9a90c56282f64b50d29cba2ee1f745381174caacc50d501b109", + "sha256:805cb481474e111ee3687c9047c5f3286e62496f09c0e82e8853338aaaa348f8", + "sha256:871f55e478b5a648c08dd24af44345406d0e636ffe021d64c9b57a4a11518304", + "sha256:87a1ce1f5e5dc4b6f4e0aac34e7bb535cb23bd4f5d9c799ed1633b65c2bcad8c", + "sha256:8a10ca7f8a1ea0fd5630f02feb055b0f5cdfcd07bb3715fc1b6f8cb72bf114e4", + "sha256:995c2bacdddcb640c2ca558e6760383dcdd68830160af92b5c6e6928ffd259b4", + "sha256:9f03143f8f851dd8de6b0c10784363712058f38209e926723c80654c1b40327a", + "sha256:a1c6b0a5e3e326a466d809b651c63f278b1256146a377a528b6938a279da334f", + "sha256:a28f9c238f1e143ff42ab3ba27990dfb964e5d413c0eb001b88794c5c4a528a9", + "sha256:b2cf5b5ddb69142511d5559c427ff00ec8c0919a1e6c09486e9c32636ea2b9dd", + "sha256:b761a6847f96fdc2d002e29e9e9ac2439c13b919adfd64e8ef49e75f6355c548", + "sha256:bf555f3e25ac3a70c67807b2949bfe15f377a40df84b71ab2c58d8593a1e036e", + "sha256:c08a972cbac2a14810463aec3a47ff218bb00c1a607e6689b531a7c589c50723", + "sha256:c457a38351fb6234781d054260c60e531047e4d07beca1889b558ff73dc2014b", + "sha256:c4c433f78c2908ae352848f56589c02b982d0e741b7905228fad628999799de4", + "sha256:d9f119e7736967c0ea03aff91ac7d04555ee038caf89bb855d93bbd04ae85b41", + "sha256:e6b0a1c7ed54a5361aaebb910c1fa864bae34273662bb4ff788a527eafd6e14d", + "sha256:f2bcb085faffcacf9319b1b1445a7e1cfdc6fb46c03f2dce7bc2d9a4b3c1cdc5", + "sha256:fe193d3ae297c423e0e567e240b4324d6b6c280a048e64c77a3ea6886cc2aa87" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==2.0.38" + "version": "==2.0.39" }, "trueskill": { "hashes": [ @@ -1003,16 +1007,17 @@ "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], + "index": "pypi", "markers": "python_version >= '3.8'", "version": "==4.12.2" }, "tzlocal": { "hashes": [ - "sha256:2fafbfc07e9d8b49ade18f898d6bcd37ae88ce3ad6486842a2e4f03af68323d2", - "sha256:3814135a1bb29763c6e4f08fd6e41dbb435c7a60bfbb03270211bcc537187d8c" + "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", + "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d" ], "markers": "python_version >= '3.9'", - "version": "==5.3" + "version": "==5.3.1" }, "uvloop": { "hashes": [ @@ -1149,11 +1154,11 @@ "develop": { "attrs": { "hashes": [ - "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", - "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a" + "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", + "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b" ], "markers": "python_version >= '3.8'", - "version": "==25.1.0" + "version": "==25.3.0" }, "coverage": { "extras": [ @@ -1246,12 +1251,12 @@ }, "hypothesis": { "hashes": [ - "sha256:8246ae8530b64af60f821d845bf7b12aafc28e49ef9096abf9238d48f13fc3dd", - "sha256:be7946a8ba3da4964a5b8623bd8e95f019daca0618f5d0450fb17fe19272a108" + "sha256:23f180782dbbbcb6e59051794295d75783ad59241e6629a5bf700936c28a995b", + "sha256:a3b4c9087806c1f5650a538b6acd781b5f3856ebaf64aac20a10c226c9e7c3a8" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==6.127.3" + "version": "==6.129.2" }, "iniconfig": { "hashes": [ @@ -1371,12 +1376,12 @@ }, "pytest": { "hashes": [ - "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", - "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761" + "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", + "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==8.3.4" + "version": "==8.3.5" }, "pytest-asyncio": { "hashes": [ @@ -1462,79 +1467,79 @@ }, "websockets": { "hashes": [ - "sha256:0e389efe46ccb25a1f93d08c7a74e8123a2517f7b7458f043bd7529d1a63ffeb", - "sha256:0f2205cdb444a42a7919690238fb5979a05439b9dbb73dd47c863d39640d85ab", - "sha256:10552fed076757a70ba2c18edcbc601c7637b30cdfe8c24b65171e824c7d6081", - "sha256:110a847085246ab8d4d119632145224d6b49e406c64f1bbeed45c6f05097b680", - "sha256:1206432cc6c644f6fc03374b264c5ff805d980311563202ed7fef91a38906276", - "sha256:1657a9eecb29d7838e3b415458cc494e6d1b194f7ac73a34aa55c6fb6c72d1f3", - "sha256:17f2854c6bd9ee008c4b270f7010fe2da6c16eac5724a175e75010aacd905b31", - "sha256:190bc6ef8690cd88232a038d1b15714c258f79653abad62f7048249b09438af3", - "sha256:1caf951110ca757b8ad9c4974f5cac7b8413004d2f29707e4d03a65d54cedf2b", - "sha256:24d5333a9b2343330f0f4eb88546e2c32a7f5c280f8dd7d3cc079beb0901781b", - "sha256:26ba70fed190708551c19a360f9d7eca8e8c0f615d19a574292b7229e0ae324c", - "sha256:2bd8ef197c87afe0a9009f7a28b5dc613bfc585d329f80b7af404e766aa9e8c7", - "sha256:2ea4f210422b912ebe58ef0ad33088bc8e5c5ff9655a8822500690abc3b1232d", - "sha256:30cff3ef329682b6182c01c568f551481774c476722020b8f7d0daacbed07a17", - "sha256:327adab7671f3726b0ba69be9e865bba23b37a605b585e65895c428f6e47e766", - "sha256:32e02a2d83f4954aa8c17e03fe8ec6962432c39aca4be7e8ee346b05a3476904", - "sha256:37d66646f929ae7c22c79bc73ec4074d6db45e6384500ee3e0d476daf55482a9", - "sha256:3a302241fbe825a3e4fe07666a2ab513edfdc6d43ce24b79691b45115273b5e7", - "sha256:3abd670ca7ce230d5a624fd3d55e055215d8d9b723adee0a348352f5d8d12ff4", - "sha256:4095a1f2093002c2208becf6f9a178b336b7572512ee0a1179731acb7788e8ad", - "sha256:45535fead66e873f411c1d3cf0d3e175e66f4dd83c4f59d707d5b3e4c56541c4", - "sha256:45d464622314973d78f364689d5dbb9144e559f93dca11b11af3f2480b5034e1", - "sha256:4f7290295794b5dec470867c7baa4a14182b9732603fd0caf2a5bf1dc3ccabf3", - "sha256:4ff380aabd7a74a42a760ee76c68826a8f417ceb6ea415bd574a035a111fd133", - "sha256:51ffd53c53c4442415b613497a34ba0aa7b99ac07f1e4a62db5dcd640ae6c3c3", - "sha256:5294fcb410ed0a45d5d1cdedc4e51a60aab5b2b3193999028ea94afc2f554b05", - "sha256:56e3efe356416bc67a8e093607315951d76910f03d2b3ad49c4ade9207bf710d", - "sha256:5d3cc75ef3e17490042c47e0523aee1bcc4eacd2482796107fd59dd1100a44bc", - "sha256:5e6ee18a53dd5743e6155b8ff7e8e477c25b29b440f87f65be8165275c87fef0", - "sha256:67a04754d121ea5ca39ddedc3f77071651fb5b0bc6b973c71c515415b44ed9c5", - "sha256:7394c0b7d460569c9285fa089a429f58465db930012566c03046f9e3ab0ed181", - "sha256:789c43bf4a10cd067c24c321238e800b8b2716c863ddb2294d2fed886fa5a689", - "sha256:7ac67b542505186b3bbdaffbc303292e1ee9c8729e5d5df243c1f20f4bb9057e", - "sha256:8561c48b0090993e3b2a54db480cab1d23eb2c5735067213bb90f402806339f5", - "sha256:86bfb52a9cfbcc09aba2b71388b0a20ea5c52b6517c0b2e316222435a8cdab72", - "sha256:8711682a629bbcaf492f5e0af72d378e976ea1d127a2d47584fa1c2c080b436b", - "sha256:89da58e4005e153b03fe8b8794330e3f6a9774ee9e1c3bd5bc52eb098c3b0c4f", - "sha256:89f72524033abbfde880ad338fd3c2c16e31ae232323ebdfbc745cbb1b3dcc03", - "sha256:8bf1ab71f9f23b0a1d52ec1682a3907e0c208c12fef9c3e99d2b80166b17905f", - "sha256:8d7bbbe2cd6ed80aceef2a14e9f1c1b61683194c216472ed5ff33b700e784e37", - "sha256:94c4a9b01eede952442c088d415861b0cf2053cbd696b863f6d5022d4e4e2453", - "sha256:98dcf978d4c6048965d1762abd534c9d53bae981a035bfe486690ba11f49bbbb", - "sha256:a4cc73a6ae0a6751b76e69cece9d0311f054da9b22df6a12f2c53111735657c8", - "sha256:a9f8e33747b1332db11cf7fcf4a9512bef9748cb5eb4d3f7fbc8c30d75dc6ffc", - "sha256:ace960769d60037ca9625b4c578a6f28a14301bd2a1ff13bb00e824ac9f73e55", - "sha256:ae721bcc8e69846af00b7a77a220614d9b2ec57d25017a6bbde3a99473e41ce8", - "sha256:aea01f40995fa0945c020228ab919b8dfc93fc8a9f2d3d705ab5b793f32d9e99", - "sha256:b499caef4bca9cbd0bd23cd3386f5113ee7378094a3cb613a2fa543260fe9506", - "sha256:b89504227a5311610e4be16071465885a0a3d6b0e82e305ef46d9b064ce5fb72", - "sha256:bd66b4865c8b853b8cca7379afb692fc7f52cf898786537dfb5e5e2d64f0a47f", - "sha256:bfcd3acc1a81f106abac6afd42327d2cf1e77ec905ae11dc1d9142a006a496b6", - "sha256:c24ba103ecf45861e2e1f933d40b2d93f5d52d8228870c3e7bf1299cd1cb8ff1", - "sha256:c348abc5924caa02a62896300e32ea80a81521f91d6db2e853e6b1994017c9f6", - "sha256:c53f97032b87a406044a1c33d1e9290cc38b117a8062e8a8b285175d7e2f99c9", - "sha256:c7cd4b1015d2f60dfe539ee6c95bc968d5d5fad92ab01bb5501a77393da4f596", - "sha256:c86dc2068f1c5ca2065aca34f257bbf4f78caf566eb230f692ad347da191f0a1", - "sha256:c8c5c8e1bac05ef3c23722e591ef4f688f528235e2480f157a9cfe0a19081375", - "sha256:ca36151289a15b39d8d683fd8b7abbe26fc50be311066c5f8dcf3cb8cee107ab", - "sha256:cc8821a03bcfb36e4e4705316f6b66af28450357af8a575dc8f4b09bf02a3dee", - "sha256:cccc18077acd34c8072578394ec79563664b1c205f7a86a62e94fafc7b59001f", - "sha256:d2244d8ab24374bed366f9ff206e2619345f9cd7fe79aad5225f53faac28b6b1", - "sha256:d4c22992e24f12de340ca5f824121a5b3e1a37ad4360b4e1aaf15e9d1c42582d", - "sha256:dd24c4d256558429aeeb8d6c24ebad4e982ac52c50bc3670ae8646c181263965", - "sha256:e413352a921f5ad5d66f9e2869b977e88d5103fc528b6deb8423028a2befd842", - "sha256:ee06405ea2e67366a661ed313e14cf2a86e84142a3462852eb96348f7219cee3", - "sha256:f83eca8cbfd168e424dfa3b3b5c955d6c281e8fc09feb9d870886ff8d03683c7", - "sha256:fb915101dfbf318486364ce85662bb7b020840f68138014972c08331458d41f3", - "sha256:ffc02b159b65c05f2ed9ec176b715b66918a674bd4daed48a9a7a590dd4be1aa", - "sha256:ffc5ae23ada6515f31604f700009e2df90b091b67d463a8401c1d8a37f76c1d7" + "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", + "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", + "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", + "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", + "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", + "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", + "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", + "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", + "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", + "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", + "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", + "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", + "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", + "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", + "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", + "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", + "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", + "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", + "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", + "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", + "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", + "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", + "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", + "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", + "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", + "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", + "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", + "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", + "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", + "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", + "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", + "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", + "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", + "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", + "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", + "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", + "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", + "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", + "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", + "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", + "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", + "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", + "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", + "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", + "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", + "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", + "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", + "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", + "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", + "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", + "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", + "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", + "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", + "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", + "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", + "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", + "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", + "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", + "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", + "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", + "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", + "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", + "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", + "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", + "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", + "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", + "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", + "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", + "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==15.0" + "version": "==15.0.1" } } } diff --git a/README.md b/README.md index 4bd71bf2f..50b12d09b 100644 --- a/README.md +++ b/README.md @@ -145,11 +145,10 @@ the server API documentation: Work is ongoing to document these messages in a comprehensive way. For now, all commands that can be sent from the client -> server can be found via the server API documentation: -[LobbyConnection](https://faforever.github.io/server/lobbyconnection.html) -under the `command_*` methods. Check the source code for what fields the message -is expected to have and any possible responses. +[LobbyConnection](https://faforever.github.io/server/types/messages/). -It may also be useful to look at the definitions in the +For server -> client messages itt may also be useful to look at the definitions +in the [faf-java-commons](https://github.com/FAForever/faf-java-commons/tree/develop/lobby/src/main/kotlin/com/faforever/commons/lobby) to see how the official client is deserializing messages from the server. diff --git a/server/__init__.py b/server/__init__.py index 40e55620c..32cf983f2 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -89,7 +89,10 @@ See `server.protocol`. ### Application messages -See `server.lobbyconnection`. +See `server.types.messages` for type definitions explaining what messages, and +fields are available. + +See `server.lobbyconnection` for the implementation of those message handlers. Terms: diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index b5c8c77dc..8ae7c4ac7 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -1,5 +1,7 @@ """ -Handles requests from connected clients +Handles requests from connected clients. + +Message type definitions can be found at `server.types.messages`. """ import asyncio @@ -61,6 +63,7 @@ from .rating import InclusiveRange, RatingType from .rating_service import RatingService from .types import Address, GameLaunchOptions +from .types.messages import client def ice_only(func): @@ -270,16 +273,16 @@ async def on_message_received(self, message): self._logger.exception(e) await self.abort("Error processing command") - async def command_ping(self, msg): + async def command_ping(self, msg: client.Ping): await self.send({"command": "pong"}) - async def command_pong(self, msg): + async def command_pong(self, msg: client.Pong): pass - async def command_create_account(self, message): + async def command_create_account(self, message: dict): raise ClientError("FAF no longer supports direct registration. Please use the website to register.", recoverable=True) - async def command_coop_list(self, message): + async def command_coop_list(self, message: client.CoopList): """Request for coop map list""" async with self._db.acquire() as conn: result = await conn.stream(select(coop_map)) @@ -307,7 +310,7 @@ async def command_coop_list(self, message): "featured_mod": "coop" }) - async def command_matchmaker_info(self, message): + async def command_matchmaker_info(self, message: client.MatchmakerInfo): await self.send({ "command": "matchmaker_info", "queues": [ @@ -328,7 +331,7 @@ async def send_game_list(self): ] }) - async def command_social_remove(self, message): + async def command_social_remove(self, message: client.SocialRemove): assert self.player is not None if "friend" in message: @@ -350,7 +353,7 @@ async def command_social_remove(self, message): with self._get_visibility_context_manager(subject_id): player_attr.discard(subject_id) - async def command_social_add(self, message): + async def command_social_add(self, message: client.SocialAdd): assert self.player is not None if "friend" in message: @@ -437,12 +440,13 @@ async def send_updated_achievements(self, updated_achievements): "updated_achievements": updated_achievements }) - async def command_admin(self, message): + async def command_admin(self, message: client.Admin): assert self.player is not None - action = message["action"] + # Mypy does not understand the tagged union if we assign + # message["action"] to a variable - if action == "closeFA": + if message["action"] == "closeFA": if await self.player_service.has_permission_role( self.player, "ADMIN_KICK_SERVER" ): @@ -457,7 +461,7 @@ async def command_admin(self, message): "style": "kill", }) - elif action == "closelobby": + elif message["action"] == "closelobby": if await self.player_service.has_permission_role( self.player, "ADMIN_KICK_SERVER" ): @@ -470,7 +474,7 @@ async def command_admin(self, message): with contextlib.suppress(DisconnectedError): await player.lobby_connection.kick() - elif action == "broadcast": + elif message["action"] == "broadcast": message_text = message.get("message") if not message_text: return @@ -488,7 +492,7 @@ async def command_admin(self, message): "%s broadcasting message to all players: %s", self.player.login, message_text ) - elif action == "join_channel": + elif message["action"] == "join_channel": if await self.player_service.has_permission_role( self.player, "ADMIN_JOIN_CHANNEL" ): @@ -627,7 +631,7 @@ async def check_policy_conformity( return response.get("result", "") == "honest" - async def command_auth(self, message): + async def command_auth(self, message: client.Auth): token = message["token"] unique_id = message["unique_id"] player_id = await self.oauth_service.get_player_id_from_token(token) @@ -679,7 +683,7 @@ async def command_auth(self, message): player_id, username, unique_id, auth_method ) - async def command_hello(self, message): + async def command_hello(self, message: client.Hello): login = message["login"].strip() password = message["password"] unique_id = message["unique_id"] @@ -846,7 +850,10 @@ async def on_player_login( @ice_only @player_idle("reconnect to a game") - async def command_restore_game_session(self, message): + async def command_restore_game_session( + self, + message: client.RestoreGameSession + ): assert self.player is not None assert self.protocol is not None @@ -888,19 +895,20 @@ async def command_restore_game_session(self, message): self.player.state = PlayerState.PLAYING self.player.game = game - async def command_ask_session(self, message): + async def command_ask_session(self, message: client.AskSession): user_agent = message.get("user_agent") version = message.get("version") self._set_user_agent_and_version(user_agent, version) await self._check_user_agent() await self.send({"command": "session", "session": self.session}) - async def command_avatar(self, message): + async def command_avatar(self, message: client.Avatar): assert self.player is not None - action = message["action"] + # Mypy does not understand the tagged union if we assign + # message["action"] to a variable - if action == "list_avatar": + if message["action"] == "list_avatar": async with self._db.acquire() as conn: result = await conn.execute( select( @@ -923,7 +931,7 @@ async def command_avatar(self, message): ] }) - elif action == "select": + elif message["action"] == "select": avatar_url = message["avatar"] async with self._db.acquire() as conn: @@ -975,7 +983,7 @@ async def command_avatar(self, message): @ice_only @player_idle("join a game") - async def command_game_join(self, message): + async def command_game_join(self, message: client.GameJoin): """ We are going to join a game. """ @@ -1041,7 +1049,7 @@ async def command_game_join(self, message): await self.launch_game(game, is_host=False) @ice_only - async def command_game_matchmaking(self, message): + async def command_game_matchmaking(self, message: client.GameMatchmaking): assert self.player is not None queue_name = str( @@ -1096,7 +1104,7 @@ async def command_game_matchmaking(self, message): @ice_only @player_idle("host a game") - async def command_game_host(self, message): + async def command_game_host(self, message: client.GameHost): assert self.player is not None await self.abort_connection_if_banned() @@ -1137,7 +1145,7 @@ async def command_game_host(self, message): ) await self.launch_game(game, is_host=True) - async def command_match_ready(self, message): + async def command_match_ready(self, message: client.MatchReady): """ Replace with full implementation when implemented in client, see: https://github.com/FAForever/downlords-faf-client/issues/1783 @@ -1232,7 +1240,7 @@ def _prepare_launch_game( return {k: v for k, v in cmd.items() if v is not None} # DEPRECATED: Use the FAF API instead - async def command_modvault(self, message): + async def command_modvault(self, message: client.Modvault): assert self.player is not None type = message["type"] @@ -1311,7 +1319,7 @@ async def command_modvault(self, message): # DEPRECATED: ICE servers are handled outside of the lobby server. # This message remains here for backwards compatibility, but the list # of servers will always be empty. - async def command_ice_servers(self, message): + async def command_ice_servers(self, message: client.IceServers): if not self.player: return @@ -1321,7 +1329,7 @@ async def command_ice_servers(self, message): }) @player_idle("invite a player") - async def command_invite_to_party(self, message): + async def command_invite_to_party(self, message: client.InviteToParty): assert self.player is not None recipient = self.player_service.get_player(message["recipient_id"]) @@ -1335,7 +1343,10 @@ async def command_invite_to_party(self, message): self.party_service.invite_player_to_party(self.player, recipient) @player_idle("join a party") - async def command_accept_party_invite(self, message): + async def command_accept_party_invite( + self, + message: client.AcceptPartyInvite + ): assert self.player is not None sender = self.player_service.get_player(message["sender_id"]) @@ -1346,7 +1357,10 @@ async def command_accept_party_invite(self, message): await self.party_service.accept_invite(self.player, sender) @player_idle("kick a player") - async def command_kick_player_from_party(self, message): + async def command_kick_player_from_party( + self, + message: client.KickPlayerFromParty + ): assert self.player is not None kicked_player = self.player_service.get_player(message["kicked_player_id"]) @@ -1356,13 +1370,16 @@ async def command_kick_player_from_party(self, message): await self.party_service.kick_player_from_party(self.player, kicked_player) - async def command_leave_party(self, _message): + async def command_leave_party(self, message: client.LeaveParty): assert self.player is not None self.ladder_service.cancel_search(self.player) await self.party_service.leave_party(self.player) - async def command_set_party_factions(self, message): + async def command_set_party_factions( + self, + message: client.SetPartyFactions + ): assert self.player is not None factions = set(Faction.from_value(v) for v in message["factions"]) diff --git a/server/types.py b/server/types/__init__.py similarity index 100% rename from server/types.py rename to server/types/__init__.py diff --git a/server/types/messages/__init__.py b/server/types/messages/__init__.py new file mode 100644 index 000000000..3bd3353aa --- /dev/null +++ b/server/types/messages/__init__.py @@ -0,0 +1,3 @@ +""" +Protocol message type definitions +""" diff --git a/server/types/messages/client.py b/server/types/messages/client.py new file mode 100644 index 000000000..12199acc8 --- /dev/null +++ b/server/types/messages/client.py @@ -0,0 +1,589 @@ +"""Messages originating from the client""" + +from typing import Literal, TypedDict, Union + +from typing_extensions import NotRequired + + +class AcceptPartyInvite(TypedDict): + """Accept a party invite. + + The sender must have first created an invite by sending a `InviteToParty` + command. + + **Example** + ```json + { + "command": "accept_party_invite", + "sender_id": 1234 + } + ``` + """ + + command: Literal["accept_party_invite"] + + sender_id: int + """ID of the player who sent the invite.""" + + +Admin = Union[ + "AdminBroadcast", + "AdminCloseFA", + "AdminCloseLobby", + "AdminJoinChannel", +] + + +class AdminBroadcast(TypedDict): + """Send a message to all connected players. + + **Example** + ```json + { + "command": "admin", + "action": "broadcast", + "message": "Hello World" + } + ``` + """ + + command: Literal["admin"] + action: Literal["broadcast"] + + message: str + """Message text to send. Can contain certain HTML styling tags. + + Only applies to `broadcast`. + """ + + +class AdminCloseFA(TypedDict): + """Tell the client of a player to close the running ForgedAlliance process. + + **Example** + ```json + { + "command": "admin", + "action": "closeFA", + "user_id": 1234 + } + ``` + """ + + command: Literal["admin"] + action: Literal["closeFA"] + + user_id: int + """The target player to perform the action on. + + Only applies to `closeFA` and `closelobby` actions. + """ + + +class AdminCloseLobby(TypedDict): + """Kick a player from the server, and close their lobby server connection. + + **Example** + ```json + { + "command": "admin", + "action": "closelobby", + "user_id": 1234 + } + ``` + """ + + command: Literal["admin"] + action: Literal["closelobby"] + + user_id: int + """The target player to perform the action on.""" + + +class AdminJoinChannel(TypedDict): + """Tell the client of one or more players to join an IRC channel. + + **Example** + ```json + { + "command": "admin", + "action": "join_channel", + "user_ids": [1234, 4321], + "channel": "private_channel" + } + ``` + """ + + command: Literal["admin"] + action: Literal["join_channel"] + + user_ids: list[int] + """The target players to perform the action on.""" + + channel: str + """The chat channel to join.""" + + +class AskSession(TypedDict): + """Request session ID information needed by the faf-uid binary. + + **Example** + ```json + { + "command": "ask_session", + "user_agent": "downlords-faf-client", + "version": "2020.1.1" + } + ``` + """ + + command: Literal["ask_session"] + + user_agent: NotRequired[str] + """An identifier unique to the client software.""" + + version: NotRequired[str] + """A version number associated with the client software. + + Typically a sequence of numbers separated by dots. + """ + + +class Auth(TypedDict): + """Log in using an OAuth token. + + **Example** + ```json + { + "command": "auth", + "token": "xxxxxEXAMPLExxxxx", + "unique_id": "xxxxxEXAMPLExxxxx" + } + ``` + """ + + command: Literal["auth"] + + token: str + """JWT token returned by the API.""" + + unique_id: str + """String generated by the faf-uid binary.""" + + +Avatar = Union["AvatarListAvatar", "AvatarSelect"] + + +class AvatarListAvatar(TypedDict): + """Get a list of available avatars. + + **Example** + ```json + { + "command": "avatar", + "action": "list_avatar", + } + ``` + """ + + command: Literal["avatar"] + action: Literal["list_avatar", "select"] + + +class AvatarSelect(TypedDict): + """Select the actively displayed avatar. + + **Example** + ```json + { + "command": "avatar", + "action": "select", + "avatar": "http://content.faforever.com/avatars/example.png" + } + ``` + """ + + command: Literal["avatar"] + action: Literal["list_avatar", "select"] + + avatar: str + """The URL of the avatar to select.""" + + +class CoopList(TypedDict): + """Request a list of available coop missions. + + **Example** + ```json + { + "command": "coop_list" + } + ``` + """ + + command: Literal["coop_list"] + + +class GameHost(TypedDict): + """Request to host a custom or coop game. + + **Example** + ```json + { + "command": "game_host", + "visibility": "public", + "title": "200+", + "mod": "faf", + "mapname": "SCMP_001", + "password": "banana", + "enforce_rating_range": true, + "rating_min": 200, + } + ``` + """ + + command: Literal["game_host"] + + visibility: Literal["public", "friends"] + """Controls which players can see the game in the lobby list.""" + + title: NotRequired[str] + """Lobby title. + + Can be changed once in lobby. + """ + + mod: NotRequired[str] + """Featured mod to use.""" + + mapname: NotRequired[str] + """Map to use. + + Can be changed once in lobby. + """ + + password: NotRequired[str] + """If set, anyone joining the game will need to enter this value.""" + + enforce_rating_range: NotRequired[bool] + """Whether or not to hide the game from players who do not meet the min/max + rating requirements. + """ + + rating_min: NotRequired[float] + """Minimum (displayed) global rating requirement.""" + + rating_max: NotRequired[float] + """Maximum (displayed) global rating requirement.""" + + +class GameJoin(TypedDict): + """Request to join a custom game. + + **Example** + ```json + { + "command": "game_join", + "uid": 1234, + "password": "banana" + } + ``` + """ + + command: Literal["game_join"] + + uid: int + """ID of the game to join.""" + + password: NotRequired[str] + """Optional password if joining a password protected game.""" + + +class GameMatchmaking(TypedDict): + """Request to join a matchmaker queue. + + **Example** + ```json + { + "command": "game_matchmaking", + "queue_name": "ladder_1v1", + "state": "start", + } + ``` + """ + + command: Literal["game_matchmaking"] + + queue_name: str + """Technical name of the queue to join.""" + + state: Literal["start", "stop"] + """Whether to start or stop searching.""" + + mod: NotRequired[str] + """Technical name of the queue to join. + + DEPRECATED: Use `queue_name` instead. + """ + + faction: NotRequired[str] + """Which faction to use when the game starts. + + DEPRECATED: Use party to set faction instead. + """ + + +class Hello(TypedDict): + """Log in using a username and password. + + DEPRECATED: Use Auth instead. + + **Example** + ```json + { + "command": "hello", + "login": "username", + "password": "xxxxxEXAMPLExxxxx", + "unique_id": "xxxxxEXAMPLExxxxx" + } + ``` + """ + + command: Literal["hello"] + + login: str + """Player username.""" + + password: str + """SHA256 hash of the player's password""" + + unique_id: str + """String generated by the faf-uid binary.""" + + +class IceServers(TypedDict): + """Request a list of available ICE server credentials. + + DEPRECATED: ICE servers are handled by the icebreaker service. + + **Example** + ```json + { + "command": "ice_servers" + } + ``` + """ + + command: Literal["ice_servers"] + + +class InviteToParty(TypedDict): + """Request to send a party invite to a player. + + **Example** + ```json + { + "command": "invite_to_party", + "recipient_id": 1234 + } + ``` + """ + + command: Literal["invite_to_party"] + + recipient_id: int + """ID of the player to invite.""" + + +class KickPlayerFromParty(TypedDict): + """Request to kick a player from your party. + + **Example** + ```json + { + "command": "kick_player_from_party", + "kicked_player_id": 1234, + } + ``` + """ + + command: Literal["kick_player_from_party"] + + kicked_player_id: int + """ID of the player to kick.""" + + +class LeaveParty(TypedDict): + """Request to leave your current party. + + **Example** + ```json + { + "command": "leave_party" + } + ``` + """ + + command: Literal["leave_party"] + + +class MatchmakerInfo(TypedDict): + """Request a list of available matchmaker queues. + + **Example** + ```json + { + "command": "matchmaker_info" + } + ``` + """ + + command: Literal["matchmaker_info"] + + +class MatchReady(TypedDict): + """Signal that the player has accepted their matchmaker game. + + **Example** + ```json + { + "command": "match_ready" + } + ``` + """ + + command: Literal["match_ready"] + + +class Modvault(TypedDict): + """ + DEPRECATED: This command will be removed in the future. Use the FAF API + instead. + """ + + command: Literal["modvault"] + + type: Literal["start", "like", "download"] + uid: NotRequired[int] + + +class Ping(TypedDict): + """Request a pong message. + + **Example** + ```json + { + "command": "ping" + } + ``` + """ + + command: Literal["ping"] + + +class Pong(TypedDict): + """Response to a ping message. + + **Example** + ```json + { + "command": "pong" + } + ``` + """ + + command: Literal["pong"] + + +class RestoreGameSession(TypedDict): + """Reconnect to a game after being disconnected from the lobby server. + + This only works to restore GPGNet functionality so long as the connection + to other players was never lost. Reconnection to other players is handled + by the ICE adapter. + + **Example** + ```json + { + "command": "restore_game_session", + "game_id": 1234 + } + ``` + """ + + command: Literal["restore_game_session"] + + game_id: int + """The ID of the game to reconnect to.""" + + +class SetPartyFactions(TypedDict): + """Set list of faction choices to use during matchmaker games. + + **Example** + ```json + { + "command": "set_party_factions", + "factions": ["uef", "cybran"] + } + ``` + """ + + command: Literal["set_party_factions"] + + factions: list[Union[Literal["aeon", "uef", "cybran", "seraphim"], int]] + """List of faction choices. + + Factions can be sent either using their string values (`aeon`, `uef`, + `cybran`, `seraphim`), or by sending their integer codes. Duplicates will be + ignored. The list must contain at least one valid choice. + + DEPRECATED: Sending factions as integers. Send strings instead. + """ + + +class SocialAdd(TypedDict): + """Add a player to the friend list or foe list. + + Exactly one of the `friend` or `foe` fields should be supplied. + + **Example** + ```json + { + "command": "social_add", + "friend": 1234 + } + ``` + """ + + command: Literal["social_add"] + + friend: NotRequired[int] + """If present, indicate the player ID of the player to add as a friend.""" + + foe: NotRequired[int] + """If present, indicate the player ID of the player to add as a foe.""" + + +class SocialRemove(TypedDict): + """Remove a player from the friend list or foe list. + + Exactly one of the `friend` or `foe` fields should be supplied. + + **Example** + ```json + { + "command": "social_remove", + "friend": 1234 + } + ``` + """ + command: Literal["social_remove"] + + friend: NotRequired[int] + """If present, indicate the player ID of the player to remove from the + friend list.""" + + foe: NotRequired[int] + """If present, indicate the player ID of the player to remove from the + foe list.""" From e3c4d1ff5b748623a41d7656b1a37b893f44f980 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sat, 15 Mar 2025 17:24:47 -0400 Subject: [PATCH 2/3] Make game host non-optional --- server/game_service.py | 2 +- server/games/game.py | 2 +- server/lobbyconnection.py | 4 +- server/types/messages/client.py | 4 +- tests/conftest.py | 23 +- tests/unit_tests/conftest.py | 70 +++++- tests/unit_tests/test_custom_game.py | 7 - tests/unit_tests/test_game.py | 31 +-- tests/unit_tests/test_game_rating.py | 26 +-- tests/unit_tests/test_game_stats_service.py | 5 +- tests/unit_tests/test_gameconnection.py | 226 ++++++++++---------- tests/unit_tests/test_games_service.py | 7 +- tests/unit_tests/test_laddergame.py | 13 +- tests/unit_tests/test_lobbyconnection.py | 94 +++----- 14 files changed, 253 insertions(+), 261 deletions(-) diff --git a/server/game_service.py b/server/game_service.py index 735dabe82..e17e135e3 100644 --- a/server/game_service.py +++ b/server/game_service.py @@ -197,9 +197,9 @@ def create_uid(self) -> int: def create_game( self, game_mode: str, + host: Player, game_class: type[Game] = CustomGame, visibility=VisibilityState.PUBLIC, - host: Optional[Player] = None, name: Optional[str] = None, map: Map = MAP_DEFAULT, password: Optional[str] = None, diff --git a/server/games/game.py b/server/games/game.py index 1dd6e3d76..faedb4065 100644 --- a/server/games/game.py +++ b/server/games/game.py @@ -71,7 +71,7 @@ def __init__( database: FAFDatabase, game_service: "GameService", game_stats_service: "GameStatsService", - host: Optional[Player] = None, + host: Player, name: str = "New Game", map: Map = MAP_DEFAULT, game_mode: str = FeaturedModType.FAF, diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 8ae7c4ac7..47e47a6f8 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -1132,10 +1132,10 @@ async def command_game_host(self, message: client.GameHost): game_class = CoopGame if game_mode == FeaturedModType.COOP else CustomGame game = self.game_service.create_game( - visibility=visibility, - game_mode=game_mode, game_class=game_class, + game_mode=game_mode, host=self.player, + visibility=visibility, name=title, map=game_map, password=password, diff --git a/server/types/messages/client.py b/server/types/messages/client.py index 12199acc8..53a77494f 100644 --- a/server/types/messages/client.py +++ b/server/types/messages/client.py @@ -187,7 +187,7 @@ class AvatarListAvatar(TypedDict): """ command: Literal["avatar"] - action: Literal["list_avatar", "select"] + action: Literal["list_avatar"] class AvatarSelect(TypedDict): @@ -204,7 +204,7 @@ class AvatarSelect(TypedDict): """ command: Literal["avatar"] - action: Literal["list_avatar", "select"] + action: Literal["select"] avatar: str """The URL of the avatar to select.""" diff --git a/tests/conftest.py b/tests/conftest.py index 7b8934f8e..7189fdd6f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -205,11 +205,6 @@ def transport(): return mock.Mock(spec=asyncio.Transport) -@pytest.fixture -async def game(database, players): - return make_game(database, 1, players) - - GAME_UID = 1 COOP_GAME_UID = 2 @@ -233,17 +228,19 @@ async def coop_game(database, players): def make_game(database, uid, players, game_type=Game): - mock_parent = mock.AsyncMock() game = mock.create_autospec( - spec=game_type(uid, database, mock_parent, mock.AsyncMock()) + spec=game_type( + id=uid, + database=database, + game_service=mock.Mock(), + game_stats_service=mock.Mock(), + host=players.hosting, + ), + init_mode=InitMode.NORMAL_LOBBY, ) - players.hosting.getGame = mock.AsyncMock(return_value=game) - players.joining.getGame = mock.AsyncMock(return_value=game) - players.peer.getGame = mock.AsyncMock(return_value=game) - game.host = players.hosting - game.init_mode = InitMode.NORMAL_LOBBY - game.name = "Some game name" game.id = uid + game.name = "Some game name" + game.host = players.hosting, return game diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index a1d9ffe12..970efabf1 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -8,10 +8,11 @@ from server import GameStatsService from server.game_service import GameService from server.gameconnection import GameConnection, GameConnectionState -from server.games import Game +from server.games import CoopGame, CustomGame, Game, LadderGame from server.ladder_service import LadderService from server.ladder_service.violation_service import ViolationService from server.protocol import QDataStreamProtocol +from server.rating import RatingType @pytest.fixture(scope="session") @@ -78,14 +79,14 @@ async def violation_service(): async def game_connection( request, database, - game, + mock_game, players, game_service, player_service, ): conn = GameConnection( database=database, - game=game, + game=mock_game, player=players.hosting, protocol=mock.create_autospec(QDataStreamProtocol), player_service=player_service, @@ -182,6 +183,69 @@ def add(gameobj: Game, n: int, team: int = None): return add +@pytest.fixture +async def game(database, game_service, game_stats_service, players): + return Game( + 42, + database, + game_service, + game_stats_service, + host=players.hosting, + rating_type=RatingType.GLOBAL + ) + + +@pytest.fixture +async def coop_game(database, game_service, game_stats_service, players): + return CoopGame( + 42, + database, + game_service, + game_stats_service, + host=players.hosting, + ) + + +@pytest.fixture +async def custom_game(database, game_service, game_stats_service, players): + return CustomGame( + 42, + database, + game_service, + game_stats_service, + host=players.hosting, + ) + + +@pytest.fixture +async def ladder_game(database, game_service, game_stats_service, players): + return LadderGame( + 42, + database, + game_service, + game_stats_service, + host=players.hosting, + rating_type=RatingType.LADDER_1V1, + ) + + +@pytest.fixture +async def mock_game(database, game_service, game_stats_service, players): + game = mock.create_autospec( + Game( + id=42, + database=database, + game_service=game_service, + game_stats_service=game_stats_service, + host=players.hosting, + ), + ) + game.id = 42 + game.host = players.hosting + + return game + + class Benchmark(AbstractContextManager): """A contextmanager for benchmarking a section of code. diff --git a/tests/unit_tests/test_custom_game.py b/tests/unit_tests/test_custom_game.py index 42c0eb691..05d80bbd7 100644 --- a/tests/unit_tests/test_custom_game.py +++ b/tests/unit_tests/test_custom_game.py @@ -1,17 +1,10 @@ import time -import pytest - from server.games import CustomGame, GameState, ValidityState from server.rating import RatingType from tests.unit_tests.conftest import add_connected_players -@pytest.fixture -async def custom_game(database, game_service, game_stats_service): - return CustomGame(42, database, game_service, game_stats_service) - - async def test_rate_game_early_abort_no_enforce( custom_game: CustomGame, player_factory diff --git a/tests/unit_tests/test_game.py b/tests/unit_tests/test_game.py index 5e5b10074..b16e3b143 100644 --- a/tests/unit_tests/test_game.py +++ b/tests/unit_tests/test_game.py @@ -10,8 +10,6 @@ from server.gameconnection import GameConnection, GameConnectionState from server.games import ( - CoopGame, - CustomGame, Game, GameError, GameState, @@ -31,21 +29,6 @@ from tests.utils import fast_forward -@pytest.fixture -async def game(database, game_service, game_stats_service): - return Game(42, database, game_service, game_stats_service, rating_type=RatingType.GLOBAL) - - -@pytest.fixture -async def coop_game(database, game_service, game_stats_service): - return CoopGame(42, database, game_service, game_stats_service) - - -@pytest.fixture -async def custom_game(database, game_service, game_stats_service): - return CustomGame(42, database, game_service, game_stats_service) - - async def game_player_scores(database, game): async with database.acquire() as conn: result = await conn.execute( @@ -60,11 +43,17 @@ async def test_initialization(game: Game): assert game.enforce_rating is False -async def test_instance_logging(database, game_stats_service): +async def test_instance_logging(database, game_stats_service, players): logger = logging.getLogger(f"{Game.__qualname__}.5") logger.debug = mock.Mock() mock_parent = mock.Mock() - game = Game(5, database, mock_parent, game_stats_service) + game = Game( + 5, + database, + mock_parent, + game_stats_service, + host=players.hosting, + ) logger.debug.assert_called_with("%s created", game) @@ -830,14 +819,14 @@ async def test_get_army_score_conflicting_results_tied(game, game_add_players): async def test_equality(game): assert game == game - assert game != Game(5, mock.Mock(), mock.Mock(), mock.Mock()) + assert game != Game(5, mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock()) assert game != "a string" async def test_hashing(game): assert { game: 1, - Game(game.id, mock.Mock(), mock.Mock(), mock.Mock()): 1 + Game(game.id, mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock()): 1 } == { game: 1 } diff --git a/tests/unit_tests/test_game_rating.py b/tests/unit_tests/test_game_rating.py index d852aae72..48edf7eca 100644 --- a/tests/unit_tests/test_game_rating.py +++ b/tests/unit_tests/test_game_rating.py @@ -5,14 +5,7 @@ import pytest -from server.games import ( - CustomGame, - Game, - GameError, - GameState, - LadderGame, - ValidityState -) +from server.games import Game, GameError, GameState, ValidityState from server.games.game_results import GameOutcome from server.games.typedefs import TeamRatingSummary from server.rating import PlayerRatings, Rating, RatingType @@ -119,23 +112,6 @@ def get_published_results_by_player_id(mock_service): return result -@pytest.fixture -async def game(database, game_service, game_stats_service): - return Game( - 42, database, game_service, game_stats_service, rating_type=RatingType.GLOBAL - ) - - -@pytest.fixture -async def custom_game(database, game_service, game_stats_service): - return CustomGame(42, database, game_service, game_stats_service) - - -@pytest.fixture -async def ladder_game(database, game_service, game_stats_service): - return LadderGame(42, database, game_service, game_stats_service, rating_type=RatingType.LADDER_1V1) - - def add_players_with_rating(player_factory, game, ratings, teams): rating_service = game.game_service._rating_service diff --git a/tests/unit_tests/test_game_stats_service.py b/tests/unit_tests/test_game_stats_service.py index caf6ce153..f841fb537 100644 --- a/tests/unit_tests/test_game_stats_service.py +++ b/tests/unit_tests/test_game_stats_service.py @@ -4,7 +4,6 @@ import pytest from server.factions import Faction -from server.games import Game from server.games.game_results import ( ArmyReportedOutcome, GameResultReport, @@ -40,8 +39,8 @@ def player(player_factory): @pytest.fixture() -async def game(database, game_stats_service, player): - game = Game(1, database, mock.Mock(), game_stats_service) +async def game(game, player): + game.host = player game._player_options[player.id] = {"Army": 1} game._results = GameResultReports(1) game._results.add(GameResultReport(1, 1, ArmyReportedOutcome.VICTORY, 0)) diff --git a/tests/unit_tests/test_gameconnection.py b/tests/unit_tests/test_gameconnection.py index f39b9facb..bbed8218f 100644 --- a/tests/unit_tests/test_gameconnection.py +++ b/tests/unit_tests/test_gameconnection.py @@ -23,11 +23,6 @@ from tests.utils import exhaust_callbacks -@pytest.fixture -async def real_game(database, game_service, game_stats_service): - return Game(42, database, game_service, game_stats_service) - - def assert_message_sent(game_connection: GameConnection, command, args): game_connection.protocol.send_message.assert_called_with({ "command": command, @@ -36,23 +31,23 @@ def assert_message_sent(game_connection: GameConnection, command, args): }) -async def test_abort(game_connection: GameConnection, game: Game, players): +async def test_abort(game_connection: GameConnection, mock_game: Game, players): game_connection.player = players.hosting - game_connection.game = game + game_connection.game = mock_game await game_connection.abort() - game.remove_game_connection.assert_called_with(game_connection) + mock_game.remove_game_connection.assert_called_with(game_connection) async def test_disconnect_all_peers( game_connection: GameConnection, - real_game: Game, + game: Game, players ): - real_game.state = GameState.LOBBY + game.state = GameState.LOBBY game_connection.player = players.hosting - game_connection.game = real_game + game_connection.game = game disconnect_done = mock.Mock() @@ -72,8 +67,8 @@ async def fake_send_dc(player_id): fail_disconnect.state = GameConnectionState.CONNECTED_TO_HOST # Add the peers to the game - real_game.add_game_connection(fail_disconnect) - real_game.add_game_connection(ok_disconnect) + game.add_game_connection(fail_disconnect) + game.add_game_connection(ok_disconnect) await game_connection.disconnect_all_peers() @@ -100,17 +95,17 @@ async def test_connect_to_peer_disconnected(game_connection): async def test_handle_action_GameState_idle_adds_connection( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): - players.joining.game = game + players.joining.game = mock_game game_connection.player = players.hosting - game_connection.game = game + game_connection.game = mock_game await game_connection.handle_action("GameState", ["Idle"]) - game.add_game_connection.assert_called_with(game_connection) + mock_game.add_game_connection.assert_called_with(game_connection) async def test_handle_action_GameState_idle_sets_player_state( @@ -135,29 +130,29 @@ async def test_handle_action_GameState_idle_sets_player_state( async def test_handle_action_GameState_lobby_sends_HostGame( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): game_connection.player = players.hosting - game.map = Map(None, "some_map") + mock_game.map = Map(None, "some_map") await game_connection.handle_action("GameState", ["Lobby"]) await exhaust_callbacks() - assert_message_sent(game_connection, "HostGame", [game.map.folder_name]) + assert_message_sent(game_connection, "HostGame", [mock_game.map.folder_name]) async def test_handle_action_GameState_lobby_calls_ConnectToHost( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): game_connection.send = mock.AsyncMock() game_connection.connect_to_host = mock.AsyncMock() game_connection.player = players.joining - players.joining.game = game - game.host = players.hosting + players.joining.game = mock_game + mock_game.host = players.hosting await game_connection.handle_action("GameState", ["Lobby"]) await exhaust_callbacks() @@ -166,7 +161,7 @@ async def test_handle_action_GameState_lobby_calls_ConnectToHost( async def test_handle_action_GameState_lobby_calls_ConnectToPeer( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): @@ -175,13 +170,13 @@ async def test_handle_action_GameState_lobby_calls_ConnectToPeer( game_connection.connect_to_peer = mock.AsyncMock() game_connection.player = players.joining - players.joining.game = game + players.joining.game = mock_game - game.host = players.hosting - game.map = Map(None, "some_map") + mock_game.host = players.hosting + mock_game.map = Map(None, "some_map") peer_conn = mock.Mock() players.peer.game_connection = peer_conn - game.connections = [peer_conn] + mock_game.connections = [peer_conn] await game_connection.handle_action("GameState", ["Lobby"]) await exhaust_callbacks() @@ -190,19 +185,18 @@ async def test_handle_action_GameState_lobby_calls_ConnectToPeer( async def test_handle_lobby_state_handles_GameError( - real_game: Game, + game: Game, game_connection: GameConnection, players ): game_connection.abort = mock.AsyncMock() game_connection.connect_to_host = mock.AsyncMock() game_connection.player = players.joining - game_connection.game = real_game + game_connection.game = game - players.joining.game = real_game + players.joining.game = game - real_game.host = players.hosting - real_game.state = GameState.ENDED + game.state = GameState.ENDED await game_connection.handle_action("GameState", ["Lobby"]) await exhaust_callbacks() @@ -211,17 +205,17 @@ async def test_handle_lobby_state_handles_GameError( async def test_handle_action_GameState_lobby_calls_abort( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): game_connection.send = mock.AsyncMock() game_connection.abort = mock.AsyncMock() game_connection.player = players.joining - players.joining.game = game - game.host = players.hosting - game.host.state = PlayerState.IDLE - game.map = Map(None, "some_map") + players.joining.game = mock_game + mock_game.host = players.hosting + mock_game.host.state = PlayerState.IDLE + mock_game.map = Map(None, "some_map") await game_connection.handle_action("GameState", ["Lobby"]) await exhaust_callbacks() @@ -230,33 +224,33 @@ async def test_handle_action_GameState_lobby_calls_abort( async def test_handle_action_GameState_launching_calls_launch( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): game_connection.player = players.hosting - game_connection.game = game - game.launch = mock.AsyncMock() - game.state = GameState.LOBBY + game_connection.game = mock_game + mock_game.launch = mock.AsyncMock() + mock_game.state = GameState.LOBBY await game_connection.handle_action("GameState", ["Launching"]) - game.launch.assert_any_call() + mock_game.launch.assert_any_call() async def test_handle_action_GameState_launching_when_ended( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): game_connection.player = players.hosting - game_connection.game = game - game.launch = mock.AsyncMock() - game.state = GameState.ENDED + game_connection.game = mock_game + mock_game.launch = mock.AsyncMock() + mock_game.state = GameState.ENDED await game_connection.handle_action("GameState", ["Launching"]) - game.launch.assert_not_called() + mock_game.launch.assert_not_called() async def test_handle_action_GameState_ended_calls_on_connection_closed( @@ -267,9 +261,12 @@ async def test_handle_action_GameState_ended_calls_on_connection_closed( game_connection.on_connection_closed.assert_called_once_with() -async def test_handle_action_PlayerOption(game: Game, game_connection: GameConnection): +async def test_handle_action_PlayerOption( + mock_game: Game, + game_connection: GameConnection, +): await game_connection.handle_action("PlayerOption", [1, "Color", 2]) - game.set_player_option.assert_called_once_with(1, "Color", 2) + mock_game.set_player_option.assert_called_once_with(1, "Color", 2) async def test_handle_action_PlayerOption_malformed_no_raise(game_connection: GameConnection): @@ -278,47 +275,53 @@ async def test_handle_action_PlayerOption_malformed_no_raise(game_connection: Ga async def test_handle_action_PlayerOption_not_host( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): game_connection.player = players.joining await game_connection.handle_action("PlayerOption", [1, "Color", 2]) - game.set_player_option.assert_not_called() + mock_game.set_player_option.assert_not_called() -async def test_handle_action_GameMods(game: Game, game_connection: GameConnection): +async def test_handle_action_GameMods( + mock_game: Game, + game_connection: GameConnection, +): await game_connection.handle_action("GameMods", ["uids", "foo baz"]) - assert game.mods == {"baz": "test-mod2", "foo": "test-mod"} + assert mock_game.mods == {"baz": "test-mod2", "foo": "test-mod"} -async def test_handle_action_GameMods_activated(game: Game, game_connection: GameConnection): - game.mods = {"a": "b"} +async def test_handle_action_GameMods_activated( + mock_game: Game, + game_connection: GameConnection, +): + mock_game.mods = {"a": "b"} await game_connection.handle_action("GameMods", ["activated", 0]) - assert game.mods == {} + assert mock_game.mods == {} await game_connection.handle_action("GameMods", ["activated", "0"]) - assert game.mods == {} + assert mock_game.mods == {} async def test_handle_action_GameMods_not_host( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): game_connection.player = players.joining - mods = game.mods + mods = mock_game.mods await game_connection.handle_action("GameMods", ["uids", "foo baz"]) - assert game.mods == mods + assert mock_game.mods == mods async def test_handle_action_GameMods_post_launch_updates_played_cache( - game: Game, + mock_game: Game, game_connection: GameConnection, database ): - game.launch = mock.AsyncMock() - game.state = GameState.LOBBY - game.remove_game_connection = mock.AsyncMock() + mock_game.launch = mock.AsyncMock() + mock_game.state = GameState.LOBBY + mock_game.remove_game_connection = mock.AsyncMock() await game_connection.handle_action("GameMods", ["uids", "foo bar EA040F8E-857A-4566-9879-0D37420A5B9D"]) await game_connection.handle_action("GameState", ["Launching"]) @@ -333,21 +336,21 @@ async def test_handle_action_GameMods_post_launch_updates_played_cache( async def test_handle_action_AIOption( - game: Game, + mock_game: Game, game_connection: GameConnection ): await game_connection.handle_action("AIOption", ["QAI", "StartSpot", 1]) - game.set_ai_option.assert_called_once_with("QAI", "StartSpot", 1) + mock_game.set_ai_option.assert_called_once_with("QAI", "StartSpot", 1) async def test_handle_action_AIOption_not_host( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): game_connection.player = players.joining await game_connection.handle_action("AIOption", ["QAI", "StartSpot", 1]) - game.set_ai_option.assert_not_called() + mock_game.set_ai_option.assert_not_called() async def test_handle_action_Bottleneck(game_connection: GameConnection): @@ -379,23 +382,23 @@ async def test_handle_action_Chat(game_connection: GameConnection): async def test_handle_action_ClearSlot( - game: Game, + mock_game: Game, game_connection: GameConnection ): await game_connection.handle_action("ClearSlot", [1]) - game.clear_slot.assert_called_once_with(1) + mock_game.clear_slot.assert_called_once_with(1) await game_connection.handle_action("ClearSlot", ["1"]) - game.clear_slot.assert_called_with(1) + mock_game.clear_slot.assert_called_with(1) async def test_handle_action_ClearSlot_not_host( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): game_connection.player = players.joining await game_connection.handle_action("ClearSlot", [1]) - game.clear_slot.assert_not_called() + mock_game.clear_slot.assert_not_called() async def test_handle_action_Disconnected(game_connection: GameConnection): @@ -411,17 +414,23 @@ async def test_handle_action_GameFull(game_connection: GameConnection): async def test_handle_action_GameResult_calls_add_result( - game: Game, + mock_game: Game, game_connection: GameConnection ): game_connection.connect_to_host = mock.AsyncMock() await game_connection.handle_action("GameResult", [0, "score -5"]) - game.add_result.assert_called_once_with(game_connection.player.id, 0, "score", -5, frozenset()) + mock_game.add_result.assert_called_once_with( + game_connection.player.id, + 0, + "score", + -5, + frozenset(), + ) async def test_cannot_parse_game_results( - game: Game, + mock_game: Game, game_connection: GameConnection, caplog ): @@ -429,19 +438,17 @@ async def test_cannot_parse_game_results( with caplog.at_level(logging.WARNING): await game_connection.handle_action("GameResult", [0, ""]) - game.add_result.assert_not_called() + mock_game.add_result.assert_not_called() assert "Invalid result" in caplog.messages[0] async def test_handle_action_GameOption( - real_game: Game, + game: Game, game_connection: GameConnection, players, ): - game = real_game - game.host = players.hosting game_connection.player = players.hosting - game_connection.game = real_game + game_connection.game = game game.game_options.clear() await game_connection.handle_action("GameOption", ["Victory", "sandbox"]) @@ -470,15 +477,13 @@ async def test_handle_action_GameOption( "////maps/////x1mp_002////x1mp_002_scenario.lua", )) async def test_handle_action_GameOption_ScenarioFile( - real_game: Game, + game: Game, game_connection: GameConnection, players, scenario_file, ): - game = real_game - game.host = players.hosting game_connection.player = players.hosting - game_connection.game = real_game + game_connection.game = game await game_connection.handle_action( "GameOption", @@ -489,46 +494,46 @@ async def test_handle_action_GameOption_ScenarioFile( async def test_handle_action_GameOption_not_host( - game: Game, + mock_game: Game, game_connection: GameConnection, players ): game_connection.player = players.joining - game.game_options = {"Victory": "asdf"} + mock_game.game_options = {"Victory": "asdf"} await game_connection.handle_action("GameOption", ["Victory", "sandbox"]) - assert game.game_options == {"Victory": "asdf"} + assert mock_game.game_options == {"Victory": "asdf"} async def test_json_stats( - real_game: Game, + game: Game, game_connection: GameConnection, ): - game_connection.game = real_game + game_connection.game = game await game_connection.handle_action("JsonStats", ['{"stats": {}}']) async def test_json_stats_malformed( - real_game: Game, + game: Game, game_connection: GameConnection, ): - game_connection.game = real_game + game_connection.game = game await game_connection.handle_action("JsonStats", ['{"stats": {}']) async def test_handle_json_stats_malformed( - real_game: Game, + game: Game, game_connection: GameConnection, ): - game_connection.game = real_game + game_connection.game = game await game_connection.handle_json_stats('{"stats": {}') async def test_handle_action_EnforceRating( - game: Game, + mock_game: Game, game_connection: GameConnection ): await game_connection.handle_action("EnforceRating", []) - assert game.enforce_rating is True + assert mock_game.enforce_rating is True async def test_handle_action_Rehost(game_connection: GameConnection): @@ -538,38 +543,40 @@ async def test_handle_action_Rehost(game_connection: GameConnection): async def test_handle_action_TeamkillReport( - game: Game, + mock_game: Game, game_connection: GameConnection, database ): - game.launch = mock.AsyncMock() + mock_game.id = 1 + mock_game.launch = mock.AsyncMock() await game_connection.handle_action("TeamkillReport", ["200", "2", "Dostya", "3", "Rhiza"]) async with database.acquire() as conn: result = await conn.execute( "select game_id,id from moderation_report where reporter_id=2 and " "game_id=:id and game_incident_timecode=200", - {"id": game.id} + {"id": mock_game.id} ) report = result.fetchone() assert report is None async def test_handle_action_TeamkillHappened( - game: Game, + mock_game: Game, game_connection: GameConnection, database ): - game.launch = mock.AsyncMock() + mock_game.id = 1 + mock_game.launch = mock.AsyncMock() await game_connection.handle_action("TeamkillHappened", ["200", "2", "Dostya", "3", "Rhiza"]) async with database.acquire() as conn: result = await conn.execute( "select game_id from teamkills where victim=2 and teamkiller=3 and " "game_id=:id and gametime=200", - {"id": game.id} + {"id": mock_game.id} ) row = result.fetchone() - assert game.id == row.game_id + assert mock_game.id == row.game_id async def test_handle_action_TeamkillHappened_AI( @@ -582,13 +589,13 @@ async def test_handle_action_TeamkillHappened_AI( async def test_handle_action_GameEnded_ends_sim( - game: Game, + mock_game: Game, game_connection: GameConnection ): await game_connection.handle_action("GameEnded", []) assert game_connection.finished_sim - game.check_game_finish.assert_called_once() + mock_game.check_game_finish.assert_called_once() @pytest.mark.parametrize( @@ -777,8 +784,11 @@ async def test_handle_action_invalid(game_connection: GameConnection): game_connection.protocol.send_message.assert_not_called() -async def test_result_format_phantom(game: Game, game_connection: GameConnection): +async def test_result_format_phantom( + mock_game: Game, + game_connection: GameConnection, +): await game_connection.handle_action("GameResult", [0, "phantom score -5"]) - game.add_result.assert_called_once_with( + mock_game.add_result.assert_called_once_with( game_connection.player.id, 0, "score", -5, frozenset(["phantom"]) ) diff --git a/tests/unit_tests/test_games_service.py b/tests/unit_tests/test_games_service.py index c2f25ff67..89dc80a1a 100644 --- a/tests/unit_tests/test_games_service.py +++ b/tests/unit_tests/test_games_service.py @@ -32,21 +32,22 @@ async def test_initialize_game_counter_empty(game_service, database): assert game_service.game_id_counter == 0 -async def test_graceful_shutdown(game_service): +async def test_graceful_shutdown(players, game_service): await game_service.graceful_shutdown() with pytest.raises(DisabledError): game_service.create_game( game_mode="faf", + host=players.hosting, map=Map(None, "SCMP_007"), - ) @fast_forward(2) -async def test_drain_games(game_service): +async def test_drain_games(players, game_service): game = game_service.create_game( game_mode="faf", + host=players.hosting, name="TestGame" ) diff --git a/tests/unit_tests/test_laddergame.py b/tests/unit_tests/test_laddergame.py index 4abf5aa1c..ba1924916 100644 --- a/tests/unit_tests/test_laddergame.py +++ b/tests/unit_tests/test_laddergame.py @@ -12,19 +12,19 @@ @pytest.fixture() -async def laddergame(database, game_service, game_stats_service): +async def laddergame(database, game_service, game_stats_service, players): return LadderGame( id=465312, database=database, game_service=game_service, game_stats_service=game_stats_service, - rating_type=RatingType.LADDER_1V1 + rating_type=RatingType.LADDER_1V1, + host=players.hosting, ) async def test_handle_game_closed_manually(laddergame, players): laddergame.state = GameState.LOBBY - laddergame.host = players.hosting await laddergame.check_game_finish(players.hosting) e1 = laddergame._hosted_future.exception() assert isinstance(e1, GameClosedError) @@ -150,8 +150,11 @@ async def test_rate_game(laddergame: LadderGame, database, game_add_players): assert rows[1].after_deviation < rows[0].deviation -async def test_persist_rating_victory(laddergame: LadderGame, database, - game_add_players): +async def test_persist_rating_victory( + laddergame: LadderGame, + database, + game_add_players, +): laddergame.state = GameState.LOBBY players = game_add_players(laddergame, 2) laddergame.set_player_option(players[0].id, "Team", 2) diff --git a/tests/unit_tests/test_lobbyconnection.py b/tests/unit_tests/test_lobbyconnection.py index 24cc60e4f..b93970f2c 100644 --- a/tests/unit_tests/test_lobbyconnection.py +++ b/tests/unit_tests/test_lobbyconnection.py @@ -317,22 +317,16 @@ async def test_command_game_host_creates_correct_game( async def test_command_game_join_calls_join_game( - database, lobbyconnection, + game, game_service, test_game_info, players, - game_stats_service ): lobbyconnection.send = mock.AsyncMock() lobbyconnection.game_service = game_service - game = Game(42, database, game_service, game_stats_service) game.state = GameState.LOBBY - game.password = None - game.game_mode = "faf" - game.id = 42 game.name = "Test Game Name" - game.host = players.hosting game_service._games[42] = game lobbyconnection.player = players.joining players.joining.state = PlayerState.IDLE @@ -348,7 +342,7 @@ async def test_command_game_join_calls_join_game( "uid": 42, "mod": "faf", "name": "Test Game Name", - "init_mode": InitMode.NORMAL_LOBBY.value, + "init_mode": 0, "game_type": "custom", "rating_type": "global", } @@ -356,22 +350,16 @@ async def test_command_game_join_calls_join_game( async def test_command_game_join_uid_as_str( - database, lobbyconnection, + game, game_service, test_game_info, players, - game_stats_service ): lobbyconnection.send = mock.AsyncMock() lobbyconnection.game_service = game_service - game = Game(42, database, game_service, game_stats_service) game.state = GameState.LOBBY - game.password = None - game.game_mode = "faf" - game.id = 42 game.name = "Test Game Name" - game.host = players.hosting game_service._games[42] = game lobbyconnection.player = players.joining players.joining.state = PlayerState.IDLE @@ -387,7 +375,7 @@ async def test_command_game_join_uid_as_str( "mod": "faf", "uid": 42, "name": "Test Game Name", - "init_mode": InitMode.NORMAL_LOBBY.value, + "init_mode": 0, "game_type": "custom", "rating_type": "global", } @@ -396,19 +384,15 @@ async def test_command_game_join_uid_as_str( async def test_command_game_join_without_password( lobbyconnection, + game, game_service, test_game_info, players, ): lobbyconnection.send = mock.AsyncMock() lobbyconnection.game_service = game_service - game = mock.create_autospec(Game) game.state = GameState.LOBBY - game.init_mode = InitMode.NORMAL_LOBBY game.password = "password" - game.game_mode = "faf" - game.id = 42 - game.host = players.hosting game_service._games[42] = game lobbyconnection.player = players.joining players.joining.state = PlayerState.IDLE @@ -464,18 +448,14 @@ async def test_command_game_join_game_not_found( async def test_command_game_join_game_not_ready( lobbyconnection, + game, game_service, test_game_info, - players + players, ): lobbyconnection.send = mock.AsyncMock() lobbyconnection.game_service = game_service - game = mock.create_autospec(Game) game.state = GameState.INITIALIZING - game.init_mode = InitMode.NORMAL_LOBBY - game.game_mode = "faf" - game.id = 42 - game.host = players.hosting game_service._games[42] = game lobbyconnection.player = players.joining players.joining.state = PlayerState.IDLE @@ -500,17 +480,15 @@ async def test_command_game_join_game_not_ready( async def test_command_game_join_game_bad_init_mode( lobbyconnection, + game, game_service, test_game_info, players ): lobbyconnection.send = mock.AsyncMock() lobbyconnection.game_service = game_service - game = mock.create_autospec(Game) game.state = GameState.LOBBY game.init_mode = InitMode.AUTO_LOBBY - game.id = 42 - game.host = players.hosting game_service._games[42] = game lobbyconnection.player = players.joining lobbyconnection.player.state = PlayerState.IDLE @@ -552,8 +530,8 @@ async def test_abort(lobbyconnection): async def test_send_game_list(mocker, database, lobbyconnection, game_stats_service): games = mocker.patch.object(lobbyconnection, "game_service") # type: GameService - game1, game2 = mock.create_autospec(Game(42, database, mock.Mock(), game_stats_service)), \ - mock.create_autospec(Game(22, database, mock.Mock(), game_stats_service)) + game1 = mock.create_autospec(Game, id=42) + game2 = mock.create_autospec(Game, id=22) games.open_games = [game1, game2] lobbyconnection.send = mock.AsyncMock() @@ -754,12 +732,10 @@ async def test_command_social_add_friend_idempotent(lobbyconnection, database): async def test_command_social_add_friend_while_hosting( lobbyconnection, + game, database, - game_service, - game_stats_service, ): lobbyconnection.player.id = 1 - game = Game(42, database, game_service, game_stats_service) game.host = lobbyconnection.player lobbyconnection.player.game = game @@ -779,12 +755,10 @@ async def test_command_social_add_friend_while_hosting( async def test_command_social_add_friend_while_hosting_offline( lobbyconnection, + game, database, - game_service, - game_stats_service, ): lobbyconnection.player.id = 1 - game = Game(42, database, game_service, game_stats_service) game.host = lobbyconnection.player lobbyconnection.player.game = game lobbyconnection.player_service.get_player.return_value = None @@ -840,12 +814,10 @@ async def test_command_social_remove_friend_idempotent(lobbyconnection, database async def test_command_social_remove_friend_while_hosting( lobbyconnection, + game, database, - game_service, - game_stats_service, ): lobbyconnection.player.id = 2 - game = Game(42, database, game_service, game_stats_service) game.host = lobbyconnection.player lobbyconnection.player.game = game @@ -865,12 +837,10 @@ async def test_command_social_remove_friend_while_hosting( async def test_command_social_remove_friend_while_hosting_offline( lobbyconnection, + game, database, - game_service, - game_stats_service, ): lobbyconnection.player.id = 2 - game = Game(42, database, game_service, game_stats_service) game.host = lobbyconnection.player lobbyconnection.player.game = game lobbyconnection.player_service.get_player.return_value = None @@ -995,23 +965,18 @@ async def test_game_connection_not_restored_if_no_such_game_exists( @pytest.mark.parametrize("game_state", [GameState.INITIALIZING, GameState.ENDED]) async def test_game_connection_not_restored_if_game_state_prohibits( - lobbyconnection: LobbyConnection, - game_service: GameService, - game_stats_service, + lobbyconnection, + mock_game, + game_service, game_state, - database ): del lobbyconnection.player.game_connection lobbyconnection.send = mock.AsyncMock() lobbyconnection.player.state = PlayerState.IDLE lobbyconnection.game_service = game_service - game = mock.create_autospec(Game(42, database, game_service, game_stats_service)) - game.state = game_state - game.password = None - game.game_mode = "faf" - game.id = 42 - game.players = [lobbyconnection.player] - game_service._games[42] = game + mock_game.state = game_state + mock_game.players = [lobbyconnection.player] + game_service._games[42] = mock_game await lobbyconnection.on_message_received({ "command": "restore_game_session", @@ -1030,22 +995,17 @@ async def test_game_connection_not_restored_if_game_state_prohibits( @pytest.mark.parametrize("game_state", [GameState.LIVE, GameState.LOBBY]) async def test_game_connection_restored_if_game_exists( - lobbyconnection: LobbyConnection, - game_service: GameService, - game_stats_service, + lobbyconnection, + mock_game, + game_service, game_state, - database ): del lobbyconnection.player.game_connection lobbyconnection.player.state = PlayerState.IDLE lobbyconnection.game_service = game_service - game = mock.create_autospec(Game(42, database, game_service, game_stats_service)) - game.state = game_state - game.password = None - game.game_mode = "faf" - game.id = 42 - game.players = [lobbyconnection.player] - game_service._games[42] = game + mock_game.state = game_state + mock_game.players = [lobbyconnection.player] + game_service._games[42] = mock_game await lobbyconnection.on_message_received({ "command": "restore_game_session", @@ -1054,7 +1014,7 @@ async def test_game_connection_restored_if_game_exists( assert lobbyconnection.game_connection assert lobbyconnection.player.state is PlayerState.PLAYING - assert lobbyconnection.player.game is game + assert lobbyconnection.player.game is mock_game async def test_command_invite_to_party(lobbyconnection, mock_player): From a79306dd892603a65ba7469a5cf044de9747a591 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sun, 16 Mar 2025 22:06:54 -0400 Subject: [PATCH 3/3] WIP: Add TypedDict definitions for all server messages --- server/games/game.py | 29 +- server/ladder_service/ladder_service.py | 11 +- server/ladder_service/violation_service.py | 3 +- server/lobbyconnection.py | 83 +- server/matchmaker/matchmaker_queue.py | 3 +- server/party_service.py | 15 +- server/players.py | 40 +- server/protocol/protocol.py | 16 +- server/protocol/qdatastream.py | 9 +- server/protocol/simple_json.py | 8 +- server/team_matchmaker/party_member.py | 3 +- server/team_matchmaker/player_party.py | 3 +- server/types/messages/client.py | 41 + server/types/messages/server.py | 1104 ++++++++++++++++++++ tests/unit_tests/test_players.py | 1 + 15 files changed, 1290 insertions(+), 79 deletions(-) create mode 100644 server/types/messages/server.py diff --git a/server/games/game.py b/server/games/game.py index faedb4065..2ba271bcd 100644 --- a/server/games/game.py +++ b/server/games/game.py @@ -6,7 +6,15 @@ import time from collections import defaultdict from datetime import datetime -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable, Optional +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Iterable, + Literal, + Optional +) from sqlalchemy import and_, bindparam from sqlalchemy.exc import DBAPIError @@ -32,6 +40,7 @@ from server.rating import InclusiveRange, RatingType from server.timing import datetime_now from server.types import MAP_DEFAULT, Map +from server.types.messages.server import GameInfo from ..players import Player, PlayerState from .typedefs import ( @@ -58,6 +67,14 @@ class GameError(Exception): pass +_CLIENT_STATES: dict[GameState, Literal["closed", "open", "playing"]] = { + GameState.LOBBY: "open", + GameState.LIVE: "playing", + GameState.ENDED: "closed", + GameState.INITIALIZING: "closed", +} + + class Game: """ Object that lasts for the lifetime of a game on FAF. @@ -909,13 +926,8 @@ def is_visible_to_player(self, player: Player) -> bool: else: return player.id not in self.host.foes - def to_dict(self): - client_state = { - GameState.LOBBY: "open", - GameState.LIVE: "playing", - GameState.ENDED: "closed", - GameState.INITIALIZING: "closed", - }.get(self.state, "closed") + def to_dict(self) -> GameInfo: + client_state = _CLIENT_STATES.get(self.state, "closed") connected_players = self.get_connected_players() return { "command": "game_info", @@ -949,6 +961,7 @@ def to_dict(self): } for team in self.teams if team is not None ], + # DEPRECATED: Use team_ids instead "teams": { team: [ player.login for player in connected_players diff --git a/server/ladder_service/ladder_service.py b/server/ladder_service/ladder_service.py index 3a5d722ea..619002f8d 100644 --- a/server/ladder_service/ladder_service.py +++ b/server/ladder_service/ladder_service.py @@ -47,6 +47,11 @@ from server.metrics import MatchLaunch from server.players import Player, PlayerState from server.types import GameLaunchOptions, Map, NeroxisGeneratedMap +from server.types.messages.server import ( + MatchCancelled, + MatchFound, + SearchTimeoutTimeout +) if TYPE_CHECKING: from server.lobbyconnection import LobbyConnection @@ -298,7 +303,7 @@ def start_search( timeouts = self.violation_service.get_violations(players) if timeouts: self._logger.debug("timeouts: %s", timeouts) - times = [ + times: list[SearchTimeoutTimeout] = [ { "player": p.id, "expires_at": violation.get_ban_expiration().isoformat() @@ -463,7 +468,7 @@ def on_match_found( so it should only perform fast operations. """ try: - msg = {"command": "match_found", "queue_name": queue.name} + msg: MatchFound = {"command": "match_found", "queue_name": queue.name} for player in s1.players + s2.players: player.state = PlayerState.STARTING_AUTOMATCH @@ -621,7 +626,7 @@ def make_game_options(player: Player) -> GameLaunchOptions: await game.on_game_finish() game_id = game.id if game else None - msg = {"command": "match_cancelled", "game_id": game_id} + msg: MatchCancelled = {"command": "match_cancelled", "game_id": game_id} for player in all_players: player.write_message(msg) diff --git a/server/ladder_service/violation_service.py b/server/ladder_service/violation_service.py index d672adad9..594f36d69 100644 --- a/server/ladder_service/violation_service.py +++ b/server/ladder_service/violation_service.py @@ -10,6 +10,7 @@ from server.decorators import with_logger from server.players import Player from server.timing import at_interval, datetime_now +from server.types.messages.server import SearchViolationBody @dataclass @@ -51,7 +52,7 @@ def is_expired(self, now: Optional[datetime] = None) -> bool: exp = self.time + timedelta(seconds=config.LADDER_VIOLATIONS_RESET_TIME) return exp <= now - def to_dict(self) -> dict: + def to_dict(self) -> SearchViolationBody: return { "count": self.count, "time": self.time.isoformat() diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 47e47a6f8..21cf0930a 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -13,7 +13,7 @@ import urllib.request from datetime import datetime from functools import wraps -from typing import ClassVar, Optional +from typing import ClassVar, Literal, Optional, cast import aiohttp from sqlalchemy import and_, func, select @@ -64,6 +64,7 @@ from .rating_service import RatingService from .types import Address, GameLaunchOptions from .types.messages import client +from .types.messages.server import GameLaunch, ServerMessage def ice_only(func): @@ -320,13 +321,14 @@ async def command_matchmaker_info(self, message: client.MatchmakerInfo): ] }) - async def send_game_list(self): + async def send_game_list(self) -> None: assert self.player is not None await self.send({ "command": "game_info", "games": [ - game.to_dict() for game in self.game_service.open_games + game.to_dict() + for game in self.game_service.open_games if game.is_visible_to_player(self.player) ] }) @@ -427,7 +429,7 @@ def _write_visibility_change_context( player.write_message(msg) - async def kick(self): + async def kick(self) -> None: await self.send({ "command": "notice", "style": "kick", @@ -772,25 +774,22 @@ async def on_player_login( login=username, session=self.session, player_id=player_id, + leaderboards=self.rating_service.leaderboards, + country=self.geoip_service.country(self.peer_address.host), lobby_connection=self, - leaderboards=self.rating_service.leaderboards ) await self.player_service.fetch_player_data(self.player) self.player_service[self.player.id] = self.player self._authenticated = True - # Country - # ------- - self.player.country = self.geoip_service.country(self.peer_address.host) - # Send the player their own player info. await self.send({ "command": "welcome", "me": self.player.to_dict(), "current_time": datetime_now().isoformat(), - # For backwards compatibility for old clients. For now. + # DEPRECATED: Use attributes in `me` instead. "id": self.player.id, "login": username }) @@ -836,15 +835,14 @@ async def on_player_login( if self.player.clan is not None: channels.append(f"#{self.player.clan}_clan") - json_to_send = { + await self.send({ "command": "social", "autojoin": channels, "channels": channels, "friends": friends, "foes": foes, "power": self.player.power() - } - await self.send(json_to_send) + }) await self.send_game_list() @@ -1192,7 +1190,7 @@ def _prepare_launch_game( game: Game, is_host: bool = False, options: GameLaunchOptions = GameLaunchOptions(), - ): + ) -> GameLaunch: assert self.player is not None assert self.game_connection is None assert self.protocol is not None @@ -1219,7 +1217,7 @@ def _prepare_launch_game( self.player.state = PlayerState.STARTING_GAME self.player.game = game - cmd = { + cmd: GameLaunch = { "command": "game_launch", "args": ["/numgames", self.player.game_count[game.rating_type]], "uid": game.id, @@ -1234,10 +1232,10 @@ def _prepare_launch_game( "init_mode": game.init_mode.value, "game_type": game.game_type.value, "rating_type": game.rating_type, - **options._asdict() + **options._asdict(), } - return {k: v for k, v in cmd.items() if v is not None} + return cast(GameLaunch, {k: v for k, v in cmd.items() if v is not None}) # DEPRECATED: Use the FAF API instead async def command_modvault(self, message: client.Modvault): @@ -1257,11 +1255,23 @@ async def command_modvault(self, message: client.Modvault): if icon: thumbstr = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/mods_thumbs/" + urllib.parse.quote(icon)) - out = dict(command="modvault_info", thumbnail=thumbstr, link=link, bugreports=[], - comments=[], description=description, played=played, likes=likes, - downloads=downloads, date=int(date.timestamp()), uid=uid, name=name, version=version, author=author, - ui=ui) - await self.send(out) + await self.send({ + "command": "modvault_info", + "thumbnail": thumbstr, + "link": link, + "bugreports": [], + "comments": [], + "description": description, + "played": played, + "likes": likes, + "downloads": downloads, + "date": int(date.timestamp()), + "uid": uid, + "name": name, + "version": version, + "author": author, + "ui": ui, + }) except Exception: self._logger.error(f"Error handling table_mod row (uid: {uid})", exc_info=True) @@ -1282,11 +1292,6 @@ async def command_modvault(self, message: client.Modvault): if icon: thumbstr = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/mods_thumbs/" + urllib.parse.quote(icon)) - out = dict(command="modvault_info", thumbnail=thumbstr, link=link, bugreports=[], - comments=[], description=description, played=played, likes=likes + 1, - downloads=downloads, date=int(date.timestamp()), uid=uid, name=name, version=version, author=author, - ui=ui) - try: likers = json.loads(likerList) if self.player.id in likers: @@ -1305,7 +1310,23 @@ async def command_modvault(self, message: client.Modvault): l=json.dumps(likers), id=uid ) - await self.send(out) + await self.send({ + "command": "modvault_info", + "thumbnail": thumbstr, + "link": link, + "bugreports": [], + "comments": [], + "description": description, + "played": played, + "likes": likes + 1, + "downloads": downloads, + "date": int(date.timestamp()), + "uid": uid, + "name": name, + "version": version, + "author": author, + "ui": ui, + }) elif type == "download": uid = message["uid"] @@ -1413,7 +1434,7 @@ def write_warning( self, message: str, fatal: bool = False, - style: Optional[str] = None + style: Optional[Literal["error", "info", "kick", "kill"]] = None ): """ Like `send_warning`, but does not await the data to be sent. @@ -1426,14 +1447,14 @@ def write_warning( if fatal: asyncio.create_task(self.abort(message)) - async def send(self, message): + async def send(self, message: ServerMessage) -> None: """Send a message and wait for it to be sent.""" assert self.protocol is not None self.write(message) await self.protocol.drain() - def write(self, message): + def write(self, message: ServerMessage) -> None: """Write a message into the send buffer.""" assert self.protocol is not None diff --git a/server/matchmaker/matchmaker_queue.py b/server/matchmaker/matchmaker_queue.py index 0cc343edd..13e10dfe4 100644 --- a/server/matchmaker/matchmaker_queue.py +++ b/server/matchmaker/matchmaker_queue.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterable, Optional import server.metrics as metrics +from server.types.messages.server import MatchmakerInfoQueue from ..asyncio_extensions import SpinLock, synchronized from ..decorators import with_logger @@ -282,7 +283,7 @@ def shutdown(self): self._is_running = False self.timer.cancel() - def to_dict(self): + def to_dict(self) -> MatchmakerInfoQueue: """ Return a fuzzy representation of the searches currently in the queue """ diff --git a/server/party_service.py b/server/party_service.py index 05acdeb18..53c2d4ed3 100644 --- a/server/party_service.py +++ b/server/party_service.py @@ -3,7 +3,9 @@ """ import logging -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, Iterable, Optional + +from server.types.messages.server import UpdateParty from .core import Service from .decorators import with_logger @@ -11,6 +13,7 @@ from .factions import Faction from .game_service import GameService from .players import Player, PlayerState +from .team_matchmaker.party_member import PartyMember from .team_matchmaker.player_party import PlayerParty from .timing import at_interval @@ -55,13 +58,17 @@ async def update_dirties(self): "Unexpected exception while sending party updates!" ) - def write_broadcast_party(self, party, members=None): + def write_broadcast_party( + self, + party: PlayerParty, + members: Optional[Iterable[PartyMember]] = None, + ) -> None: """ Send a party update to all players in the party """ - if not members: + if members is None: members = iter(party) - msg = { + msg: UpdateParty = { "command": "update_party", **party.to_dict() } diff --git a/server/players.py b/server/players.py index a61aa2f43..e24cf45b1 100644 --- a/server/players.py +++ b/server/players.py @@ -10,6 +10,11 @@ from .factions import Faction from .protocol import DisconnectedError from .rating import Leaderboard, PlayerRatings, RatingType +from .types.messages.server import ( + PlayerInfoPlayer, + PlayerInfoPlayerAvatar, + ServerMessage +) from .weakattr import WeakAttribute if TYPE_CHECKING: @@ -49,6 +54,7 @@ def __init__( leaderboards: dict[str, Leaderboard] = {}, ratings: Optional[PlayerRatings] = None, clan: Optional[str] = None, + country: str = "__", game_count: Optional[dict[str, int]] = None, lobby_connection: Optional["LobbyConnection"] = None ) -> None: @@ -69,9 +75,9 @@ def __init__( self.game_count.update(game_count) # social - self.avatar: Optional[dict[str, str]] = None + self.avatar: Optional[PlayerInfoPlayerAvatar] = None self.clan = clan - self.country: Optional[str] = None + self.country = country self.friends: set[int] = set() self.foes: set[int] = set() @@ -112,7 +118,7 @@ def is_admin(self) -> bool: def is_moderator(self) -> bool: return "faf_moderators_global" in self.user_groups - async def send_message(self, message: dict) -> None: + async def send_message(self, message: ServerMessage) -> None: """ Try to send a message to this player. @@ -124,7 +130,7 @@ async def send_message(self, message: dict) -> None: await self.lobby_connection.send(message) - def write_message(self, message: dict) -> None: + def write_message(self, message: ServerMessage) -> None: """ Try to queue a message to be sent to this player. @@ -136,24 +142,16 @@ def write_message(self, message: dict) -> None: with suppress(DisconnectedError): self.lobby_connection.write(message) - def to_dict(self) -> dict: + def to_dict(self) -> PlayerInfoPlayer: """ Return a dictionary representing this player object """ assert self.state is not None and self.state.value is not None - cmd = { + cmd: PlayerInfoPlayer = { "id": self.id, "login": self.login, - "avatar": self.avatar, "country": self.country, - "clan": self.clan, - # NOTE: We are only sending an 'offline' state for now to signal to - # the client when a player disconnects. However, this could be - # expanded in the future to expose more of the internal state - # tracking to the client to make the UI for showing players in game - # more correct. - "state": None if self.lobby_connection else "offline", "ratings": { rating_type: { "rating": self.ratings[rating_type], @@ -166,7 +164,19 @@ def to_dict(self) -> dict: "ladder_rating": self.ratings[RatingType.LADDER_1V1], "number_of_games": self.game_count[RatingType.GLOBAL], } - return {k: v for k, v in cmd.items() if v is not None} + if self.avatar is not None: + cmd["avatar"] = self.avatar + if self.clan is not None: + cmd["clan"] = self.clan + if self.lobby_connection is None: + # NOTE: We are only sending an 'offline' state for now to signal to + # the client when a player disconnects. However, this could be + # expanded in the future to expose more of the internal state + # tracking to the client to make the UI for showing players in game + # more correct. + cmd["state"] = "offline" + + return cmd def __str__(self) -> str: return (f"Player({self.login}, {self.id}, " diff --git a/server/protocol/protocol.py b/server/protocol/protocol.py index 2f77ace8c..8eeda371a 100644 --- a/server/protocol/protocol.py +++ b/server/protocol/protocol.py @@ -4,6 +4,8 @@ import json from abc import ABCMeta, abstractmethod from asyncio import StreamReader, StreamWriter +from collections.abc import Mapping, Sequence +from typing import Any import server.metrics as metrics @@ -25,7 +27,7 @@ def __init__(self, reader: StreamReader, writer: StreamWriter): @staticmethod @abstractmethod - def encode_message(message: dict) -> bytes: + def encode_message(message: Mapping[str, Any]) -> bytes: """ Encode a message as raw bytes. Can be used along with `*_raw` methods. """ @@ -33,7 +35,7 @@ def encode_message(message: dict) -> bytes: @staticmethod @abstractmethod - def decode_message(data: bytes) -> dict: + def decode_message(data: bytes) -> dict[str, Any]: """ Decode a message from raw bytes. """ @@ -46,7 +48,7 @@ def is_connected(self) -> bool: return not self.writer.is_closing() @abstractmethod - async def read_message(self) -> dict: + async def read_message(self) -> dict[str, Any]: """ Asynchronously read a message from the stream @@ -58,7 +60,7 @@ async def read_message(self) -> dict: """ pass # pragma: no cover - async def send_message(self, message: dict) -> None: + async def send_message(self, message: Mapping[str, Any]) -> None: """ Send a single message in the form of a dictionary @@ -67,7 +69,7 @@ async def send_message(self, message: dict) -> None: """ await self.send_raw(self.encode_message(message)) - async def send_messages(self, messages: list[dict]) -> None: + async def send_messages(self, messages: Sequence[Mapping[str, Any]]) -> None: """ Send multiple messages in the form of a list of dictionaries. @@ -89,7 +91,7 @@ async def send_raw(self, data: bytes) -> None: self.write_raw(data) await self.drain() - def write_message(self, message: dict) -> None: + def write_message(self, message: Mapping[str, Any]) -> None: """ Write a single message into the message buffer. Should be used when sending broadcasts or when sending messages that are triggered by @@ -103,7 +105,7 @@ def write_message(self, message: dict) -> None: self.write_raw(self.encode_message(message)) - def write_messages(self, messages: list[dict]) -> None: + def write_messages(self, messages: Sequence[Mapping[str, Any]]) -> None: """ Write multiple message into the message buffer. diff --git a/server/protocol/qdatastream.py b/server/protocol/qdatastream.py index 999d2313c..77a562f6d 100644 --- a/server/protocol/qdatastream.py +++ b/server/protocol/qdatastream.py @@ -24,7 +24,8 @@ import logging import struct from asyncio import IncompleteReadError -from typing import ClassVar +from collections.abc import Mapping +from typing import Any, ClassVar from server.decorators import with_logger @@ -95,7 +96,7 @@ def pack_message(*args: str) -> bytes: return QDataStreamProtocol.pack_block(msg) @staticmethod - def encode_message(message: dict) -> bytes: + def encode_message(message: Mapping[str, Any]) -> bytes: """ Encodes a python object as a block of QStrings """ @@ -108,7 +109,7 @@ def encode_message(message: dict) -> bytes: return QDataStreamProtocol.pack_message(json_encoder.encode(message)) @staticmethod - def decode_message(data: bytes) -> dict: + def decode_message(data: bytes) -> dict[str, Any]: _, action = QDataStreamProtocol.read_qstring(data) if action in ("PING", "PONG"): return {"command": action.lower()} @@ -128,7 +129,7 @@ def decode_message(data: bytes) -> dict: pass return message - async def read_message(self): + async def read_message(self) -> dict[str, Any]: """ Read a message from the stream diff --git a/server/protocol/simple_json.py b/server/protocol/simple_json.py index 656de65d9..6fd9bdf71 100644 --- a/server/protocol/simple_json.py +++ b/server/protocol/simple_json.py @@ -9,20 +9,22 @@ """ import json +from collections.abc import Mapping +from typing import Any from .protocol import DisconnectedError, Protocol, json_encoder class SimpleJsonProtocol(Protocol): @staticmethod - def encode_message(message: dict) -> bytes: + def encode_message(message: Mapping[str, Any]) -> bytes: return (json_encoder.encode(message) + "\n").encode() @staticmethod - def decode_message(data: bytes) -> dict: + def decode_message(data: bytes) -> dict[str, Any]: return json.loads(data.strip()) - async def read_message(self) -> dict: + async def read_message(self) -> dict[str, Any]: line = await self.reader.readline() if not line: raise DisconnectedError() diff --git a/server/team_matchmaker/party_member.py b/server/team_matchmaker/party_member.py index bd65f4f16..ff1a5e367 100644 --- a/server/team_matchmaker/party_member.py +++ b/server/team_matchmaker/party_member.py @@ -1,6 +1,7 @@ import random from server.players import Player +from server.types.messages.server import UpdatePartyMember from ..factions import Faction @@ -23,7 +24,7 @@ def set_player_faction(self) -> None: self.player.faction = random.choice(self.factions) - def to_dict(self): + def to_dict(self) -> UpdatePartyMember: return { "player": self.player.id, "factions": list(faction.name for faction in self.factions) diff --git a/server/team_matchmaker/player_party.py b/server/team_matchmaker/player_party.py index 4d6665462..4caddf590 100644 --- a/server/team_matchmaker/player_party.py +++ b/server/team_matchmaker/player_party.py @@ -5,6 +5,7 @@ from server.matchmaker import Search from server.players import Player from server.team_matchmaker.party_member import PartyMember +from server.types.messages.server import UpdatePartyBody PARTY_INVITE_TIMEOUT = 60 * 60 * 24 # secs @@ -83,7 +84,7 @@ def clear(self): self._members.clear() self.invited_players.clear() - def to_dict(self): + def to_dict(self) -> UpdatePartyBody: return { "owner": self.owner.id, "members": [m.to_dict() for m in self._members.values()] diff --git a/server/types/messages/client.py b/server/types/messages/client.py index 53a77494f..23f007109 100644 --- a/server/types/messages/client.py +++ b/server/types/messages/client.py @@ -127,6 +127,8 @@ class AdminJoinChannel(TypedDict): class AskSession(TypedDict): """Request session ID information needed by the faf-uid binary. + Responds with a `server.types.messages.server.Session` message. + **Example** ```json { @@ -177,6 +179,8 @@ class Auth(TypedDict): class AvatarListAvatar(TypedDict): """Get a list of available avatars. + Responds with a `server.types.messages.server.Avatar` message. + **Example** ```json { @@ -213,6 +217,9 @@ class AvatarSelect(TypedDict): class CoopList(TypedDict): """Request a list of available coop missions. + Responds with a `server.types.messages.server.CoopInfo` message for each + coop mission. + **Example** ```json { @@ -227,6 +234,9 @@ class CoopList(TypedDict): class GameHost(TypedDict): """Request to host a custom or coop game. + Responds with a `server.types.messages.server.GameLaunch` message signaling + to launch ForgedAlliance. + **Example** ```json { @@ -280,6 +290,12 @@ class GameHost(TypedDict): class GameJoin(TypedDict): """Request to join a custom game. + Responds with a `server.types.messages.server.GameLaunch` message signaling + to launch ForgedAlliance. + + Responds with a `server.types.messages.server.GameJoinFailed` message if the + game could not be joined. + **Example** ```json { @@ -302,6 +318,17 @@ class GameJoin(TypedDict): class GameMatchmaking(TypedDict): """Request to join a matchmaker queue. + Responds with a `server.types.messages.server.SearchInfo` message with state + "start" on success. Each search will conclude with either a + `server.types.messages.server.SearchInfo` message with state "stop" + signaling that the search was cancelled, or a + `server.types.messages.server.MatchFound` message signaling that a match was + found succesfully. + + Responds with a `server.types.messages.server.SearchTimeout` message if the + request failed because some members of the party are actively banned from + the matchmaker. + **Example** ```json { @@ -364,6 +391,8 @@ class Hello(TypedDict): class IceServers(TypedDict): """Request a list of available ICE server credentials. + Responds with a `server.types.messages.server.IceServers` message. + DEPRECATED: ICE servers are handled by the icebreaker service. **Example** @@ -380,6 +409,9 @@ class IceServers(TypedDict): class InviteToParty(TypedDict): """Request to send a party invite to a player. + Sends a `server.types.messages.server.PartyInvite` message to the invited + player. + **Example** ```json { @@ -398,6 +430,9 @@ class InviteToParty(TypedDict): class KickPlayerFromParty(TypedDict): """Request to kick a player from your party. + Sends a `server.types.messages.server.KickedFromParty` message to the kicked + player. + **Example** ```json { @@ -430,6 +465,9 @@ class LeaveParty(TypedDict): class MatchmakerInfo(TypedDict): """Request a list of available matchmaker queues. + Responds with a `server.types.messages.server.MatchmakerInfo` message + describing the currently available matchmaker queues. + **Example** ```json { @@ -459,6 +497,9 @@ class Modvault(TypedDict): """ DEPRECATED: This command will be removed in the future. Use the FAF API instead. + + Responds with a `server.types.messages.server.ModvaultInfo` message for + 'start' and 'like' type. """ command: Literal["modvault"] diff --git a/server/types/messages/server.py b/server/types/messages/server.py new file mode 100644 index 000000000..f30b388d0 --- /dev/null +++ b/server/types/messages/server.py @@ -0,0 +1,1104 @@ +"""Messages originating from the server""" + +from typing import Any, Literal, Optional, TypedDict, Union + +from typing_extensions import NotRequired + +ServerMessage = Union[ + "AuthenticationFailed", + "Avatar", + "CoopInfo", + "Disabled", + "GameInfo", + "GameInfoList", + "GameJoinFailed", + "GameLaunch", + "IceServers", + "Invalid", + "IRCPassword", + "KickedFromParty", + "MatchCancelled", + "MatchFound", + "MatchmakerInfo", + "ModvaultInfo", + "Notice", + "PartyInvite", + "Ping", + "PlayerInfo", + "Pong", + "SearchInfo", + "SearchTimeout", + "SearchViolation", + "Session", + "Social", + "UpdateParty", + "Welcome", +] + + +class AuthenticationFailed(TypedDict): + """Authentication request failed. + + **Example** + ```json + { + "command": "authentication_failed", + "text": "Cannot find user id" + } + ``` + """ + + command: Literal["authentication_failed"] + + text: str + """Reason message to display to the user.""" + + +class Avatar(TypedDict): + """Information about available avatars. + + **Example** + ```json + { + "command": "avatar", + "avatarlist": [ + { + "url": "http://content.faforever.com/avatars/example.png", + "tooltip": "Example" + } + ] + } + ``` + """ + + command: Literal["avatar"] + + avatarlist: list["AvatarAvatarlist"] + """Reason message to display to the user.""" + + +class AvatarAvatarlist(TypedDict): + """Type for `Avatar.avatarlist`.""" + + url: str + """HTTP url for the avatar image.""" + + tooltip: str + """Avatar tooltip.""" + + +class CoopInfo(TypedDict): + """Information about a coop mission. + + **Example** + ```json + { + "command": "coop_info", + "uid": 1234, + "type": "FA Campaign", + "name": "Operation Black Day", + "description": "Description for Operation Black Day", + "filename": "maps/scmp_coop_123.v0002.zip", + "featured_mod": "coop" + } + ``` + """ + + command: Literal["coop_info"] + + uid: int + """Database id.""" + + type: str + """Which campaign the mission belongs to.""" + + name: str + """Mission name.""" + + description: str + """Mission description.""" + + filename: str + """Mission file name.""" + + featured_mod: Literal["coop"] + + +class Disabled(TypedDict): + """The requested functionality has been disabled. + + This can happen when the server is in standby mode during a graceful + shutdown. Certain actions such as hosting and joining games will be disabled. + + **Example** + ```json + { + "command": "disabled", + "request": "game_host" + } + ``` + """ + + command: Literal["disabled"] + + request: str + """The `command` value of the message that was disabled.""" + + +class GameInfo(TypedDict): + """Information about a game. + + **Example** + ```json + { + "command": "game_info", + "visibility": "public", + "password_protected": false, + "uid": 1234, + "title": "200+", + "state": "open", + "game_type": "custom", + "featured_mod": "faf", + "sim_mods": { + "foo-mod-id": "Mod display name" + }, + "mapname": "scmp_001", + "map_file_path": "maps/scmp_001.zip", + "host": "username", + "host_id": 1234, + "num_players": 4, + "max_players": 4, + "hosted_at": "2020-01-01T01:02:03.123456+00:00", + "launched_at": 1234567.891234, + "rating_type": "global", + "rating_min": 200.0, + "rating_max": null, + "enforce_rating_range": false, + "teams_ids": [ + { + "team_id": 1, + "player_ids": [1, 2, 3] + } + ], + "teams": { + "1": ["username", "username2", "username3"] + } + } + ``` + """ + + command: Literal["game_info"] + + visibility: Literal["friends", "public"] + """Game visibility.""" + + password_protected: bool + """Whether or not a password is required to join the game.""" + + uid: int + """Game id.""" + + title: str + """Game title.""" + + state: Literal["closed", "open", "playing"] + """Game state.""" + + game_type: Literal["coop", "custom", "matchmaker"] + """Game type.""" + + featured_mod: str + """Featured mod name.""" + + sim_mods: dict[str, str] + """Mapping of mod uid to display name for each sim mod used.""" + + mapname: str + """Map name.""" + + map_file_path: str + """Map name formatted like `maps/{name}.zip`. + + DEPRECATED: Use `mapname` instead + """ + + host: str + """Username of the host player.""" + + num_players: int + """Number of players connected to the game.""" + + max_players: int + """Total slots for players.""" + + hosted_at: Optional[str] + """The ISO formatted datetime at which the game was hosted or `None` if + the game is still waiting for the host to connect. + """ + + launched_at: Optional[float] + """Unix timestamp when the game was launched or `None` if the game is + still in lobby. + """ + + rating_type: str + """The leaderboard technical name that will be used to rate the game and + enforce the rating range. + """ + + rating_min: Optional[float] + """Minimum displayed rating desired by the host, if any. + + Displayed rating is `mean - (3 * dev)`. + """ + + rating_max: Optional[float] + """Maximum displayed rating desired by the host, if any. + + Displayed rating is `mean - (3 * dev)`. + """ + + enforce_rating_range: bool + """Whether or not min/max_rating will be enforced when players try to join + the game. + """ + + teams_ids: list["GameInfoTeam"] + """Team setup information.""" + + teams: dict[int, list[str]] + """Team setup information. + + DEPRECATED: Use `teams_ids` instead. + """ + + +class GameInfoList(TypedDict): + """Information about multiple games + + **Example** + ```json + { + "command": "game_info", + "games": [ + { + "command": "game_info", + "visibility": "public", + "password_protected": false, + "uid": 1234, + "title": "200+", + "state": "open", + "game_type": "custom", + "featured_mod": "faf", + "sim_mods": { + "foo-mod-id": "Mod display name" + }, + "mapname": "scmp_001", + "map_file_path": "maps/scmp_001.zip", + "host": "username", + "host_id": 1234, + "num_players": 4, + "max_players": 4, + "hosted_at": "2020-01-01T01:02:03.123456+00:00", + "launched_at": 1234567.891234, + "rating_type": "global", + "rating_min": 200.0, + "rating_max": null, + "enforce_rating_range": false, + "teams_ids": [ + { + "team_id": 1, + "player_ids": [1, 2, 3] + } + ], + "teams": { + "1": ["username", "username2", "username3"] + } + } + ] + } + ``` + """ + + command: Literal["game_info"] + + games: list[GameInfo] + + +class GameInfoTeam(TypedDict): + """Type for `GameInfo.teams_ids`.""" + + team_id: int + """Team id.""" + + player_ids: list[int] + """List of team member player ids.""" + + +class GameJoinFailed(TypedDict): + """The attempt to join the game failed. + + **Example** + ```json + { + "command": "game_join_failed", + "reason": "bad_password", + "uid": 1234 + } + ``` + """ + + command: Literal["game_join_failed"] + + reason: Literal["bad_password", "game_not_ready", "host_left_game"] + """Reason code.""" + + uid: int + """The game id that the player failed to join.""" + + +class GameLaunch(TypedDict): + """Tells the client to launch a new ForgedAlliance process. + + **Example** + ```json + { + "command": "game_launch", + "args": ["/numgames", 123], + "uid": 1234, + "mod": "faf", + "name": "Rhiza vs Dostya", + "init_mode": 1, + "game_type": "matchmaker", + "rating_type": "ladder1v1", + "mapname": "scmp_001", + "team": 1, + "faction": 1, + "expected_players": 2, + "map_position": 1, + "game_options": { + "Share": "ShareUntilDeath", + "UnitCap": 500 + } + } + ``` + """ + + command: Literal["game_launch"] + + args: list[Union[str, int]] + """Command line arguments to be passed to ForgedAlliance.exe.""" + + uid: int + """The game id.""" + + mod: str + """The featured mod to use.""" + + name: str + """The game title.""" + + init_mode: int + """ + + DEPRICATED: init_mode can be inferred from game_type""" + + game_type: Literal["coop", "custom", "matchmaker"] + """Game type.""" + + rating_type: str + """The leaderboard technical name that will be used to rate the game.""" + + mapname: NotRequired[str] + """Name of the map to use.""" + + team: NotRequired[int] + """The team to play on. + + Used in AUTO_LOBBY matches to set the team. + """ + + faction: NotRequired[int] + """The faction to use. + + Used in AUTO_LOBBY matches to set the faction. + """ + + expected_players: NotRequired[int] + """Expected number of players. + + Used in AUTO_LOBBY matches to wait for the game to be ready. + """ + + map_position: NotRequired[int] + """The start spot to use. + + Used in AUTO_LOBBY matches to set the start spot. + """ + + game_options: NotRequired[dict[str, Any]] + """Additional game options to set. + + Used in AUTO_LOBBY matches. + """ + + +class IceServers(TypedDict): + """ + DEPRECATED: ICE servers are handled by the icebreaker service. + + **Example** + ```json + { + "command": "ice_servers", + "ice_servers": [] + } + ``` + """ + + command: Literal["ice_servers"] + + ice_servers: list[Any] + """Always empty.""" + + +class Invalid(TypedDict): + """The command sent by the client was invalid. + + **Example** + ```json + { + "command": "invalid" + } + ``` + """ + + command: Literal["invalid"] + + +class IRCPassword(TypedDict): + """The password to use when logging into the IRC server. + + DEPRECATED: The lobby server no longer handles IRC passwords. + + **Example** + ```json + { + "command": "irc_password", + "password": "deprecated" + } + ``` + """ + + command: Literal["irc_password"] + + password: str + """The password.""" + + +class KickedFromParty(TypedDict): + """Sent when the player is kicked from their current party by the owner via + `server.types.messages.client.KickPlayerFromParty`. + + **Example** + ```json + { + "command": "kicked_from_party" + } + ``` + """ + + command: Literal["kicked_from_party"] + + +class MatchCancelled(TypedDict): + """Send when a matchmaker match was unable to start. + + This can be sent either directly after `MatchFound`, in which case the host + failed to setup the lobby and there will be no follow up `GameLaunch` + message, or after `GameLaunch`, in which case the game failed to start and + should be aborted. + + Once `MatchCancelled` has been received, it is important that the client + terminate the running ForgedAlliance.exe process associated with the + cancelled game id. If the game is allowed to stay open, it is possible for + the connection to succeed after the server timeout, and players to end up + playing a matchmaker game that will not be rated and will not be recorded + in the database. + + **Example** + ```json + { + "command": "match_cancelled", + "game_id": 1234 + } + ``` + """ + + command: Literal["match_cancelled"] + + game_id: Optional[int] + """The id of the game that failed to start.""" + + +class MatchFound(TypedDict): + """Send when a matchmaker match has been found. + + This signals the successful end of a matchmaker search attempt, and the + start of an automatch setup. Automatch setup will either end with a + `GameLaunch` message that succesfully starts a game, or a `MatchCancelled` + message signaling that game setup failed. If `MatchCancelled` is received + directly after `MatchFound`, then the host failed to setup the lobby. In + this case there will be no `GameLaunch` message. If `GameLaunch` is recieved + and then `MatchCancelled` is received with the corresponding game id, then + the game failed to start and should be aborted. + + **Example** + ```json + { + "command": "match_found", + "queue_name": "ladder1v1" + } + ``` + """ + + command: Literal["match_found"] + + queue_name: str + """The queue for which a match was found.""" + + +class MatchmakerInfo(TypedDict): + """Information about matchmaker queues. + + **Example** + ```json + { + "command": "matchmaker_info", + "queues": [ + { + "queue_name": "ladder1v1", + "queue_pop_time": "2020-01-01T01:02:03.123456+00:00", + "queue_pop_time_delta": 123.45, + "num_players": 10, + "boundary_80s": [(0, 400), (100, 500)], + "boundary_75": [(100, 300), (200, 400)], + "team_size": 2 + } + ] + } + ``` + """ + + command: Literal["matchmaker_info"] + + queues: list["MatchmakerInfoQueue"] + """Updated queue info for a list of queues. + + This will be a subset of the available queues that have had a state change + since the last update message, so if a queue is missing from the list, it + should still be retained in the client state / UI. + """ + + +class MatchmakerInfoQueue(TypedDict): + """Type for `MatchmakerInfo.queues`.""" + + queue_name: str + """The queue technical name. + + This name is used when requesting to join the queue via + `server.types.messages.client.GameMatchmaking`. + """ + + queue_pop_time: str + """The ISO formatted datetime at which the queue will pop.""" + + queue_pop_time_delta: float + """The number of seconds until the queue will pop. + + It is prefered to use `queue_pop_time` instead as this will avoid any + inconsistencies with network latency, however, if clock sync is an issue on + the local system then `queue_pop_time_delta` can be used to calculate the + approximate pop time. + """ + + num_players: int + """The total number of players currently in the queue.""" + + boundary_80s: list[tuple[int, int]] + """Rating boundaries that achieve roughly 80% quality.""" + + boundary_75s: list[tuple[int, int]] + """Rating boundaries that achieve roughly 75% quality.""" + + team_size: int + """The size of teams that games created by this queue will have. + + This is also the maximum party size allowed when joining the queue. + """ + + +class ModvaultInfo(TypedDict): + """DEPRECATED: Use the API to manage mods.""" + + command: Literal["modvault_info"] + thumbnail: Any + link: Any + bugreports: list[Any] + comments: list[Any] + description: Any + played: Any + likes: Any + downloads: Any + date: Any + uid: Any + name: Any + version: Any + author: Any + ui: Any + + +# DEPRECATED: Notice messages should not be used for new features. Instead, +# implement a feature specific message class with machine friendly error codes. +class Notice(TypedDict): + """Display an informational message to the user. + + **Example** + ```json + { + "command": "notice", + "style": "info", + "text": "Message from server" + } + ``` + """ + + command: Literal["notice"] + + style: Literal["error", "info", "kick", "kill"] + """Styling mode.""" + + text: NotRequired[str] + """The text to display.""" + + +class PartyInvite(TypedDict): + """Sent when someone has invited the current player to join their party. + + **Example** + ```json + { + "command": "party_invite", + "sender": 1234 + } + ``` + """ + + command: Literal["party_invite"] + + sender: int + """The id of the player who sent the party invite.""" + + +class Ping(TypedDict): + """Ping message to create traffic on the network socket. + + **Example** + ```json + { + "command": "ping" + } + ``` + """ + + command: Literal["ping"] + + +class PlayerInfo(TypedDict): + """Information about players. + + **Example** + ```json + { + "command": "player_info" + "players": [ + { + "id": 1234, + "login": "username", + "avatar": "http://content.faforever.com/avatars/example.png", + "country": "US", + "clan": "ACC", + "state": "offline", + "ratings": { + "global": { + "rating": [1000, 100], + "number_of_games": 1234, + }, + "ladder1v1": { + "rating": [800, 150], + "number_of_games": 1234, + }, + "tmm2v2": { + "rating": [1100, 100], + "number_of_games": 1234, + } + }, + "global_rating": [1000, 100], + "ladder_rating": [800, 150], + "number_of_games": 1234 + } + ] + } + ``` + """ + + command: Literal["player_info"] + + players: list["PlayerInfoPlayer"] + """Updated queue info for a list of players. + + This will be a subset of players that have had a state change since the last + update message, so if a player is missing from the list, they should still + be retained in the client state / UI. + + Players should be removed from the client state / UI if the player `state` + is `"offline"`. + """ + + +class PlayerInfoPlayer(TypedDict): + """Type for `PlayerInfo.players`.""" + + id: int + """The player id.""" + + login: str + """The player's username.""" + + avatar: NotRequired["PlayerInfoPlayerAvatar"] + """The currently selected avatar.""" + + country: str + """The two character country code.""" + + clan: NotRequired[str] + """The three character clan code.""" + + state: NotRequired[str] + """The player state. + + Currently only the value 'offline' is sent when a player has disconnected. + """ + + ratings: dict[str, "PlayerInfoPlayerRating"] + """The player's rating information.""" + + global_rating: tuple[float, float] + """The player's global rating. + + DEPRECATED: Use `ratings["global"]["rating"]` instead. + """ + + ladder_rating: tuple[float, float] + """The player's ladder1v1 rating. + + DEPRECATED: Use `ratings["ladder1v1"]["rating"]` instead. + """ + + number_of_games: int + """The total number of global rated games played. + + DEPRECATED: Use `ratings["global"]["number_of_games"]` instead. + """ + + +class PlayerInfoPlayerAvatar(TypedDict): + """Type for `PlayerInfoPlayer.ratings`.""" + + url: str + """The avatar URL.""" + + tooltip: str + """The avatar tooltip to show in the client.""" + + +class PlayerInfoPlayerRating(TypedDict): + """Type for `PlayerInfoPlayer.ratings`.""" + + rating: tuple[float, float] + """The rating mean, deviation.""" + + number_of_games: int + """The total number of games played for this rating type.""" + + +class Pong(TypedDict): + """Sent when a `Ping` message is received. + + **Example** + ```json + { + "command": "pong" + } + ``` + """ + + command: Literal["pong"] + + +class SearchInfo(TypedDict): + """Information about the state of the current player's search in some + matchmaker queue. + + **Example** + ```json + { + "command": "search_info", + "queue_name": "ladder1v1", + "state": "start" + } + ``` + """ + + command: Literal["search_info"] + + queue_name: str + """The name of the queue that the search applies to.""" + + state: Literal["start", "stop"] + """The state of the search.""" + + +class SearchTimeout(TypedDict): + """Sent when a player attempts to join a matchmaker queue, but one or more + players in their party have received a temporary matchmaker ban. + + **Example** + ```json + { + "command": "search_timeout", + "timeouts": [ + { + "player": 1234, + "expires_at": "2020-01-01T01:02:03.123456+00:00" + }, + { + "player": 12345, + "expires_at": "2020-01-01T01:02:03.123456+00:00" + } + ] + } + ``` + """ + + command: Literal["search_timeout"] + + timeouts: list["SearchTimeoutTimeout"] + """List of timeout information for timed out players in the party that + attempted to search. + """ + + +class SearchTimeoutTimeout(TypedDict): + """Type for `SearchTimeout.timeouts`.""" + + player: int + """The player id of the player who received a search timeout.""" + + expires_at: str + """The ISO formatted datetime after which the player will be able to queue + again. + """ + + +class SearchViolationBody(TypedDict): + """Type for `SearchViolation`.""" + + count: int + """The cumulative number of violations received.""" + + time: str + """The ISO formatted datetime after which the player will be able to queue + again. + """ + + +class SearchViolation(SearchViolationBody): + """Sent when a matchmaker violation is registered for the current player. + + **Example** + ```json + { + "command": "search_violation", + "count": 1234, + "time": "2020-01-01T01:02:03.123456+00:00" + } + ``` + """ + + command: Literal["search_violation"] + + +class Session(TypedDict): + """The session id to pass to `faf-uid`. + + **Example** + ```json + { + "command": "session", + "session": 1234 + } + ``` + """ + + command: Literal["session"] + + session: int + """Session id.""" + + +class Social(TypedDict): + """Information about social functions for the player. + + **Example** + ```json + { + "command": "social", + "autojoin": ["aeolus"], + "channels": ["aeolus"], + "friends": [1, 2, 3], + "foes": [4, 5, 6], + "power": 0 + } + ``` + """ + + command: Literal["social"] + + # TODO: Deprecate one of these? + autojoin: list[str] + """A list of IRC channels to join.""" + + channels: NotRequired[list[str]] + """A list of IRC channels to join.""" + + friends: NotRequired[list[int]] + """List of friends ids.""" + + foes: NotRequired[list[int]] + """List of foes ids.""" + + power: NotRequired[int] + """Category to group the player under. + + 0 - Regular user + 1 - Moderator + 2 - Admin + """ + + +class UpdatePartyBody(TypedDict): + """Type for `UpdateParty`.""" + + owner: int + """The player id of the party owner.""" + + members: list["UpdatePartyMember"] + """ + """ + + +class UpdateParty(UpdatePartyBody): + """Sent when a party is updated. + + **Example** + ```json + { + "command": "update_party", + "owner": 1234, + "members": [ + { + "player": 1234, + "factions": ["cybran", "uef"] + }, + { + "player": 12345, + "factions": ["aeon", "seraphim"] + } + ] + } + ``` + """ + + command: Literal["update_party"] + + +class UpdatePartyMember(TypedDict): + """Type for `UpdatePartyBody.members`.""" + + player: int + """The member's player id.""" + + factions: list[str] + """The member's selected faction.""" + + +class Welcome(TypedDict): + """Send after the player has succesfully authenticated. + + **Example** + ```json + { + "command": "welcome" + "me": { + "id": 1234, + "login": "username", + "avatar": "http://content.faforever.com/avatars/example.png", + "country": "US", + "clan": "ACC", + "state": "offline", + "ratings": { + "global": { + "rating": [1000, 100], + "number_of_games": 1234, + }, + "ladder1v1": { + "rating": [800, 150], + "number_of_games": 1234, + }, + "tmm2v2": { + "rating": [1100, 100], + "number_of_games": 1234, + } + }, + "global_rating": [1000, 100], + "ladder_rating": [800, 150], + "number_of_games": 1234 + }, + "current_time": "2020-01-01T01:02:03.123456+00:00", + "id": 1234, + "login": "username" + } + ``` + """ + + command: Literal["welcome"] + + me: PlayerInfoPlayer + """Player information about the logged in user.""" + + current_time: str + """The ISO formatted current datetime.""" + + id: int + """The player id. + + DEPRECATED: Use `me['id']` instead. + """ + + login: str + """The player username. + + DEPRECATED: Use `me['login']` instead. + """ diff --git a/tests/unit_tests/test_players.py b/tests/unit_tests/test_players.py index 67d8341f0..023d9a081 100644 --- a/tests/unit_tests/test_players.py +++ b/tests/unit_tests/test_players.py @@ -91,6 +91,7 @@ def test_serialize(): "id": 42, "login": "Something", "clan": "TOAST", + "country": "__", "state": "offline", "ratings": { "global": {